From 151c0fe151f617917d24b26cacfd92c56c269eb5 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 19 Jun 2026 19:24:30 -0600 Subject: [PATCH 001/284] feat(copilot): add MCP server-managed tools Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../client/monitor/monitor-tool-utils.ts | 136 ----------- .../client/workflow/block-output-utils.ts | 227 ------------------ 2 files changed, 363 deletions(-) delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tool-utils.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/workflow/block-output-utils.ts diff --git a/apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tool-utils.ts b/apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tool-utils.ts deleted file mode 100644 index 927a85e86..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tool-utils.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { - INDICATOR_MONITOR_PROVIDER, - type MonitorWebhookProvider, - PORTFOLIO_MONITOR_PROVIDER, -} from '@/lib/monitors/sources' -import { getCopilotStoreForToolCall } from '@/stores/copilot/store-access' - -export type ListMonitorArgs = { - entityId?: string - blockId?: string -} - -export type ReadMonitorArgs = { - monitorId: string -} - -export type EditMonitorArgs = ReadMonitorArgs & { - monitorDocument: string - documentFormat?: string -} - -export type MonitorRecord = { - monitorId: string - source: MonitorWebhookProvider - workflowId: string - blockId: string - isActive: boolean - providerConfig: { - monitor: { - providerId: string - interval?: string - listing?: Record - indicatorId?: string - serviceId?: string - credentialId?: string - accountId?: string - condition?: unknown - fireMode?: 'edge' | 'while_true' - cooldownSeconds?: number - pollIntervalSeconds?: number - auth?: { - hasEncryptedSecrets?: boolean - encryptedSecretFieldIds?: string[] - } - providerParams?: Record - } - } - createdAt: string - updatedAt: string -} - -export function readStoredToolArgs(toolCallId: string): TArgs | undefined { - try { - const { toolCallsById } = getCopilotStoreForToolCall(toolCallId).getState() - return toolCallsById[toolCallId]?.params as TArgs | undefined - } catch { - return undefined - } -} - -function getListingLabel(listing: Record | null | undefined): string { - if (!listing || typeof listing !== 'object') { - return 'listing' - } - - const name = typeof listing.name === 'string' ? listing.name.trim() : '' - if (name) return name - - const listingType = typeof listing.listing_type === 'string' ? listing.listing_type : '' - if (listingType === 'default') { - const listingId = typeof listing.listing_id === 'string' ? listing.listing_id.trim() : '' - return listingId || 'listing' - } - - const baseId = typeof listing.base_id === 'string' ? listing.base_id.trim() : '' - const quoteId = typeof listing.quote_id === 'string' ? listing.quote_id.trim() : '' - return baseId && quoteId ? `${baseId}/${quoteId}` : baseId || quoteId || 'listing' -} - -export function buildMonitorName(record: MonitorRecord): string { - if (record.source === PORTFOLIO_MONITOR_PROVIDER) { - return `Portfolio state (${record.providerConfig.monitor.accountId || 'account'})` - } - - const indicatorId = record.providerConfig.monitor.indicatorId || 'indicator' - const interval = record.providerConfig.monitor.interval || 'interval' - const listingLabel = getListingLabel(record.providerConfig.monitor.listing) - return `${indicatorId} on ${listingLabel} (${interval})` -} - -export function toMonitorDocumentFields(record: MonitorRecord) { - const monitor = record.providerConfig.monitor - if (record.source === PORTFOLIO_MONITOR_PROVIDER) { - return { - source: PORTFOLIO_MONITOR_PROVIDER, - workflowId: record.workflowId, - blockId: record.blockId, - providerId: monitor.providerId, - serviceId: monitor.serviceId, - credentialId: monitor.credentialId, - accountId: monitor.accountId, - condition: monitor.condition, - fireMode: monitor.fireMode, - cooldownSeconds: monitor.cooldownSeconds, - pollIntervalSeconds: monitor.pollIntervalSeconds, - isActive: record.isActive, - } - } - - return { - source: INDICATOR_MONITOR_PROVIDER, - workflowId: record.workflowId, - blockId: record.blockId, - providerId: monitor.providerId, - interval: monitor.interval, - indicatorId: monitor.indicatorId, - listing: monitor.listing, - isActive: record.isActive, - ...(monitor.providerParams ? { providerParams: monitor.providerParams } : {}), - } -} - -export async function fetchMonitorById(monitorId: string): Promise { - const response = await fetch(`/api/monitors/${encodeURIComponent(monitorId)}`) - const payload = await response.json().catch(() => ({})) - - if (!response.ok) { - throw new Error(payload?.error || `Failed to fetch monitor: ${response.status}`) - } - - if (!payload?.data || typeof payload.data !== 'object') { - throw new Error('Invalid monitor response') - } - - return payload.data as MonitorRecord -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/block-output-utils.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/block-output-utils.ts deleted file mode 100644 index 0ccc62ca7..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/block-output-utils.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { getBlockOutputPaths, getBlockOutputType } from '@/lib/workflows/block-outputs' -import { readWorkflowSnapshot } from '@/lib/yjs/workflow-session' -import { getRegisteredWorkflowSession } from '@/lib/yjs/workflow-session-registry' -import type { Variable } from '@/stores/variables/types' -import { normalizeBlockName } from '@/stores/workflows/utils' -import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' - -export interface WorkflowContext { - blocks: Record - loops: Record - parallels: Record - subBlockValues: Record> -} - -export interface VariableOutput { - id: string - name: string - type: string - tag: string -} - -export interface BlockOutputReference { - path: string - type: string -} - -/** - * Extract sub-block values from a plain blocks record (no Yjs session needed). - * Returns a map of blockId -> { subBlockId -> value }. - * - * This is the pure-data counterpart of `readWorkflowSubBlockValues` which reads - * from a live Yjs session. Use this variant when you already have the blocks - * snapshot in memory (e.g. during YAML export or server-side processing). - */ -export function extractSubBlockValuesFromBlocks( - blocks: Record -): Record> { - const result: Record> = {} - for (const [blockId, block] of Object.entries(blocks)) { - if (block?.subBlocks) { - const blockValues: Record = {} - for (const [subId, sub] of Object.entries(block.subBlocks as Record)) { - if (sub && typeof sub === 'object' && 'value' in sub) { - blockValues[subId] = sub.value - } - } - result[blockId] = blockValues - } - } - return result -} - -/** - * Get subblock values from the Yjs session registry. - * In the Yjs world, subblock values are embedded in the blocks themselves, - * so we extract them from the workflow snapshot. - * - * Accepts an optional pre-fetched `snapshot` parameter. When provided, the - * function skips the Yjs document snapshot entirely, avoiding redundant - * full-document reads when the caller already has the snapshot in hand - * (e.g. from a prior `readWorkflowSnapshot` call in the same tool execution). - */ -export function readWorkflowSubBlockValues( - workflowId: string, - snapshot?: { blocks: Record } -): Record> { - if (snapshot) { - return extractSubBlockValuesFromBlocks(snapshot.blocks) - } - - const session = getRegisteredWorkflowSession(workflowId) - if (!session?.doc) return {} - const liveSnapshot = readWorkflowSnapshot(session.doc) - return extractSubBlockValuesFromBlocks(liveSnapshot.blocks) -} - -export function getMergedSubBlocks( - blocks: Record, - subBlockValues: Record>, - targetBlockId: string -): Record { - const base = blocks[targetBlockId]?.subBlocks || {} - const live = subBlockValues?.[targetBlockId] || {} - const merged: Record = { ...base } - for (const [subId, liveVal] of Object.entries(live)) { - merged[subId] = { ...(base[subId] || {}), value: liveVal } - } - return merged -} - -export function getSubBlockValue( - blocks: Record, - subBlockValues: Record>, - targetBlockId: string, - subBlockId: string -): any { - const live = subBlockValues?.[targetBlockId]?.[subBlockId] - if (live !== undefined) return live - return blocks[targetBlockId]?.subBlocks?.[subBlockId]?.value -} - -export function readWorkflowVariableOutputs( - variablesRecord: Record | null | undefined -): VariableOutput[] { - const varsSnapshot = variablesRecord - if (!varsSnapshot) return [] - const workflowVariables = Object.values(varsSnapshot) as Variable[] - const validVariables = workflowVariables.filter( - (variable: Variable) => variable.name && variable.name.trim() !== '' - ) - return validVariables.map((variable: Variable) => ({ - id: variable.id, - name: variable.name, - type: variable.type, - tag: `variable.${normalizeBlockName(variable.name)}`, - })) -} - -function getSubflowInsidePaths( - blockType: 'loop' | 'parallel', - blockId: string, - loops: Record, - parallels: Record -): string[] { - const paths = ['index'] - if (blockType === 'loop') { - const loopType = loops[blockId]?.loopType || 'for' - if (loopType === 'forEach') { - paths.push('currentItem', 'items') - } - } else { - const parallelType = parallels[blockId]?.parallelType || 'count' - if (parallelType === 'collection') { - paths.push('currentItem', 'items') - } - } - return paths -} - -function formatOutputReferencesWithPrefix( - paths: string[], - blockName: string, - resolveType: (path: string) => string -): BlockOutputReference[] { - const normalizedName = normalizeBlockName(blockName) - return paths.map((path) => ({ - path: `${normalizedName}.${path}`, - type: resolveType(path), - })) -} - -function resolveSubflowOutputType(path: string): string { - if (path === 'index') return 'number' - if (path === 'results' || path === 'items') return 'json' - return 'any' -} - -export function getSubflowInsideOutputReferences( - blockType: 'loop' | 'parallel', - blockId: string, - blockName: string, - loops: Record, - parallels: Record -): BlockOutputReference[] { - return formatOutputReferencesWithPrefix( - getSubflowInsidePaths(blockType, blockId, loops, parallels), - blockName, - resolveSubflowOutputType - ) -} - -export function getSubflowOutsideOutputReferences(blockName: string): BlockOutputReference[] { - return formatOutputReferencesWithPrefix(['results'], blockName, resolveSubflowOutputType) -} - -export function computeBlockOutputReferences( - block: BlockState, - ctx: WorkflowContext, - workflowVariables: VariableOutput[] = [] -): BlockOutputReference[] { - const { blocks, loops, parallels, subBlockValues } = ctx - const blockName = block.name || block.type - - if (block.type === 'loop' || block.type === 'parallel') { - return formatOutputReferencesWithPrefix( - ['results', ...getSubflowInsidePaths(block.type, block.id, loops, parallels)], - blockName, - resolveSubflowOutputType - ) - } - - if (block.type === 'evaluator') { - const metricsValue = getSubBlockValue(blocks, subBlockValues, block.id, 'metrics') - const metricPaths = - metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0 - ? metricsValue - .filter((metric: { name?: string }) => metric?.name) - .map((metric: { name: string }) => metric.name.toLowerCase()) - : null - - if (metricPaths) { - return formatOutputReferencesWithPrefix(metricPaths, blockName, () => 'number') - } - } - - if (block.type === 'variables') { - const variablesValue = getSubBlockValue(blocks, subBlockValues, block.id, 'variables') - const variableNames = - variablesValue && Array.isArray(variablesValue) && variablesValue.length > 0 - ? variablesValue - .filter((assignment: { variableName?: string }) => assignment?.variableName?.trim()) - .map((assignment: { variableName: string }) => assignment.variableName.trim()) - : [] - - return formatOutputReferencesWithPrefix(variableNames, blockName, (path) => { - const variable = workflowVariables.find((entry) => entry.name === path) - return variable?.type || 'any' - }) - } - - const mergedSubBlocks = getMergedSubBlocks(blocks, subBlockValues, block.id) - const outputPaths = getBlockOutputPaths(block.type, mergedSubBlocks, block.triggerMode) - - return formatOutputReferencesWithPrefix(outputPaths, blockName, (path) => - getBlockOutputType(block.type, path, mergedSubBlocks, block.triggerMode) - ) -} From 46a7e74a2d6245217825ff4dc29d50737c9afaf6 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 19 Jun 2026 19:27:47 -0600 Subject: [PATCH 002/284] feat(copilot): route document tools through server reviews Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../copilot/chat/review-session-post.test.ts | 9 +- .../api/copilot/chat/review-session.test.ts | 39 +- .../execute-copilot-server-tool/route.ts | 52 +- .../app/api/knowledge/[id]/route.test.ts | 16 +- .../app/api/knowledge/[id]/route.ts | 24 +- .../app/api/monitors/[id]/route.ts | 264 +------ .../app/api/monitors/update-service.ts | 282 +++++++ .../api/workflows/yaml/export/route.test.ts | 2 +- .../app/api/workflows/yaml/export/route.ts | 2 +- apps/tradinggoose/components/ui/tool-call.tsx | 59 +- .../lib/copilot/chat-replay-safety.test.ts | 2 +- .../lib/copilot/entity-documents.ts | 26 + .../lib/copilot/inline-tool-call.tsx | 82 +- .../lib/copilot/process-contents.ts | 63 +- apps/tradinggoose/lib/copilot/registry.ts | 260 ++++-- .../lib/copilot/review-sessions/types.ts | 2 + .../lib/copilot/runtime-tool-manifest.test.ts | 6 +- .../lib/copilot/tool-prompt-metadata.ts | 58 +- .../lib/copilot/tools/client/base-tool.ts | 30 - .../entities/entity-document-tool-utils.ts | 482 ----------- .../client/entities/entity-document-tools.ts | 585 -------------- .../client/entities/entity-tools.test.ts | 745 ------------------ .../copilot/tools/client/execution-context.ts | 13 + .../tools/client/knowledge/knowledge-base.ts | 129 --- .../tools/client/monitor/edit-monitor.ts | 137 ---- .../tools/client/monitor/list-monitors.ts | 84 -- .../client/monitor/monitor-tools.test.ts | 361 --------- .../tools/client/monitor/read-monitor.ts | 63 -- .../tools/client/server-tool-metadata.ts | 326 ++++++++ .../tools/client/server-tool-response.ts | 47 +- .../workflow/block-output-tools.test.ts | 227 ------ .../workflow/check-deployment-status.ts | 94 --- .../tools/client/workflow/create-workflow.ts | 98 --- .../workflow/edit-workflow-block.test.ts | 166 ---- .../client/workflow/edit-workflow-block.ts | 61 -- .../client/workflow/edit-workflow.test.ts | 497 ------------ .../tools/client/workflow/edit-workflow.ts | 220 ------ .../tools/client/workflow/list-workflows.ts | 57 -- .../client/workflow/read-block-outputs.ts | 145 ---- .../read-block-upstream-references.ts | 226 ------ .../workflow/read-workflow-variables.ts | 60 -- .../tools/client/workflow/read-workflow.ts | 104 --- .../tools/client/workflow/rename-workflow.ts | 129 --- .../workflow/set-workflow-variables.test.ts | 123 --- .../client/workflow/set-workflow-variables.ts | 147 ---- .../workflow/workflow-metadata-tools.test.ts | 183 ----- .../agent/get-agent-accessory-catalog.test.ts | 18 +- .../agent/get-agent-accessory-catalog.ts | 25 +- .../lib/copilot/tools/server/base-tool.ts | 30 + .../server/blocks/block-mermaid-catalog.ts | 2 +- .../server/blocks/get-blocks-metadata.test.ts | 2 +- .../tools/server/entities/custom-tool.ts | 158 ++++ .../copilot/tools/server/entities/index.ts | 42 + .../tools/server/entities/indicator.ts | 185 +++++ .../tools/server/entities/mcp-server.ts | 268 +++++++ .../copilot/tools/server/entities/shared.ts | 319 ++++++++ .../copilot/tools/server/entities/skill.ts | 125 +++ .../server/entities/workflow-variable.test.ts | 168 ++++ .../copilot/tools/server/entities/workflow.ts | 617 +++++++++++++++ .../copilot/tools/server/gdrive/list-files.ts | 31 +- .../copilot/tools/server/gdrive/read-file.ts | 35 +- .../tools/server/knowledge/knowledge-base.ts | 439 +++++------ .../tools/server/monitor/edit-monitor.ts | 57 ++ .../tools/server/monitor/list-monitors.ts | 42 + .../tools/server/monitor/read-monitor.ts | 38 + .../copilot/tools/server/monitor/shared.ts | 135 ++++ .../copilot/tools/server/review-acceptance.ts | 50 ++ .../lib/copilot/tools/server/router.test.ts | 170 +++- .../lib/copilot/tools/server/router.ts | 151 +++- .../tools/server/user/read-credentials.ts | 34 +- .../user/read-environment-variables.test.ts | 27 +- .../server/user/read-environment-variables.ts | 72 +- .../server/user/read-oauth-credentials.ts | 35 +- .../server/user/set-environment-variables.ts | 19 +- .../workflow/block-output-tools.test.ts | 150 ++++ .../workflow/check-deployment-status.ts | 88 +++ .../workflow/edit-workflow-block.test.ts | 23 +- .../server/workflow/edit-workflow-block.ts | 15 +- .../server/workflow/edit-workflow.test.ts | 111 +-- .../tools/server/workflow/edit-workflow.ts | 14 +- .../server/workflow/read-block-outputs.ts | 85 ++ .../read-block-upstream-references.ts | 152 ++++ .../workflow/workflow-mutation-utils.test.ts | 109 +++ .../workflow/workflow-mutation-utils.ts | 55 +- .../lib/copilot/tools/shared/schemas.ts | 41 +- .../copilot/workflow/block-output-utils.ts | 195 +++++ apps/tradinggoose/lib/knowledge/service.ts | 144 ++-- apps/tradinggoose/lib/yjs/entity-session.ts | 13 + apps/tradinggoose/lib/yjs/entity-state.ts | 18 +- .../lib/yjs/server/bootstrap-review-target.ts | 45 +- .../lib/yjs/server/entity-loaders.ts | 32 +- .../tradinggoose/socket-server/routes/http.ts | 1 + .../tradinggoose/stores/copilot/store.test.ts | 197 +++-- apps/tradinggoose/stores/copilot/store.ts | 43 +- .../stores/copilot/tool-registry.test.ts | 181 +++-- .../stores/copilot/tool-registry.ts | 147 ++-- apps/tradinggoose/stores/copilot/types.ts | 2 +- .../hooks/use-user-input-mentions.ts | 1 + 98 files changed, 5351 insertions(+), 6592 deletions(-) create mode 100644 apps/tradinggoose/app/api/monitors/update-service.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/entities/entity-document-tool-utils.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/entities/entity-document-tools.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/entities/entity-tools.test.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/client/execution-context.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/knowledge/knowledge-base.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/monitor/edit-monitor.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/monitor/list-monitors.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tools.test.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/monitor/read-monitor.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/workflow/block-output-tools.test.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/workflow/check-deployment-status.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/workflow/create-workflow.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.test.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.test.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/workflow/list-workflows.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/workflow/read-block-outputs.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/workflow/read-block-upstream-references.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/workflow/read-workflow-variables.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/workflow/read-workflow.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/workflow/rename-workflow.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/workflow/set-workflow-variables.test.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/workflow/set-workflow-variables.ts delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/workflow/workflow-metadata-tools.test.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/entities/index.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/monitor/list-monitors.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/monitor/read-monitor.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/monitor/shared.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/workflow/block-output-tools.test.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/workflow/check-deployment-status.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/workflow/read-block-outputs.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/workflow/read-block-upstream-references.ts create mode 100644 apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.test.ts create mode 100644 apps/tradinggoose/lib/copilot/workflow/block-output-utils.ts diff --git a/apps/tradinggoose/app/api/copilot/chat/review-session-post.test.ts b/apps/tradinggoose/app/api/copilot/chat/review-session-post.test.ts index 0947718e7..d32258538 100644 --- a/apps/tradinggoose/app/api/copilot/chat/review-session-post.test.ts +++ b/apps/tradinggoose/app/api/copilot/chat/review-session-post.test.ts @@ -228,7 +228,14 @@ describe('Copilot Chat POST Generic Sessions', () => { })) vi.doMock('@/lib/copilot/review-sessions/types', () => ({ - REVIEW_ENTITY_KINDS: ['workflow', 'skill', 'custom_tool', 'mcp_server', 'indicator'], + REVIEW_ENTITY_KINDS: [ + 'workflow', + 'skill', + 'custom_tool', + 'mcp_server', + 'indicator', + 'knowledge_base', + ], })) vi.doMock('@/lib/copilot/runtime-provider.server', () => ({ diff --git a/apps/tradinggoose/app/api/copilot/chat/review-session.test.ts b/apps/tradinggoose/app/api/copilot/chat/review-session.test.ts index 8ff3d0832..2ad3f00a6 100644 --- a/apps/tradinggoose/app/api/copilot/chat/review-session.test.ts +++ b/apps/tradinggoose/app/api/copilot/chat/review-session.test.ts @@ -208,24 +208,33 @@ describe('Copilot Chat Review Session GET', () => { createdAt: 'createdAt', updatedAt: 'updatedAt', }, - mapSessionToApiResponse: vi.fn((session: any, opts: { messageCount: number; messages?: any[] }) => ({ - reviewSessionId: session.id, - workspaceId: session.workspaceId, - entityKind: session.entityKind, - entityId: session.entityId, - draftSessionId: session.draftSessionId, - title: session.title, - messages: opts.messages ?? [], - messageCount: opts.messageCount, - conversationId: session.conversationId, - createdAt: session.createdAt, - updatedAt: session.updatedAt, - })), + mapSessionToApiResponse: vi.fn( + (session: any, opts: { messageCount: number; messages?: any[] }) => ({ + reviewSessionId: session.id, + workspaceId: session.workspaceId, + entityKind: session.entityKind, + entityId: session.entityId, + draftSessionId: session.draftSessionId, + title: session.title, + messages: opts.messages ?? [], + messageCount: opts.messageCount, + conversationId: session.conversationId, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }) + ), })) vi.doMock('@/lib/copilot/review-sessions/types', () => ({ ENTITY_KIND_WORKFLOW: 'workflow', - REVIEW_ENTITY_KINDS: ['workflow', 'skill', 'custom_tool', 'mcp_server', 'indicator'], + REVIEW_ENTITY_KINDS: [ + 'workflow', + 'skill', + 'custom_tool', + 'mcp_server', + 'indicator', + 'knowledge_base', + ], })) vi.doMock('@/lib/logs/console/logger', () => ({ @@ -250,7 +259,6 @@ describe('Copilot Chat Review Session GET', () => { vi.doMock('@/app/api/copilot/proxy', () => ({ proxyCopilotRequest: vi.fn(), })) - }) afterEach(() => { @@ -385,5 +393,4 @@ describe('Copilot Chat Review Session GET', () => { expect(mockSelect).toHaveBeenCalledTimes(3) }) - }) diff --git a/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts b/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts index de79c7705..f04c53416 100644 --- a/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts +++ b/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts @@ -16,6 +16,8 @@ const logger = createLogger('ExecuteCopilotServerToolAPI') const ExecuteSchema = z.object({ toolName: z.string().min(1), payload: z.unknown().optional(), + reviewAction: z.enum(['accept']).optional(), + reviewResult: z.unknown().optional(), context: z .object({ contextEntityKind: z.enum(REVIEW_ENTITY_KINDS).optional(), @@ -25,6 +27,17 @@ const ExecuteSchema = z.object({ .optional(), }) +function readPayloadWorkspaceId(payload: unknown): string | undefined { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return undefined + } + + const workspaceId = (payload as { workspaceId?: unknown }).workspaceId + return typeof workspaceId === 'string' && workspaceId.trim().length > 0 + ? workspaceId.trim() + : undefined +} + export async function POST(req: NextRequest) { const tracker = createRequestTracker() let toolName: string | undefined @@ -53,20 +66,33 @@ export async function POST(req: NextRequest) { throw error } toolName = parsedBody.toolName - const { payload, context } = parsedBody + const { payload, context, reviewAction, reviewResult } = parsedBody + const payloadWorkspaceId = readPayloadWorkspaceId(payload) + const contextWorkspaceId = context?.workspaceId?.trim() - const [{ isToolId }, { routeExecution }] = await Promise.all([ - import('@/lib/copilot/registry'), - import('@/lib/copilot/tools/server/router'), - ]) + if (payloadWorkspaceId && contextWorkspaceId && payloadWorkspaceId !== contextWorkspaceId) { + return createBadRequestResponse('workspaceId does not match execution context') + } + + const executionContextInput = + payloadWorkspaceId && !contextWorkspaceId + ? { ...(context ?? {}), workspaceId: payloadWorkspaceId } + : context + + const [{ isToolId }, { routeExecution }, { acceptServerManagedToolReview }] = + await Promise.all([ + import('@/lib/copilot/registry'), + import('@/lib/copilot/tools/server/router'), + import('@/lib/copilot/tools/server/review-acceptance'), + ]) if (!isToolId(toolName)) { return createBadRequestResponse('Invalid request body for execute-copilot-server-tool') } - logger.info(`[${tracker.requestId}] Executing server tool`, { toolName }) - if (context?.workspaceId) { - const workspaceAccess = await checkWorkspaceAccess(context.workspaceId, userId) + logger.info(`[${tracker.requestId}] Executing server tool`, { toolName, reviewAction }) + if (executionContextInput?.workspaceId) { + const workspaceAccess = await checkWorkspaceAccess(executionContextInput.workspaceId, userId) if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { return NextResponse.json( { error: 'Access denied to this workspace', code: 'WORKSPACE_ACCESS_DENIED' }, @@ -75,11 +101,15 @@ export async function POST(req: NextRequest) { } } - const result = await routeExecution(toolName, payload, { + const executionContext = { userId, - ...context, + ...executionContextInput, signal: req.signal, - }) + } + const result = + reviewAction === 'accept' + ? await acceptServerManagedToolReview(toolName, reviewResult, executionContext) + : await routeExecution(toolName, payload, executionContext) try { const resultPreview = JSON.stringify(result).slice(0, 300) diff --git a/apps/tradinggoose/app/api/knowledge/[id]/route.test.ts b/apps/tradinggoose/app/api/knowledge/[id]/route.test.ts index 2c70f5d90..d39053991 100644 --- a/apps/tradinggoose/app/api/knowledge/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/knowledge/[id]/route.test.ts @@ -18,7 +18,7 @@ mockConsoleLogger() vi.mock('@/lib/knowledge/service', () => ({ getKnowledgeBaseById: vi.fn(), - updateKnowledgeBase: vi.fn(), + applyKnowledgeBaseMetadata: vi.fn(), deleteKnowledgeBase: vi.fn(), })) @@ -31,7 +31,7 @@ describe('Knowledge Base By ID API Route', () => { const mockAuth$ = mockAuth() let mockGetKnowledgeBaseById: any - let mockUpdateKnowledgeBase: any + let mockApplyKnowledgeBaseMetadata: any let mockDeleteKnowledgeBase: any let mockCheckKnowledgeBaseAccess: any let mockCheckKnowledgeBaseWriteAccess: any @@ -84,7 +84,7 @@ describe('Knowledge Base By ID API Route', () => { const knowledgeUtils = await import('@/app/api/knowledge/utils') mockGetKnowledgeBaseById = knowledgeService.getKnowledgeBaseById as any - mockUpdateKnowledgeBase = knowledgeService.updateKnowledgeBase as any + mockApplyKnowledgeBaseMetadata = knowledgeService.applyKnowledgeBaseMetadata as any mockDeleteKnowledgeBase = knowledgeService.deleteKnowledgeBase as any mockCheckKnowledgeBaseAccess = knowledgeUtils.checkKnowledgeBaseAccess as any mockCheckKnowledgeBaseWriteAccess = knowledgeUtils.checkKnowledgeBaseWriteAccess as any @@ -218,7 +218,8 @@ describe('Knowledge Base By ID API Route', () => { }) const updatedKnowledgeBase = { ...mockKnowledgeBase, ...validUpdateData } - mockUpdateKnowledgeBase.mockResolvedValueOnce(updatedKnowledgeBase) + mockGetKnowledgeBaseById.mockResolvedValueOnce(mockKnowledgeBase) + mockApplyKnowledgeBaseMetadata.mockResolvedValueOnce(updatedKnowledgeBase) const req = createMockRequest('PUT', validUpdateData) const { PUT } = await import('@/app/api/knowledge/[id]/route') @@ -229,12 +230,12 @@ describe('Knowledge Base By ID API Route', () => { expect(data.success).toBe(true) expect(data.data.name).toBe('Updated Knowledge Base') expect(mockCheckKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-123', 'user-123') - expect(mockUpdateKnowledgeBase).toHaveBeenCalledWith( + expect(mockApplyKnowledgeBaseMetadata).toHaveBeenCalledWith( 'kb-123', { name: validUpdateData.name, description: validUpdateData.description, - chunkingConfig: undefined, + chunkingConfig: mockKnowledgeBase.chunkingConfig, }, expect.any(String) ) @@ -304,7 +305,8 @@ describe('Knowledge Base By ID API Route', () => { knowledgeBase: { id: 'kb-123', userId: 'user-123' }, }) - mockUpdateKnowledgeBase.mockRejectedValueOnce(new Error('Database error')) + mockGetKnowledgeBaseById.mockResolvedValueOnce(mockKnowledgeBase) + mockApplyKnowledgeBaseMetadata.mockRejectedValueOnce(new Error('Yjs apply error')) const req = createMockRequest('PUT', validUpdateData) const { PUT } = await import('@/app/api/knowledge/[id]/route') diff --git a/apps/tradinggoose/app/api/knowledge/[id]/route.ts b/apps/tradinggoose/app/api/knowledge/[id]/route.ts index b06d9fbf4..3e946fafe 100644 --- a/apps/tradinggoose/app/api/knowledge/[id]/route.ts +++ b/apps/tradinggoose/app/api/knowledge/[id]/route.ts @@ -2,9 +2,9 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { + applyKnowledgeBaseMetadata, deleteKnowledgeBase, getKnowledgeBaseById, - updateKnowledgeBase, } from '@/lib/knowledge/service' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' @@ -17,9 +17,12 @@ const UpdateKnowledgeBaseSchema = z.object({ description: z.string().optional(), chunkingConfig: z .object({ - maxSize: z.number(), - minSize: z.number(), - overlap: z.number(), + maxSize: z.number().min(100).max(4000), + minSize: z.number().min(1).max(2000), + overlap: z.number().min(0).max(500), + }) + .refine((data) => data.minSize < data.maxSize, { + message: 'minSize must be less than maxSize', }) .optional(), }) @@ -95,12 +98,17 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: try { const validatedData = UpdateKnowledgeBaseSchema.parse(body) - const updatedKnowledgeBase = await updateKnowledgeBase( + const currentKnowledgeBase = await getKnowledgeBaseById(id) + if (!currentKnowledgeBase) { + return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + } + + const updatedKnowledgeBase = await applyKnowledgeBaseMetadata( id, { - name: validatedData.name, - description: validatedData.description, - chunkingConfig: validatedData.chunkingConfig, + name: validatedData.name ?? currentKnowledgeBase.name, + description: validatedData.description ?? currentKnowledgeBase.description ?? '', + chunkingConfig: validatedData.chunkingConfig ?? currentKnowledgeBase.chunkingConfig, }, requestId ) diff --git a/apps/tradinggoose/app/api/monitors/[id]/route.ts b/apps/tradinggoose/app/api/monitors/[id]/route.ts index 1ad271d75..505c99508 100644 --- a/apps/tradinggoose/app/api/monitors/[id]/route.ts +++ b/apps/tradinggoose/app/api/monitors/[id]/route.ts @@ -1,66 +1,24 @@ -import { isDeepStrictEqual } from 'node:util' import { db, webhook } from '@tradinggoose/db' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { - type IndicatorMonitorProviderConfig, - IndicatorMonitorUpdateSchema, - normalizeIndicatorMonitorConfig, -} from '@/lib/indicators/monitor-config' import { createLogger } from '@/lib/logs/console/logger' -import { - normalizePortfolioMonitorConfig, - type PortfolioMonitorProviderConfig, - PortfolioMonitorUpdateSchema, -} from '@/lib/monitors/portfolio-config' -import { - getMonitorTriggerIdForProvider, - MONITOR_WEBHOOK_PROVIDERS, - type MonitorWebhookProvider, - PORTFOLIO_MONITOR_PROVIDER, -} from '@/lib/monitors/sources' +import { MONITOR_WEBHOOK_PROVIDERS } from '@/lib/monitors/sources' import { generateRequestId } from '@/lib/utils' import { authenticateIndicatorRequest, checkWorkspacePermission } from '@/app/api/indicators/utils' import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' -import { getTradingProviderOAuthServiceId } from '@/providers/trading/providers' -import type { TradingProviderId } from '@/providers/trading/types' import { - ensureMonitorTriggerBlockInDeployedState, - ensureTriggerCapableIndicator, - ensureWorkflowInWorkspace, getMonitorRowById, isMonitorClientError, - loadIndicatorInputMetadata, MonitorRequestError, - resolvePortfolioMonitorAccount, toMonitorRecord, } from '../shared' +import { updateMonitorForUser } from '../update-service' const logger = createLogger('MonitorByIdAPI') export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -type IndicatorUpdatePayload = ReturnType -type PortfolioUpdatePayload = ReturnType -type MonitorUpdatePayload = IndicatorUpdatePayload | PortfolioUpdatePayload - -const parseUpdatePayload = ( - source: MonitorWebhookProvider, - body: unknown -): MonitorUpdatePayload => { - const parsed = - source === PORTFOLIO_MONITOR_PROVIDER - ? PortfolioMonitorUpdateSchema.safeParse(body) - : IndicatorMonitorUpdateSchema.safeParse(body) - - if (!parsed.success) { - throw new MonitorRequestError(parsed.error.errors[0]?.message ?? 'Invalid request') - } - - return parsed.data -} - export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() @@ -112,100 +70,16 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< if ('response' in auth) return auth.response const { id } = await params - const row = await getMonitorRowById(id) - if (!row) { - return NextResponse.json({ error: 'Monitor not found' }, { status: 404 }) - } - const body = await request.json().catch(() => ({})) - const source = row.webhook.provider as MonitorWebhookProvider - const payload = parseUpdatePayload(source, body) - const workspaceId = row.workflow.workspaceId - if (!workspaceId) { - return NextResponse.json({ error: 'Monitor workspace is missing' }, { status: 400 }) - } - if (payload.workspaceId !== workspaceId) { - return NextResponse.json( - { error: 'workspaceId does not match monitor workspace' }, - { status: 400 } - ) - } - - const permission = await checkWorkspacePermission({ - userId: auth.userId, - workspaceId, - requireWrite: true, - responseShape: 'errorOnly', - }) - if (!permission.ok) return permission.response - - const existingConfig = row.webhook.providerConfig as - | IndicatorMonitorProviderConfig - | PortfolioMonitorProviderConfig - const existingMonitor = existingConfig.monitor - if (!existingMonitor) { - return NextResponse.json({ error: 'Invalid existing monitor config' }, { status: 500 }) - } - - const nextWorkflowId = payload.workflowId ?? row.webhook.workflowId - const nextTriggerBlockId = payload.blockId ?? existingMonitor.triggerBlockId - if (!nextTriggerBlockId) { - return NextResponse.json({ error: 'blockId is required' }, { status: 400 }) - } - - const workflowRow = await ensureWorkflowInWorkspace(nextWorkflowId, workspaceId) - if ( - payload.blockId !== undefined || - payload.workflowId !== undefined || - payload.isActive === true - ) { - await ensureMonitorTriggerBlockInDeployedState( - nextWorkflowId, - nextTriggerBlockId, - getMonitorTriggerIdForProvider(source) - ) - } - const nextIsActive = - payload.isActive === undefined - ? row.webhook.isActive - : payload.isActive && workflowRow.isDeployed - - const providerConfig = await buildProviderConfigForUpdate({ - source, - payload, - existingConfig, - nextTriggerBlockId, - workspaceId, + const updatedMonitor = await updateMonitorForUser({ + monitorId: id, userId: auth.userId, + body, requestId, - requireCompleteAuth: nextIsActive, + logger, }) - const [updatedMonitor] = await db - .update(webhook) - .set({ - workflowId: nextWorkflowId, - blockId: null, - providerConfig, - isActive: nextIsActive, - updatedAt: new Date(), - }) - .where( - and( - eq(webhook.id, id), - eq(webhook.provider, source), - eq(webhook.workflowId, row.workflow.id) - ) - ) - .returning() - - void notifyMonitorsReconcile({ requestId, logger }) - - if (!updatedMonitor) { - return NextResponse.json({ error: 'Monitor not found' }, { status: 404 }) - } - - return NextResponse.json({ data: await toMonitorRecord(updatedMonitor) }, { status: 200 }) + return NextResponse.json({ data: updatedMonitor }, { status: 200 }) } catch (error) { const message = error instanceof Error ? error.message : 'Internal server error' logger.error(`[${requestId}] Failed to update monitor`, { error }) @@ -269,127 +143,3 @@ export async function DELETE( return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } - -async function buildProviderConfigForUpdate({ - source, - payload, - existingConfig, - nextTriggerBlockId, - workspaceId, - userId, - requestId, - requireCompleteAuth, -}: { - source: MonitorWebhookProvider - payload: MonitorUpdatePayload - existingConfig: IndicatorMonitorProviderConfig | PortfolioMonitorProviderConfig - nextTriggerBlockId: string - workspaceId: string - userId: string - requestId: string - requireCompleteAuth: boolean -}) { - if (source === PORTFOLIO_MONITOR_PROVIDER) { - const portfolioPayload = payload as PortfolioUpdatePayload - const portfolioConfig = existingConfig as PortfolioMonitorProviderConfig - const existingMonitor = portfolioConfig.monitor - const nextProviderId = portfolioPayload.providerId ?? existingMonitor.providerId - const nextCredentialId = portfolioPayload.credentialId ?? existingMonitor.credentialId - const nextAccountId = portfolioPayload.accountId ?? existingMonitor.accountId - const requestedServiceId = portfolioPayload.serviceId ?? existingMonitor.serviceId - const requestedOAuthServiceId = getTradingProviderOAuthServiceId( - nextProviderId as TradingProviderId, - requestedServiceId - ) - if (!requestedOAuthServiceId) { - throw new MonitorRequestError('Trading provider connection is required') - } - const connectionChanged = - nextProviderId !== existingMonitor.providerId || - requestedOAuthServiceId !== existingMonitor.serviceId || - nextCredentialId !== existingMonitor.credentialId || - nextAccountId !== existingMonitor.accountId - const connection = - requireCompleteAuth || connectionChanged - ? await resolvePortfolioMonitorAccount({ - userId, - providerId: nextProviderId, - serviceId: requestedOAuthServiceId, - credentialId: nextCredentialId, - accountId: nextAccountId, - requestId, - }) - : { - serviceId: existingMonitor.serviceId, - connectionOwnerUserId: existingMonitor.connectionOwnerUserId, - } - - const providerConfig = normalizePortfolioMonitorConfig({ - triggerBlockId: nextTriggerBlockId, - providerId: nextProviderId, - serviceId: connection.serviceId, - credentialId: nextCredentialId, - connectionOwnerUserId: connection.connectionOwnerUserId, - accountId: nextAccountId, - condition: portfolioPayload.condition ?? existingMonitor.condition, - fireMode: portfolioPayload.fireMode ?? existingMonitor.fireMode, - cooldownSeconds: portfolioPayload.cooldownSeconds ?? existingMonitor.cooldownSeconds, - pollIntervalSeconds: - portfolioPayload.pollIntervalSeconds ?? existingMonitor.pollIntervalSeconds, - }) - const shouldPreserveRuntimeState = isDeepStrictEqual( - providerConfig.monitor, - portfolioConfig.monitor - ) - if (shouldPreserveRuntimeState && portfolioConfig.runtimeState !== undefined) { - providerConfig.runtimeState = portfolioConfig.runtimeState - } - return providerConfig - } - - const indicatorPayload = payload as IndicatorUpdatePayload - const existingMonitor = (existingConfig as IndicatorMonitorProviderConfig).monitor - const nextProviderId = indicatorPayload.providerId ?? existingMonitor.providerId - const providerChanged = nextProviderId !== existingMonitor.providerId - const nextIndicatorId = indicatorPayload.indicatorId ?? existingMonitor.indicatorId - const indicatorChanged = nextIndicatorId !== existingMonitor.indicatorId - const authProvided = Object.hasOwn(indicatorPayload, 'auth') - const providerParamsProvided = Object.hasOwn(indicatorPayload, 'providerParams') - const indicatorInputsProvided = Object.hasOwn(indicatorPayload, 'indicatorInputs') - const shouldNormalizeIndicatorInputs = indicatorInputsProvided || indicatorChanged - - await ensureTriggerCapableIndicator(workspaceId, nextIndicatorId) - const indicatorMetadata = shouldNormalizeIndicatorInputs - ? await loadIndicatorInputMetadata(workspaceId, nextIndicatorId) - : null - const nextProviderParams = providerChanged - ? providerParamsProvided - ? (indicatorPayload.providerParams ?? {}) - : undefined - : providerParamsProvided - ? (indicatorPayload.providerParams ?? {}) - : existingMonitor.providerParams - const nextIndicatorInputs = shouldNormalizeIndicatorInputs - ? indicatorInputsProvided - ? (indicatorPayload.indicatorInputs ?? {}) - : {} - : undefined - - const providerConfig = await normalizeIndicatorMonitorConfig({ - triggerBlockId: nextTriggerBlockId, - providerId: nextProviderId, - interval: indicatorPayload.interval ?? existingMonitor.interval, - listingInput: indicatorPayload.listing ?? existingMonitor.listing, - indicatorId: nextIndicatorId, - authInput: authProvided ? indicatorPayload.auth : undefined, - providerParams: nextProviderParams, - indicatorInputs: nextIndicatorInputs, - indicatorInputMeta: indicatorMetadata?.inputMeta, - previousAuth: providerChanged ? undefined : existingMonitor.auth, - requireCompleteAuth, - }) - if (!shouldNormalizeIndicatorInputs && typeof existingMonitor.indicatorInputs !== 'undefined') { - providerConfig.monitor.indicatorInputs = existingMonitor.indicatorInputs - } - return providerConfig -} diff --git a/apps/tradinggoose/app/api/monitors/update-service.ts b/apps/tradinggoose/app/api/monitors/update-service.ts new file mode 100644 index 000000000..688a41895 --- /dev/null +++ b/apps/tradinggoose/app/api/monitors/update-service.ts @@ -0,0 +1,282 @@ +import { isDeepStrictEqual } from 'node:util' +import { db, webhook } from '@tradinggoose/db' +import { and, eq } from 'drizzle-orm' +import { + type IndicatorMonitorProviderConfig, + IndicatorMonitorUpdateSchema, + normalizeIndicatorMonitorConfig, +} from '@/lib/indicators/monitor-config' +import { + normalizePortfolioMonitorConfig, + type PortfolioMonitorProviderConfig, + PortfolioMonitorUpdateSchema, +} from '@/lib/monitors/portfolio-config' +import { + getMonitorTriggerIdForProvider, + type MonitorWebhookProvider, + PORTFOLIO_MONITOR_PROVIDER, +} from '@/lib/monitors/sources' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' +import { getTradingProviderOAuthServiceId } from '@/providers/trading/providers' +import type { TradingProviderId } from '@/providers/trading/types' +import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' +import { + ensureMonitorTriggerBlockInDeployedState, + ensureTriggerCapableIndicator, + ensureWorkflowInWorkspace, + getMonitorRowById, + loadIndicatorInputMetadata, + MonitorRequestError, + resolvePortfolioMonitorAccount, + toMonitorRecord, +} from './shared' + +type IndicatorUpdatePayload = ReturnType +type PortfolioUpdatePayload = ReturnType +type MonitorUpdatePayload = IndicatorUpdatePayload | PortfolioUpdatePayload +type MonitorUpdateLogger = { + warn: (message: string, ...args: unknown[]) => void +} + +const parseUpdatePayload = ( + source: MonitorWebhookProvider, + body: unknown +): MonitorUpdatePayload => { + const parsed = + source === PORTFOLIO_MONITOR_PROVIDER + ? PortfolioMonitorUpdateSchema.safeParse(body) + : IndicatorMonitorUpdateSchema.safeParse(body) + + if (!parsed.success) { + throw new MonitorRequestError(parsed.error.errors[0]?.message ?? 'Invalid request') + } + + return parsed.data +} + +export async function updateMonitorForUser({ + monitorId, + userId, + body, + requestId, + logger, +}: { + monitorId: string + userId: string + body: unknown + requestId: string + logger: MonitorUpdateLogger +}) { + const row = await getMonitorRowById(monitorId) + if (!row) { + throw new MonitorRequestError('Monitor not found', 404) + } + + const source = row.webhook.provider as MonitorWebhookProvider + const payload = parseUpdatePayload(source, body) + const workspaceId = row.workflow.workspaceId + if (!workspaceId) { + throw new MonitorRequestError('Monitor workspace is missing', 400) + } + if (payload.workspaceId !== workspaceId) { + throw new MonitorRequestError('workspaceId does not match monitor workspace', 400) + } + + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.exists || !access.hasAccess) { + throw new MonitorRequestError('Access denied', 403) + } + if (!access.canWrite) { + throw new MonitorRequestError('Write permission required', 403) + } + + const existingConfig = row.webhook.providerConfig as + | IndicatorMonitorProviderConfig + | PortfolioMonitorProviderConfig + const existingMonitor = existingConfig.monitor + if (!existingMonitor) { + throw new Error('Invalid existing monitor config') + } + + const nextWorkflowId = payload.workflowId ?? row.webhook.workflowId + const nextTriggerBlockId = payload.blockId ?? existingMonitor.triggerBlockId + if (!nextTriggerBlockId) { + throw new MonitorRequestError('blockId is required', 400) + } + + const workflowRow = await ensureWorkflowInWorkspace(nextWorkflowId, workspaceId) + if ( + payload.blockId !== undefined || + payload.workflowId !== undefined || + payload.isActive === true + ) { + await ensureMonitorTriggerBlockInDeployedState( + nextWorkflowId, + nextTriggerBlockId, + getMonitorTriggerIdForProvider(source) + ) + } + const nextIsActive = + payload.isActive === undefined ? row.webhook.isActive : payload.isActive && workflowRow.isDeployed + + const providerConfig = await buildProviderConfigForUpdate({ + source, + payload, + existingConfig, + nextTriggerBlockId, + workspaceId, + userId, + requestId, + requireCompleteAuth: nextIsActive, + }) + + const [updatedMonitor] = await db + .update(webhook) + .set({ + workflowId: nextWorkflowId, + blockId: null, + providerConfig, + isActive: nextIsActive, + updatedAt: new Date(), + }) + .where( + and( + eq(webhook.id, monitorId), + eq(webhook.provider, source), + eq(webhook.workflowId, row.workflow.id) + ) + ) + .returning() + + void notifyMonitorsReconcile({ requestId, logger }) + + if (!updatedMonitor) { + throw new MonitorRequestError('Monitor not found', 404) + } + + return toMonitorRecord(updatedMonitor) +} + +async function buildProviderConfigForUpdate({ + source, + payload, + existingConfig, + nextTriggerBlockId, + workspaceId, + userId, + requestId, + requireCompleteAuth, +}: { + source: MonitorWebhookProvider + payload: MonitorUpdatePayload + existingConfig: IndicatorMonitorProviderConfig | PortfolioMonitorProviderConfig + nextTriggerBlockId: string + workspaceId: string + userId: string + requestId: string + requireCompleteAuth: boolean +}) { + if (source === PORTFOLIO_MONITOR_PROVIDER) { + const portfolioPayload = payload as PortfolioUpdatePayload + const portfolioConfig = existingConfig as PortfolioMonitorProviderConfig + const existingMonitor = portfolioConfig.monitor + const nextProviderId = portfolioPayload.providerId ?? existingMonitor.providerId + const nextCredentialId = portfolioPayload.credentialId ?? existingMonitor.credentialId + const nextAccountId = portfolioPayload.accountId ?? existingMonitor.accountId + const requestedServiceId = portfolioPayload.serviceId ?? existingMonitor.serviceId + const requestedOAuthServiceId = getTradingProviderOAuthServiceId( + nextProviderId as TradingProviderId, + requestedServiceId + ) + if (!requestedOAuthServiceId) { + throw new MonitorRequestError('Trading provider connection is required') + } + const connectionChanged = + nextProviderId !== existingMonitor.providerId || + requestedOAuthServiceId !== existingMonitor.serviceId || + nextCredentialId !== existingMonitor.credentialId || + nextAccountId !== existingMonitor.accountId + const connection = + requireCompleteAuth || connectionChanged + ? await resolvePortfolioMonitorAccount({ + userId, + providerId: nextProviderId, + serviceId: requestedOAuthServiceId, + credentialId: nextCredentialId, + accountId: nextAccountId, + requestId, + }) + : { + serviceId: existingMonitor.serviceId, + connectionOwnerUserId: existingMonitor.connectionOwnerUserId, + } + + const providerConfig = normalizePortfolioMonitorConfig({ + triggerBlockId: nextTriggerBlockId, + providerId: nextProviderId, + serviceId: connection.serviceId, + credentialId: nextCredentialId, + connectionOwnerUserId: connection.connectionOwnerUserId, + accountId: nextAccountId, + condition: portfolioPayload.condition ?? existingMonitor.condition, + fireMode: portfolioPayload.fireMode ?? existingMonitor.fireMode, + cooldownSeconds: portfolioPayload.cooldownSeconds ?? existingMonitor.cooldownSeconds, + pollIntervalSeconds: + portfolioPayload.pollIntervalSeconds ?? existingMonitor.pollIntervalSeconds, + }) + const shouldPreserveRuntimeState = isDeepStrictEqual( + providerConfig.monitor, + portfolioConfig.monitor + ) + if (shouldPreserveRuntimeState && portfolioConfig.runtimeState !== undefined) { + providerConfig.runtimeState = portfolioConfig.runtimeState + } + return providerConfig + } + + const indicatorPayload = payload as IndicatorUpdatePayload + const existingMonitor = (existingConfig as IndicatorMonitorProviderConfig).monitor + const nextProviderId = indicatorPayload.providerId ?? existingMonitor.providerId + const providerChanged = nextProviderId !== existingMonitor.providerId + const nextIndicatorId = indicatorPayload.indicatorId ?? existingMonitor.indicatorId + const indicatorChanged = nextIndicatorId !== existingMonitor.indicatorId + const authProvided = Object.hasOwn(indicatorPayload, 'auth') + const providerParamsProvided = Object.hasOwn(indicatorPayload, 'providerParams') + const indicatorInputsProvided = Object.hasOwn(indicatorPayload, 'indicatorInputs') + const shouldNormalizeIndicatorInputs = indicatorInputsProvided || indicatorChanged + + await ensureTriggerCapableIndicator(workspaceId, nextIndicatorId) + const indicatorMetadata = shouldNormalizeIndicatorInputs + ? await loadIndicatorInputMetadata(workspaceId, nextIndicatorId) + : null + const nextProviderParams = providerChanged + ? providerParamsProvided + ? (indicatorPayload.providerParams ?? {}) + : undefined + : providerParamsProvided + ? (indicatorPayload.providerParams ?? {}) + : existingMonitor.providerParams + const nextIndicatorInputs = shouldNormalizeIndicatorInputs + ? indicatorInputsProvided + ? (indicatorPayload.indicatorInputs ?? {}) + : {} + : undefined + + const providerConfig = await normalizeIndicatorMonitorConfig({ + triggerBlockId: nextTriggerBlockId, + providerId: nextProviderId, + interval: indicatorPayload.interval ?? existingMonitor.interval, + listingInput: indicatorPayload.listing ?? existingMonitor.listing, + indicatorId: nextIndicatorId, + authInput: authProvided ? indicatorPayload.auth : undefined, + providerParams: nextProviderParams, + indicatorInputs: nextIndicatorInputs, + indicatorInputMeta: indicatorMetadata?.inputMeta, + previousAuth: providerChanged ? undefined : existingMonitor.auth, + requireCompleteAuth, + }) + if (!shouldNormalizeIndicatorInputs && typeof existingMonitor.indicatorInputs !== 'undefined') { + providerConfig.monitor.indicatorInputs = existingMonitor.indicatorInputs + } + return providerConfig +} diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts index 27d071673..df0e602c5 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts @@ -95,7 +95,7 @@ describe('Workflow YAML Export API Route', () => { loadWorkflowState: loadWorkflowStateMock, })) - vi.doMock('@/lib/copilot/tools/client/workflow/block-output-utils', () => ({ + vi.doMock('@/lib/copilot/workflow/block-output-utils', () => ({ extractSubBlockValuesFromBlocks: vi.fn((blocks: Record) => Object.fromEntries( Object.entries(blocks).map(([blockId, block]) => [ diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts index b65bc902d..9907f1033 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts @@ -8,7 +8,7 @@ import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { simAgentClient } from '@/lib/copilot/agent/client' import { generateRequestId } from '@/lib/utils' import { loadWorkflowState } from '@/lib/workflows/db-helpers' -import { extractSubBlockValuesFromBlocks } from '@/lib/copilot/tools/client/workflow/block-output-utils' +import { extractSubBlockValuesFromBlocks } from '@/lib/copilot/workflow/block-output-utils' import { getAllBlocks } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' import { resolveOutputType } from '@/blocks/utils' diff --git a/apps/tradinggoose/components/ui/tool-call.tsx b/apps/tradinggoose/components/ui/tool-call.tsx index d90fe2169..a1fe59ced 100644 --- a/apps/tradinggoose/components/ui/tool-call.tsx +++ b/apps/tradinggoose/components/ui/tool-call.tsx @@ -76,8 +76,7 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps {toolCall.parameters && Object.keys(toolCall.parameters).length > 0 && (toolCall.name === 'make_api_request' || - toolCall.name === 'set_environment_variables' || - toolCall.name === 'set_workflow_variables') && ( + toolCall.name === 'set_environment_variables') && (
{toolCall.name === 'make_api_request' ? (
@@ -154,62 +153,6 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps })() : null} - {toolCall.name === 'set_workflow_variables' - ? (() => { - const ops = Array.isArray((toolCall.parameters as any).operations) - ? ((toolCall.parameters as any).operations as any[]) - : [] - return ( -
-
-
- Name -
-
- Type -
-
- Value -
-
- {ops.length === 0 ? ( -
- No operations provided -
- ) : ( -
- {ops.map((op, idx) => ( -
-
- - {String(op.name || '')} - -
-
- - {String(op.type || '')} - -
-
- {op.value !== undefined ? ( - - {String(op.value)} - - ) : ( - - )} -
-
- ))} -
- )} -
- ) - })() - : null}
)}
diff --git a/apps/tradinggoose/lib/copilot/chat-replay-safety.test.ts b/apps/tradinggoose/lib/copilot/chat-replay-safety.test.ts index f8a15392c..3ed0edc3b 100644 --- a/apps/tradinggoose/lib/copilot/chat-replay-safety.test.ts +++ b/apps/tradinggoose/lib/copilot/chat-replay-safety.test.ts @@ -35,7 +35,7 @@ describe('chat replay safety', () => { expect( isAcceptedLiveMutationToolCall({ id: 'tool-3b', - name: 'set_workflow_variables', + name: 'edit_workflow_variable', state: 'success', }) ).toBe(true) diff --git a/apps/tradinggoose/lib/copilot/entity-documents.ts b/apps/tradinggoose/lib/copilot/entity-documents.ts index d93412ddb..321fcfb79 100644 --- a/apps/tradinggoose/lib/copilot/entity-documents.ts +++ b/apps/tradinggoose/lib/copilot/entity-documents.ts @@ -3,12 +3,15 @@ export const SKILL_DOCUMENT_FORMAT = 'tg-skill-document-v1' as const export const CUSTOM_TOOL_DOCUMENT_FORMAT = 'tg-custom-tool-document-v1' as const export const INDICATOR_DOCUMENT_FORMAT = 'tg-indicator-document-v1' as const export const MCP_SERVER_DOCUMENT_FORMAT = 'tg-mcp-server-document-v1' as const +export const KNOWLEDGE_BASE_DOCUMENT_FORMAT = 'tg-knowledge-base-document-v1' as const +export const WORKFLOW_VARIABLE_DOCUMENT_FORMAT = 'tg-workflow-variable-document-v1' as const export const ENTITY_DOCUMENT_FORMATS = { skill: SKILL_DOCUMENT_FORMAT, custom_tool: CUSTOM_TOOL_DOCUMENT_FORMAT, indicator: INDICATOR_DOCUMENT_FORMAT, mcp_server: MCP_SERVER_DOCUMENT_FORMAT, + knowledge_base: KNOWLEDGE_BASE_DOCUMENT_FORMAT, } as const export type EntityDocumentKind = keyof typeof ENTITY_DOCUMENT_FORMATS @@ -53,11 +56,26 @@ const McpServerDocumentSchema = z.object({ enabled: z.boolean(), }) +const KnowledgeBaseDocumentSchema = z.object({ + name: z.string().trim().min(1), + description: z.string(), + chunkingConfig: z + .object({ + maxSize: z.number().min(100).max(4000), + minSize: z.number().min(1).max(2000), + overlap: z.number().min(0).max(500), + }) + .refine((data) => data.minSize < data.maxSize, { + message: 'minSize must be less than maxSize', + }), +}) + export const EntityDocumentSchemas = { skill: SkillDocumentSchema, custom_tool: CustomToolDocumentSchema, indicator: IndicatorDocumentSchema, mcp_server: McpServerDocumentSchema, + knowledge_base: KnowledgeBaseDocumentSchema, } as const export type EntityDocumentFields = z.infer< @@ -131,6 +149,12 @@ function normalizeEntityFields( retries: typeof source.retries === 'number' ? source.retries : 3, enabled: typeof source.enabled === 'boolean' ? source.enabled : true, } + case 'knowledge_base': + return { + name: source.name, + description: source.description, + chunkingConfig: source.chunkingConfig, + } } } @@ -175,5 +199,7 @@ export function getEntityDocumentName( return String(normalized.name ?? '') case 'mcp_server': return String(normalized.name ?? '') + case 'knowledge_base': + return String(normalized.name ?? '') } } diff --git a/apps/tradinggoose/lib/copilot/inline-tool-call.tsx b/apps/tradinggoose/lib/copilot/inline-tool-call.tsx index 4edbe2715..193cb5af7 100644 --- a/apps/tradinggoose/lib/copilot/inline-tool-call.tsx +++ b/apps/tradinggoose/lib/copilot/inline-tool-call.tsx @@ -214,7 +214,9 @@ function isEntityReviewKind(entityKind: unknown): entityKind is string { entityKind === 'skill' || entityKind === 'custom_tool' || entityKind === 'indicator' || - entityKind === 'mcp_server' + entityKind === 'mcp_server' || + entityKind === 'knowledge_base' || + entityKind === 'workflow' ) } @@ -239,15 +241,19 @@ function readEntityReviewPayload(toolCall: CopilotToolCall): EntityReviewPayload } const entityLabel = - result?.entityKind === 'custom_tool' - ? 'Custom Tool' - : result?.entityKind === 'mcp_server' - ? 'MCP Server' - : result?.entityKind === 'indicator' - ? 'Indicator' - : result?.entityKind === 'skill' - ? 'Skill' - : 'Entity' + result?.entityKind === 'workflow' && toolCall.name === 'edit_workflow_variable' + ? 'Workflow Variable' + : result?.entityKind === 'custom_tool' + ? 'Custom Tool' + : result?.entityKind === 'mcp_server' + ? 'MCP Server' + : result?.entityKind === 'knowledge_base' + ? 'Knowledge Base' + : result?.entityKind === 'indicator' + ? 'Indicator' + : result?.entityKind === 'skill' + ? 'Skill' + : 'Entity' return { title: toolCall.state === ClientToolCallState.success @@ -394,15 +400,11 @@ export function InlineToolCall({ const isExpandablePending = toolState === 'pending' && - (toolName === 'make_api_request' || - toolName === 'set_environment_variables' || - toolName === 'set_workflow_variables') + (toolName === 'make_api_request' || toolName === 'set_environment_variables') const [expanded, setExpanded] = useState(isExpandablePending) const isExpandableTool = - toolName === 'make_api_request' || - toolName === 'set_environment_variables' || - toolName === 'set_workflow_variables' + toolName === 'make_api_request' || toolName === 'set_environment_variables' const accessLevel = useCopilotStore((s) => s.accessLevel) @@ -507,54 +509,6 @@ export function InlineToolCall({ ) } - if (toolCall.name === 'set_workflow_variables') { - const ops = Array.isArray(params.operations) ? (params.operations as any[]) : [] - return ( -
-
-
- Name -
-
- Type -
-
- Value -
-
- {ops.length === 0 ? ( -
No operations provided
- ) : ( -
- {ops.map((op, idx) => ( -
-
- - {String(op.name || '')} - -
-
- - {String(op.type || '')} - -
-
- {op.value !== undefined ? ( - - {String(op.value)} - - ) : ( - - )} -
-
- ))} -
- )} -
- ) - } - return null } diff --git a/apps/tradinggoose/lib/copilot/process-contents.ts b/apps/tradinggoose/lib/copilot/process-contents.ts index ccb1b8a4e..02b950823 100644 --- a/apps/tradinggoose/lib/copilot/process-contents.ts +++ b/apps/tradinggoose/lib/copilot/process-contents.ts @@ -3,7 +3,6 @@ import { copilotReviewItems, copilotReviewSessions, document, - knowledgeBase, permissions, templates, workflow, @@ -17,13 +16,14 @@ import { verifyWorkflowAccess, } from '@/lib/copilot/review-sessions/permissions' import { REVIEW_ITEM_KINDS } from '@/lib/copilot/review-sessions/thread-history' +import { ENTITY_KIND_KNOWLEDGE_BASE } from '@/lib/copilot/review-sessions/types' import { createLogger } from '@/lib/logs/console/logger' import { buildWorkspaceAccessScope } from '@/lib/permissions/utils' import { escapeRegExp } from '@/lib/utils' import { sanitizeForCopilot } from '@/lib/workflows/json-sanitizer' -import { readWorkflowSnapshot, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' -import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' import { getEntityFields } from '@/lib/yjs/entity-session' +import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' +import { readWorkflowSnapshot, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' import type { ChatContext } from '@/stores/copilot/types' import { readCopilotWorkspaceEntityContext } from '@/widgets/widgets/copilot/workspace-entities' @@ -98,6 +98,8 @@ export async function processContextsServer( if (ctx.kind === 'knowledge' && ctx.knowledgeId) { return await processKnowledgeContext( ctx.knowledgeId, + userId, + ctx.workspaceId ?? workspaceId ?? null, ctx.label ? `@${ctx.label}` : '@' ) } @@ -105,10 +107,7 @@ export async function processContextsServer( return await processBlocksMetadata(ctx.blockTypes ?? [], ctx.label ? `@${ctx.label}` : '@') } if (ctx.kind === 'templates' && ctx.templateId) { - return await processTemplateContext( - ctx.templateId, - ctx.label ? `@${ctx.label}` : '@' - ) + return await processTemplateContext(ctx.templateId, ctx.label ? `@${ctx.label}` : '@') } if (ctx.kind === 'logs' && ctx.executionId) { return await processExecutionLogContext( @@ -223,7 +222,7 @@ async function processEntityContext(params: { } async function readCopilotEntityFieldsFromYjs( - entityKind: 'skill' | 'indicator' | 'custom_tool' | 'mcp_server', + entityKind: 'skill' | 'indicator' | 'custom_tool' | 'knowledge_base' | 'mcp_server', entityId: string, workspaceId: string ): Promise> { @@ -488,23 +487,38 @@ async function processWorkflowContext({ async function processKnowledgeContext( knowledgeBaseId: string, + userId: string, + workspaceId: string | null, tag: string ): Promise { try { - // Load KB metadata - const kbRows = await db - .select({ - id: knowledgeBase.id, - name: knowledgeBase.name, - updatedAt: knowledgeBase.updatedAt, + const access = await verifyReviewTargetAccess( + userId, + { + entityKind: ENTITY_KIND_KNOWLEDGE_BASE, + entityId: knowledgeBaseId, + draftSessionId: null, + reviewSessionId: null, + workspaceId, + yjsSessionId: knowledgeBaseId, + }, + 'read' + ) + if (!access.hasAccess || !access.workspaceId) { + logger.warn('Skipping unauthorized knowledge context', { + knowledgeBaseId, + workspaceId, + userId, }) - .from(knowledgeBase) - .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) - .limit(1) - const kb = kbRows?.[0] - if (!kb) return null + return null + } + + const fields = await readCopilotEntityFieldsFromYjs( + ENTITY_KIND_KNOWLEDGE_BASE, + knowledgeBaseId, + access.workspaceId + ) - // Load up to 20 recent doc filenames const docRows = await db .select({ filename: document.filename }) .from(document) @@ -513,12 +527,15 @@ async function processKnowledgeContext( const sampleDocuments = docRows.map((d: any) => d.filename).filter(Boolean) const summary = { - id: kb.id, - name: kb.name, + id: knowledgeBaseId, + workspaceId: access.workspaceId, + name: fields.name ?? null, + description: fields.description ?? null, + chunkingConfig: fields.chunkingConfig ?? null, docCount: sampleDocuments.length, sampleDocuments, } - const content = JSON.stringify(summary) + const content = JSON.stringify(summary, null, 2) return { type: 'knowledge', tag, content } } catch (error) { logger.error('Error processing knowledge context', { knowledgeBaseId, error }) diff --git a/apps/tradinggoose/lib/copilot/registry.ts b/apps/tradinggoose/lib/copilot/registry.ts index 2ce13c9b7..c45d96153 100644 --- a/apps/tradinggoose/lib/copilot/registry.ts +++ b/apps/tradinggoose/lib/copilot/registry.ts @@ -2,15 +2,16 @@ import { z } from 'zod' import { CUSTOM_TOOL_DOCUMENT_FORMAT, INDICATOR_DOCUMENT_FORMAT, + KNOWLEDGE_BASE_DOCUMENT_FORMAT, MCP_SERVER_DOCUMENT_FORMAT, SKILL_DOCUMENT_FORMAT, + WORKFLOW_VARIABLE_DOCUMENT_FORMAT, } from '@/lib/copilot/entity-documents' import { MONITOR_DOCUMENT_FORMAT } from '@/lib/copilot/monitor/monitor-documents' import { TG_MERMAID_DOCUMENT_FORMAT, WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT, } from '@/lib/workflows/document-format' -import { WORKFLOW_VARIABLE_TYPES, type WorkflowVariableType } from '@/lib/workflows/value-types' import { GetAgentAccessoryCatalogInput, GetAgentAccessoryCatalogResult, @@ -22,18 +23,12 @@ import { GetIndicatorCatalogResult, GetIndicatorMetadataInput, GetIndicatorMetadataResult, - KnowledgeBaseArgsSchema, - KnowledgeBaseResultSchema, ReadBlockOutputsInput, ReadBlockOutputsResult, ReadBlockUpstreamReferencesInput, ReadBlockUpstreamReferencesResult, } from './tools/shared/schemas' -const WorkflowVariableTypeSchema = z.enum( - WORKFLOW_VARIABLE_TYPES as [WorkflowVariableType, ...WorkflowVariableType[]] -) - // Tool IDs supported by the Copilot runtime export const COPILOT_TOOL_IDS = [ 'plan', @@ -59,12 +54,16 @@ export const COPILOT_TOOL_IDS = [ 'read_oauth_credentials', 'read_credentials', 'list_workflows', - 'read_workflow_variables', - 'set_workflow_variables', + 'edit_workflow_variable', 'oauth_request_access', 'deploy_workflow', 'check_deployment_status', - 'knowledge_base', + 'list_knowledge_bases', + 'read_knowledge_base', + 'create_knowledge_base', + 'edit_knowledge_base', + 'rename_knowledge_base', + 'query_knowledge_base', 'list_custom_tools', 'read_custom_tool', 'create_custom_tool', @@ -124,6 +123,9 @@ const OptionalEntityTargetArgs = z.object({ const EntityTargetArgs = z.object({ entityId: RequiredId, }) +const WorkspaceTargetArgs = z.object({ + workspaceId: RequiredId, +}) function buildEntityDocumentMutationArgs( documentFormat: TDocumentFormat @@ -139,12 +141,10 @@ function buildEntityDocumentMutationArgs( function buildEntityDocumentCreateArgs( documentFormat: TDocumentFormat ) { - return z - .object({ - entityDocument: z.string().min(1), - documentFormat: z.literal(documentFormat).optional(), - }) - .strict() + return WorkspaceTargetArgs.extend({ + entityDocument: z.string().min(1), + documentFormat: z.literal(documentFormat).optional(), + }).strict() } const CreateWorkflowArgs = z @@ -152,7 +152,7 @@ const CreateWorkflowArgs = z name: z.string().trim().min(1).optional(), description: z.string().optional(), folderId: z.string().nullable().optional(), - workspaceId: RequiredId.optional(), + workspaceId: RequiredId, }) .strict() @@ -221,8 +221,7 @@ const CustomToolDocumentMutationShape = { const EditCustomToolArgs = EntityTargetArgs.extend(CustomToolDocumentMutationShape) .strict() .describe('Update a saved custom tool by replacing the full custom-tool document.') -const CreateCustomToolArgs = z - .object(CustomToolDocumentMutationShape) +const CreateCustomToolArgs = WorkspaceTargetArgs.extend(CustomToolDocumentMutationShape) .strict() .describe('Create a custom tool from the full custom-tool document.') const GetIndicatorArgs = z @@ -250,6 +249,40 @@ const EditSkillArgs = buildEntityDocumentMutationArgs(SKILL_DOCUMENT_FORMAT) const CreateSkillArgs = buildEntityDocumentCreateArgs(SKILL_DOCUMENT_FORMAT) const EditMcpServerArgs = buildEntityDocumentMutationArgs(MCP_SERVER_DOCUMENT_FORMAT) const CreateMcpServerArgs = buildEntityDocumentCreateArgs(MCP_SERVER_DOCUMENT_FORMAT) +const EditWorkflowVariableArgs = EntityTargetArgs.extend({ + entityDocument: z + .string() + .min(1) + .describe( + 'Full `tg-workflow-variable-document-v1` JSON document for workflow variables: {"variables":[{"name":"riskLimit","type":"number","value":100}]}. This is a full replacement document; omit a variable to delete it.' + ), + documentFormat: z.literal(WORKFLOW_VARIABLE_DOCUMENT_FORMAT).optional(), +}).strict() +const KnowledgeBaseDocumentMutationShape = { + entityDocument: z + .string() + .min(1) + .describe( + 'Full `tg-knowledge-base-document-v1` JSON document with exactly `name`, `description`, and `chunkingConfig`: {"name":"Research","description":"","chunkingConfig":{"maxSize":1024,"minSize":1,"overlap":200}}.' + ), + documentFormat: z.literal(KNOWLEDGE_BASE_DOCUMENT_FORMAT).optional(), +} +const CreateKnowledgeBaseArgs = WorkspaceTargetArgs.extend(KnowledgeBaseDocumentMutationShape) + .strict() + .describe('Create a knowledge base in a workspace from the full knowledge-base document.') +const EditKnowledgeBaseArgs = EntityTargetArgs.extend(KnowledgeBaseDocumentMutationShape) + .strict() + .describe('Update a knowledge base by replacing the full knowledge-base document.') +const RenameKnowledgeBaseArgs = EditKnowledgeBaseArgs.describe( + 'Rename a knowledge base by replacing the full knowledge-base document with an updated `name`.' +) +const QueryKnowledgeBaseArgs = z + .object({ + entityId: RequiredId, + query: z.string().trim().min(1), + topK: z.number().min(1).max(50).optional(), + }) + .strict() // Tool argument schemas for the Studio runtime tool surface export const ToolArgSchemas = { @@ -279,21 +312,8 @@ export const ToolArgSchemas = { }) .strict(), create_workflow: CreateWorkflowArgs, - [CopilotTool.list_workflows]: z.object({}), - [CopilotTool.read_workflow_variables]: z.object({ - entityId: RequiredId, - }), - [CopilotTool.set_workflow_variables]: z.object({ - entityId: RequiredId, - operations: z.array( - z.object({ - operation: z.enum(['add', 'delete', 'edit']), - name: z.string(), - type: WorkflowVariableTypeSchema.optional(), - value: z.string().optional(), - }) - ), - }), + [CopilotTool.list_workflows]: WorkspaceTargetArgs.strict(), + [CopilotTool.edit_workflow_variable]: EditWorkflowVariableArgs, oauth_request_access: z.object({ providerName: z.string().optional(), }), @@ -357,44 +377,50 @@ export const ToolArgSchemas = { body: z.union([z.record(z.any()), z.string()]).optional(), }), - [CopilotTool.read_environment_variables]: OptionalEntityTargetArgs, + [CopilotTool.read_environment_variables]: WorkspaceTargetArgs.strict(), - set_environment_variables: OptionalEntityTargetArgs.extend({ - variables: z.record(z.string()), - }), + set_environment_variables: z + .object({ + variables: z.record(z.string()), + }) + .strict(), - [CopilotTool.read_oauth_credentials]: OptionalEntityTargetArgs, + [CopilotTool.read_oauth_credentials]: WorkspaceTargetArgs.strict(), - [CopilotTool.read_credentials]: OptionalEntityTargetArgs, + [CopilotTool.read_credentials]: WorkspaceTargetArgs.strict(), gdrive_request_access: z.object({}), - list_gdrive_files: OptionalEntityTargetArgs.extend({ + list_gdrive_files: WorkspaceTargetArgs.extend({ credentialId: z.string(), search_query: z.string().optional(), num_results: z.number().optional().default(50), - }), + }).strict(), - read_gdrive_file: z.object({ + read_gdrive_file: WorkspaceTargetArgs.extend({ credentialId: z.string(), fileId: z.string(), type: z.enum(['doc', 'sheet']), range: z.string().optional(), - entityId: z.string().optional(), - }), + }).strict(), - knowledge_base: KnowledgeBaseArgsSchema, + list_knowledge_bases: WorkspaceTargetArgs.strict(), + read_knowledge_base: EntityTargetArgs, + create_knowledge_base: CreateKnowledgeBaseArgs, + edit_knowledge_base: EditKnowledgeBaseArgs, + rename_knowledge_base: RenameKnowledgeBaseArgs, + query_knowledge_base: QueryKnowledgeBaseArgs, - list_custom_tools: z.object({}), + list_custom_tools: WorkspaceTargetArgs.strict(), [CopilotTool.read_custom_tool]: EntityTargetArgs, create_custom_tool: CreateCustomToolArgs, edit_custom_tool: EditCustomToolArgs, rename_custom_tool: EditCustomToolArgs, - list_monitors: z.object({ + list_monitors: WorkspaceTargetArgs.extend({ entityId: z.string().optional(), blockId: z.string().optional(), - }), + }).strict(), [CopilotTool.read_monitor]: z.object({ monitorId: RequiredId, }), @@ -404,19 +430,19 @@ export const ToolArgSchemas = { documentFormat: z.literal(MONITOR_DOCUMENT_FORMAT).optional(), }), - [CopilotTool.list_indicators]: z.object({}), + [CopilotTool.list_indicators]: WorkspaceTargetArgs.strict(), [CopilotTool.read_indicator]: GetIndicatorArgs, create_indicator: CreateIndicatorArgs, edit_indicator: EditIndicatorArgs, rename_indicator: EditIndicatorArgs, - list_skills: z.object({}), + list_skills: WorkspaceTargetArgs.strict(), [CopilotTool.read_skill]: EntityTargetArgs, create_skill: CreateSkillArgs, edit_skill: EditSkillArgs, rename_skill: EditSkillArgs, - list_mcp_servers: z.object({}), + list_mcp_servers: WorkspaceTargetArgs.strict(), [CopilotTool.read_mcp_server]: EntityTargetArgs, create_mcp_server: CreateMcpServerArgs, edit_mcp_server: EditMcpServerArgs, @@ -439,12 +465,8 @@ export const ToolArgSchemas = { }), } as const -const CurrentWorkflowStateArg = { currentWorkflowState: z.string().min(1) } - export const ServerToolArgSchemas = { ...ToolArgSchemas, - edit_workflow: EditWorkflowArgs.extend(CurrentWorkflowStateArg), - edit_workflow_block: EditWorkflowBlockArgs.extend(CurrentWorkflowStateArg), } satisfies Record // Tool-specific SSE schemas (tool_call with typed arguments) @@ -476,13 +498,9 @@ export const ToolSSESchemas = { CopilotTool.list_workflows, ToolArgSchemas.list_workflows ), - [CopilotTool.read_workflow_variables]: toolCallSSEFor( - CopilotTool.read_workflow_variables, - ToolArgSchemas.read_workflow_variables - ), - [CopilotTool.set_workflow_variables]: toolCallSSEFor( - CopilotTool.set_workflow_variables, - ToolArgSchemas.set_workflow_variables + [CopilotTool.edit_workflow_variable]: toolCallSSEFor( + CopilotTool.edit_workflow_variable, + ToolArgSchemas.edit_workflow_variable ), edit_workflow: toolCallSSEFor('edit_workflow', ToolArgSchemas.edit_workflow), edit_workflow_block: toolCallSSEFor('edit_workflow_block', ToolArgSchemas.edit_workflow_block), @@ -543,7 +561,18 @@ export const ToolSSESchemas = { 'check_deployment_status', ToolArgSchemas.check_deployment_status ), - knowledge_base: toolCallSSEFor('knowledge_base', ToolArgSchemas.knowledge_base), + list_knowledge_bases: toolCallSSEFor('list_knowledge_bases', ToolArgSchemas.list_knowledge_bases), + read_knowledge_base: toolCallSSEFor('read_knowledge_base', ToolArgSchemas.read_knowledge_base), + create_knowledge_base: toolCallSSEFor( + 'create_knowledge_base', + ToolArgSchemas.create_knowledge_base + ), + edit_knowledge_base: toolCallSSEFor('edit_knowledge_base', ToolArgSchemas.edit_knowledge_base), + rename_knowledge_base: toolCallSSEFor( + 'rename_knowledge_base', + ToolArgSchemas.rename_knowledge_base + ), + query_knowledge_base: toolCallSSEFor('query_knowledge_base', ToolArgSchemas.query_knowledge_base), list_custom_tools: toolCallSSEFor('list_custom_tools', ToolArgSchemas.list_custom_tools), [CopilotTool.read_custom_tool]: toolCallSSEFor( CopilotTool.read_custom_tool, @@ -646,8 +675,19 @@ const WorkflowSummaryResult = z.object({ ), }) +const WorkflowVariableReadEnvelope = z.object({ + workflowVariableDocumentFormat: z.literal(WORKFLOW_VARIABLE_DOCUMENT_FORMAT), + workflowVariableDocument: z.string(), +}) + const WorkflowReadDocumentEnvelope = WorkflowDocumentEnvelope.extend({ workflowSummary: WorkflowSummaryResult, +}).merge(WorkflowVariableReadEnvelope) + +const WorkflowVariableDocumentEnvelope = WorkflowTargetEnvelope.extend({ + documentFormat: z.literal(WORKFLOW_VARIABLE_DOCUMENT_FORMAT), + entityDocument: z.string(), + variables: z.record(z.any()), }) const GenericEntityListEntry = z.object({ @@ -668,6 +708,56 @@ const GenericEntityListResult = z.object({ count: z.number(), }) +const KnowledgeBaseListEntry = z.object({ + entityId: z.string(), + entityName: z.string(), + workspaceId: z.string(), + entityDescription: z.string().optional(), + docCount: z.number(), + tokenCount: z.number(), + embeddingModel: z.string(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), +}) + +const KnowledgeBaseListResult = z.object({ + entityKind: z.literal('knowledge_base'), + entities: z.array(KnowledgeBaseListEntry), + count: z.number(), +}) + +const KnowledgeBaseDocumentEnvelope = z.object({ + entityKind: z.literal('knowledge_base'), + entityId: z.string(), + entityName: z.string().optional(), + workspaceId: z.string().optional(), + documentFormat: z.literal(KNOWLEDGE_BASE_DOCUMENT_FORMAT), + entityDocument: z.string(), + docCount: z.number().optional(), + tokenCount: z.number().optional(), + embeddingModel: z.string().optional(), + embeddingDimension: z.number().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), +}) + +const QueryKnowledgeBaseResult = z.object({ + entityKind: z.literal('knowledge_base'), + entityId: z.string(), + entityName: z.string().optional(), + query: z.string(), + topK: z.number(), + totalResults: z.number(), + results: z.array( + z.object({ + documentId: z.string(), + content: z.string(), + chunkIndex: z.number(), + similarity: z.number(), + }) + ), +}) + const IndicatorListEntry = z.object({ name: z.string(), source: z.enum(['default', 'custom']), @@ -705,9 +795,13 @@ const MonitorListEntry = z.object({ monitorDescription: z.string().optional(), workflowId: z.string(), blockId: z.string(), + source: z.string().optional(), providerId: z.string(), - indicatorId: z.string(), - interval: z.string(), + indicatorId: z.string().optional(), + interval: z.string().optional(), + serviceId: z.string().optional(), + credentialId: z.string().optional(), + accountId: z.string().optional(), isActive: z.boolean(), createdAt: z.string().optional(), updatedAt: z.string().optional(), @@ -736,6 +830,7 @@ const McpServerDocumentEnvelope = EntityDocumentEnvelopeBase.extend({ }) const EditEntityDocumentResultBase = z.object({ + requiresReview: z.literal(true).optional(), success: z.boolean(), preview: z .object({ @@ -769,6 +864,12 @@ const SkillDocumentMutationResult = EditEntityDocumentResultBase.merge( }) ) +const KnowledgeBaseDocumentMutationResult = EditEntityDocumentResultBase.merge( + KnowledgeBaseDocumentEnvelope.extend({ + entityId: z.string().optional(), + }) +) + const McpServerDocumentMutationResult = EditEntityDocumentResultBase.merge( McpServerDocumentEnvelope.extend({ entityKind: z.literal('mcp_server'), @@ -783,6 +884,7 @@ const WorkflowPreviewEdge = z.object({ }) const WorkflowMutationResultShape = { + requiresReview: z.literal(true).optional(), workflowState: z.unknown().optional(), preview: z .object({ @@ -808,6 +910,18 @@ const WorkflowMutationResultShape = { const EditWorkflowResult = WorkflowGraphDocumentEnvelope.extend(WorkflowMutationResultShape) const EditWorkflowBlockResult = WorkflowDocumentEnvelope.extend(WorkflowMutationResultShape) +const EditWorkflowVariableResult = WorkflowVariableDocumentEnvelope.extend({ + requiresReview: z.literal(true).optional(), + success: z.boolean().optional(), + preview: z + .object({ + documentDiff: z.object({ + before: z.string(), + after: z.string(), + }), + }) + .optional(), +}) const ExecutionEntry = z.object({ id: z.string(), @@ -847,12 +961,7 @@ export const ToolResultSchemas = { [CopilotTool.list_workflows]: GenericEntityListResult.extend({ entityKind: z.literal('workflow'), }), - [CopilotTool.read_workflow_variables]: z - .object({ variables: z.record(z.any()) }) - .or(z.array(z.object({ name: z.string(), value: z.any() }))), - [CopilotTool.set_workflow_variables]: z - .object({ variables: z.record(z.any()) }) - .or(z.object({ message: z.any().optional(), data: z.any().optional() })), + [CopilotTool.edit_workflow_variable]: EditWorkflowVariableResult, oauth_request_access: z.object({ granted: z.boolean().optional(), message: z.string().optional(), @@ -986,7 +1095,12 @@ export const ToolResultSchemas = { chatDeployed: z.boolean(), deployedAt: z.string().nullable(), }), - knowledge_base: KnowledgeBaseResultSchema, + list_knowledge_bases: KnowledgeBaseListResult, + read_knowledge_base: KnowledgeBaseDocumentEnvelope, + create_knowledge_base: KnowledgeBaseDocumentMutationResult, + edit_knowledge_base: KnowledgeBaseDocumentMutationResult, + rename_knowledge_base: KnowledgeBaseDocumentMutationResult, + query_knowledge_base: QueryKnowledgeBaseResult, list_custom_tools: GenericEntityListResult.extend({ entityKind: z.literal('custom_tool'), }), diff --git a/apps/tradinggoose/lib/copilot/review-sessions/types.ts b/apps/tradinggoose/lib/copilot/review-sessions/types.ts index c16c23dd5..9a9b67d79 100644 --- a/apps/tradinggoose/lib/copilot/review-sessions/types.ts +++ b/apps/tradinggoose/lib/copilot/review-sessions/types.ts @@ -3,6 +3,7 @@ export const ENTITY_KIND_MCP_SERVER = 'mcp_server' as const export const ENTITY_KIND_SKILL = 'skill' as const export const ENTITY_KIND_CUSTOM_TOOL = 'custom_tool' as const export const ENTITY_KIND_INDICATOR = 'indicator' as const +export const ENTITY_KIND_KNOWLEDGE_BASE = 'knowledge_base' as const export const REVIEW_ENTITY_KINDS = [ ENTITY_KIND_WORKFLOW, @@ -10,6 +11,7 @@ export const REVIEW_ENTITY_KINDS = [ ENTITY_KIND_SKILL, ENTITY_KIND_CUSTOM_TOOL, ENTITY_KIND_INDICATOR, + ENTITY_KIND_KNOWLEDGE_BASE, ] as const export type ReviewEntityKind = (typeof REVIEW_ENTITY_KINDS)[number] diff --git a/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts b/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts index 4f3eac525..a6cb444b8 100644 --- a/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts +++ b/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts @@ -60,9 +60,9 @@ describe('copilot runtime tool manifest', () => { entityKind: 'environment', }), expect.objectContaining({ - name: 'read_workflow_variables', - description: expect.stringContaining(''), - kind: 'read', + name: 'edit_workflow_variable', + description: expect.stringContaining('workflow-variable document'), + kind: 'edit', entityKind: 'workflow', }), expect.objectContaining({ diff --git a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts index efad9e3bd..9786877ba 100644 --- a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts +++ b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts @@ -9,6 +9,8 @@ export interface ToolPromptMetadata { const CUSTOM_TOOL_DOCUMENT_GUIDANCE = 'Use full `tg-custom-tool-document-v1` JSON with exactly `title`, `schemaText`, and `codeText`. `title` is the canonical custom-tool name. `schemaText` is a JSON-encoded string, not an object, containing {"type":"function","function":{"description":"What the tool does","parameters":{"type":"object","properties":{},"required":[]}}}. Do not include a `name` property inside `function`. `codeText` is raw async JavaScript function body only; use for inputs and {{ENV_VAR_NAME}} for environment variables.' +const KNOWLEDGE_BASE_DOCUMENT_GUIDANCE = + 'Use full `tg-knowledge-base-document-v1` JSON with exactly `name`, `description`, and `chunkingConfig` fields. `chunkingConfig` must include numeric `maxSize`, `minSize`, and `overlap`.' export const TOOL_PROMPT_METADATA: Record = { plan: { @@ -28,7 +30,7 @@ export const TOOL_PROMPT_METADATA: Record = { }, [CopilotTool.read_workflow]: { description: - 'Read a workflow by exact `entityId` and return full `tg-mermaid-v1` inspection Mermaid in `entityDocument`, plus `workflowSummary.blocks[].connections` counts and exact raw `workflowSummary.edges` with external/internal scope. Do not submit this full document to `edit_workflow`; that tool accepts minimal graph-only Mermaid. For topology, use only these edges/counts; do not infer graph connections from subBlock text references like `<...>`. `connectionIssues` only reports malformed existing edges.', + 'Read a workflow by exact `entityId` and return full `tg-mermaid-v1` inspection Mermaid in `entityDocument`, workflow variables in `workflowVariableDocument`, plus `workflowSummary.blocks[].connections` counts and exact raw `workflowSummary.edges` with external/internal scope. Do not submit the full workflow Mermaid to `edit_workflow`; that tool accepts minimal graph-only Mermaid. Use `workflowVariableDocument` with `edit_workflow_variable` for variable changes. For topology, use only these edges/counts; do not infer graph connections from subBlock text references like `<...>`. `connectionIssues` only reports malformed existing edges.', kind: 'read', entityKind: 'workflow', }, @@ -81,7 +83,7 @@ export const TOOL_PROMPT_METADATA: Record = { }, [CopilotTool.get_agent_accessory_catalog]: { description: - 'Get available Agent block accessories for the current workflow workspace. Returns `tools` options for Agent `subBlocks.tools` and `skills` options for Agent `subBlocks.skills`; write selected option `value` objects with `edit_workflow_block`.', + 'Get available Agent block accessories for the selected workspace. Returns `tools` options for Agent `subBlocks.tools` and `skills` options for Agent `subBlocks.skills`; write selected option `value` objects with `edit_workflow_block`.', kind: 'inspect', entityKind: 'workflow', }, @@ -114,7 +116,7 @@ export const TOOL_PROMPT_METADATA: Record = { }, [CopilotTool.read_environment_variables]: { description: - 'Read environment variables for the current workspace or workflow context. Use returned names with the exact `{{ENV_VAR_NAME}}` syntax in block inputs.', + 'Read environment variable names for the selected workspace. Use returned names with the exact `{{ENV_VAR_NAME}}` syntax in block inputs.', kind: 'read', entityKind: 'environment', }, @@ -124,12 +126,12 @@ export const TOOL_PROMPT_METADATA: Record = { entityKind: 'environment', }, [CopilotTool.read_oauth_credentials]: { - description: 'Read OAuth credentials.', + description: 'Read OAuth credentials for the selected workspace.', kind: 'read', entityKind: 'credential', }, [CopilotTool.read_credentials]: { - description: 'Read OAuth credentials and related environment variable names.', + description: 'Read OAuth credentials and related environment variable names for the selected workspace.', kind: 'read', entityKind: 'credential', }, @@ -139,14 +141,9 @@ export const TOOL_PROMPT_METADATA: Record = { kind: 'list', entityKind: 'workflow', }, - [CopilotTool.read_workflow_variables]: { + [CopilotTool.edit_workflow_variable]: { description: - 'Read workflow variables. Use returned names with the exact `` syntax in block inputs.', - kind: 'read', - entityKind: 'workflow', - }, - [CopilotTool.set_workflow_variables]: { - description: 'Add, edit, or delete global workflow variables.', + 'Edit global workflow variables by replacing the full workflow-variable document returned by `read_workflow`. Use returned names with the exact `` syntax in block inputs.', kind: 'edit', entityKind: 'workflow', }, @@ -165,9 +162,36 @@ export const TOOL_PROMPT_METADATA: Record = { kind: 'read', entityKind: 'workflow', }, - knowledge_base: { - description: 'Create, list, get, or query knowledge bases.', - kind: 'knowledge', + list_knowledge_bases: { + description: + 'List knowledge bases in the current workspace. If the user identifies one by name, use this list to select the exact `entityId`.', + kind: 'list', + entityKind: 'knowledge_base', + }, + read_knowledge_base: { + description: `Read one knowledge base by exact \`entityId\` as an editable document payload with \`entityDocument\` and \`documentFormat\`. ${KNOWLEDGE_BASE_DOCUMENT_GUIDANCE}`, + kind: 'read', + entityKind: 'knowledge_base', + }, + create_knowledge_base: { + description: `Create a knowledge base in the current workspace from a full knowledge-base document and return the created document. ${KNOWLEDGE_BASE_DOCUMENT_GUIDANCE}`, + kind: 'create', + entityKind: 'knowledge_base', + }, + edit_knowledge_base: { + description: `Update the target knowledge base from a full knowledge-base document and return the resulting document. ${KNOWLEDGE_BASE_DOCUMENT_GUIDANCE}`, + kind: 'edit', + entityKind: 'knowledge_base', + }, + rename_knowledge_base: { + description: `Rename the target knowledge base by sending a full knowledge-base document with the updated \`name\`, then return the resulting document. ${KNOWLEDGE_BASE_DOCUMENT_GUIDANCE}`, + kind: 'rename', + entityKind: 'knowledge_base', + }, + query_knowledge_base: { + description: + 'Search one knowledge base by exact `entityId` and query text. Use `read_knowledge_base` or `list_knowledge_bases` first when resolving a named knowledge base.', + kind: 'search', entityKind: 'knowledge_base', }, list_custom_tools: { @@ -324,13 +348,13 @@ export const TOOL_PROMPT_METADATA: Record = { }, list_gdrive_files: { description: - 'List Google Drive files using the credentialId returned by gdrive_request_access.', + 'List Google Drive files in the selected workspace using the credentialId returned by gdrive_request_access.', kind: 'list', entityKind: 'google_drive', }, read_gdrive_file: { description: - 'Read a Google doc or sheet using the credentialId returned by gdrive_request_access.', + 'Read a Google doc or sheet in the selected workspace using the credentialId returned by gdrive_request_access.', kind: 'read', entityKind: 'google_drive', }, diff --git a/apps/tradinggoose/lib/copilot/tools/client/base-tool.ts b/apps/tradinggoose/lib/copilot/tools/client/base-tool.ts index f902a6ecf..45429232e 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/base-tool.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/base-tool.ts @@ -376,33 +376,3 @@ export class BaseClientTool { return this.state } } - -export abstract class StagedReviewClientTool< - TReviewResult = Record, -> extends BaseClientTool { - private stagedReviewResult?: TReviewResult - - protected getStagedReviewResult(): TReviewResult | undefined { - return this.stagedReviewResult ?? this.resolvePersistedResult() - } - - protected stageReviewResult(result: TReviewResult): void { - this.stagedReviewResult = result - this.setState(ClientToolCallState.review, { result }) - } - - protected abstract hasStagedReviewResult(result: TReviewResult | undefined): boolean - - getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined { - return this.getState() === ClientToolCallState.review ? this.metadata.interrupt : undefined - } - - protected async prepareReviewAccept(args?: Record): Promise { - if (this.hasStagedReviewResult(this.getStagedReviewResult())) { - return true - } - - await this.execute(args) - return this.resolveUserActionState() === ClientToolCallState.review - } -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/entities/entity-document-tool-utils.ts b/apps/tradinggoose/lib/copilot/tools/client/entities/entity-document-tool-utils.ts deleted file mode 100644 index 462a28cfb..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/entities/entity-document-tool-utils.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { type EntityDocumentKind, getEntityDocumentName } from '@/lib/copilot/entity-documents' -import type { ClientToolExecutionContext } from '@/lib/copilot/tools/client/base-tool' -import { resolveOptionalCopilotEntityId } from '@/lib/copilot/tools/entity-target' -import { CustomToolOpenAiSchema, parseCustomToolSchemaText } from '@/lib/custom-tools/schema' -import { getDefaultIndicator } from '@/lib/indicators/default' -import { getEntityFields, replaceEntityTextField, setEntityField } from '@/lib/yjs/entity-session' -import { buildSavedEntityYjsDescriptor } from '@/lib/yjs/entity-state' -import { - bootstrapYjsProvider, - waitForYjsWriteSync, - type YjsProviderBootstrapResult, -} from '@/lib/yjs/provider' -import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' - -type EntityListEntry = { - entityId: string - entityName: string - entityDescription?: string - entityTitle?: string - entityTransport?: string - entityUrl?: string - entityEnabled?: boolean - entityConnectionStatus?: string -} - -export type CopilotIndicatorListEntry = { - name: string - source: 'default' | 'custom' - editable: boolean - callableInFunctionBlock: boolean - inputTitles?: string[] - entityId?: string - runtimeId?: string -} - -export type EntityReadTarget = { - entityId?: string - runtimeId?: string -} - -type EntityApiConfig = { - listEndpoint: string - extractList: (data: any) => any[] - toFields: (item: any) => Record - toListEntry: (item: any) => EntityListEntry -} - -type CopilotEntityYjsSessionLease = { - session: CopilotEntityYjsSession - release: () => void -} - -type CopilotEntityYjsSession = { - descriptor: YjsProviderBootstrapResult['descriptor'] - doc: YjsProviderBootstrapResult['doc'] - provider: YjsProviderBootstrapResult['provider'] - runtime: YjsProviderBootstrapResult['runtime'] - isSynced: boolean - canUndo: boolean - canRedo: boolean -} - -const COPILOT_ENTITY_YJS_RELEASE_MS = 2_500 - -const ENTITY_API_CONFIG: Record = { - skill: { - listEndpoint: '/api/skills', - extractList: (data) => (Array.isArray(data?.data) ? data.data : []), - toFields: (item) => ({ - name: item?.name ?? '', - description: item?.description ?? '', - content: item?.content ?? '', - }), - toListEntry: (item) => ({ - entityId: String(item?.id ?? ''), - entityName: String(item?.name ?? ''), - entityDescription: typeof item?.description === 'string' ? item.description : '', - }), - }, - custom_tool: { - listEndpoint: '/api/tools/custom', - extractList: (data) => (Array.isArray(data?.data) ? data.data : []), - toFields: (item) => ({ - title: item?.title ?? '', - schemaText: - item?.schema && typeof item.schema === 'object' - ? JSON.stringify(CustomToolOpenAiSchema.parse(item.schema), null, 2) - : typeof item?.schemaText === 'string' - ? item.schemaText - : '', - codeText: item?.code ?? item?.codeText ?? '', - }), - toListEntry: (item) => ({ - entityId: String(item?.id ?? ''), - entityName: String(item?.title ?? ''), - entityTitle: typeof item?.title === 'string' ? item.title : '', - entityDescription: - typeof item?.schema?.function?.description === 'string' - ? item.schema.function.description - : undefined, - }), - }, - indicator: { - listEndpoint: '/api/indicators/custom', - extractList: (data) => (Array.isArray(data?.data) ? data.data : []), - toFields: (item) => ({ - name: item?.name ?? '', - pineCode: item?.pineCode ?? '', - inputMeta: - item?.inputMeta && typeof item.inputMeta === 'object' && !Array.isArray(item.inputMeta) - ? item.inputMeta - : null, - }), - toListEntry: (item) => ({ - entityId: String(item?.id ?? ''), - entityName: String(item?.name ?? ''), - }), - }, - mcp_server: { - listEndpoint: '/api/mcp/servers', - extractList: (data) => (Array.isArray(data?.data?.servers) ? data.data.servers : []), - toFields: (item) => ({ - name: item?.name ?? '', - description: item?.description ?? '', - transport: item?.transport ?? 'http', - url: item?.url ?? '', - headers: - item?.headers && typeof item.headers === 'object' && !Array.isArray(item.headers) - ? item.headers - : {}, - command: item?.command ?? '', - args: Array.isArray(item?.args) ? item.args : [], - env: item?.env && typeof item.env === 'object' && !Array.isArray(item.env) ? item.env : {}, - timeout: typeof item?.timeout === 'number' ? item.timeout : 30000, - retries: typeof item?.retries === 'number' ? item.retries : 3, - enabled: typeof item?.enabled === 'boolean' ? item.enabled : true, - }), - toListEntry: (item) => ({ - entityId: String(item?.id ?? ''), - entityName: String(item?.name ?? ''), - entityTransport: typeof item?.transport === 'string' ? item.transport : undefined, - entityUrl: typeof item?.url === 'string' ? item.url : undefined, - entityEnabled: typeof item?.enabled === 'boolean' ? item.enabled : undefined, - entityConnectionStatus: - typeof item?.connectionStatus === 'string' ? item.connectionStatus : undefined, - }), - }, -} - -function buildEntityCreateRequest( - kind: EntityDocumentKind, - workspaceId: string, - fields: Record -): { endpoint: string; body: Record } { - switch (kind) { - case 'skill': - return { - endpoint: '/api/skills', - body: { - workspaceId, - skills: [ - { - name: fields.name, - description: fields.description, - content: fields.content, - }, - ], - }, - } - case 'custom_tool': - return { - endpoint: '/api/tools/custom', - body: { - workspaceId, - tools: [ - { - title: fields.title, - schema: parseCustomToolSchemaText(fields.schemaText), - code: fields.codeText, - }, - ], - }, - } - case 'indicator': - return { - endpoint: '/api/indicators/custom', - body: { - workspaceId, - indicators: [ - { - name: fields.name, - pineCode: fields.pineCode, - inputMeta: fields.inputMeta ?? undefined, - }, - ], - }, - } - case 'mcp_server': - return { - endpoint: '/api/mcp/servers', - body: { - workspaceId, - name: fields.name, - ...(typeof fields.description === 'string' && fields.description.trim() - ? { description: fields.description.trim() } - : {}), - transport: fields.transport, - ...(typeof fields.url === 'string' && fields.url.trim() - ? { url: fields.url.trim() } - : {}), - headers: fields.headers, - ...(typeof fields.command === 'string' && fields.command.trim() - ? { command: fields.command.trim() } - : {}), - args: fields.args, - env: fields.env, - timeout: fields.timeout, - retries: fields.retries, - enabled: fields.enabled, - }, - } - } -} - -function readCreatedEntityId(kind: EntityDocumentKind, payload: any): string { - if (kind === 'mcp_server') { - const serverId = payload?.data?.serverId - if (typeof serverId === 'string' && serverId.trim()) { - return serverId - } - throw new Error('Created MCP server is missing serverId') - } - - const created = Array.isArray(payload?.data) ? payload.data[0] : null - const entityId = created?.id - if (typeof entityId === 'string' && entityId.trim()) { - return entityId - } - - throw new Error(`Created ${kind} is missing id`) -} - -export async function createCanonicalEntityFromFields( - kind: EntityDocumentKind, - workspaceId: string, - fields: Record -): Promise<{ - entityId: string - entityName: string - fields: Record -}> { - const request = buildEntityCreateRequest(kind, workspaceId, fields) - const response = await fetch(request.endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request.body), - }) - const payload = await response.json().catch(() => ({})) - - if (!response.ok) { - throw new Error(payload?.error || `Failed to create ${kind}: ${response.status}`) - } - - const entityId = readCreatedEntityId(kind, payload) - const createdRecord = kind === 'mcp_server' ? null : payload.data[0] - const createdFields = createdRecord ? ENTITY_API_CONFIG[kind].toFields(createdRecord) : fields - - return { - entityId, - entityName: getEntityDocumentName(kind, createdFields), - fields: createdFields, - } -} - -export function resolveWorkspaceIdFromExecutionContext( - executionContext: ClientToolExecutionContext -): string { - if (executionContext.workspaceId) { - return executionContext.workspaceId - } - - throw new Error( - 'No active workspace found in execution context. Ensure workspaceId is included in tool provenance.' - ) -} - -function createBootstrappedEntitySessionLease( - result: YjsProviderBootstrapResult -): CopilotEntityYjsSessionLease { - const session: CopilotEntityYjsSession = { - descriptor: result.descriptor, - doc: result.doc, - provider: result.provider, - runtime: result.runtime, - isSynced: result.provider.synced, - canUndo: false, - canRedo: false, - } - - return { - session, - release: () => { - setTimeout(() => { - result.provider.disconnect() - result.provider.destroy() - result.doc.destroy() - }, COPILOT_ENTITY_YJS_RELEASE_MS) - }, - } -} - -export async function resolveCopilotEntityYjsSessionLease( - executionContext: ClientToolExecutionContext, - kind: EntityDocumentKind, - entityId?: string -): Promise { - const requestedEntityId = resolveOptionalCopilotEntityId({ entityId }) - - if (!requestedEntityId) { - throw new Error(`entityId is required to update a saved ${kind}`) - } - - const workspaceId = resolveWorkspaceIdFromExecutionContext(executionContext) - const resolved = buildSavedEntityYjsDescriptor(kind, requestedEntityId, workspaceId) - const result = await bootstrapYjsProvider(resolved) - await waitForYjsWriteSync(result.provider) - return createBootstrappedEntitySessionLease(result) -} - -async function fetchEntityList(kind: EntityDocumentKind, workspaceId: string): Promise { - const config = ENTITY_API_CONFIG[kind] - const response = await fetch( - `${config.listEndpoint}?workspaceId=${encodeURIComponent(workspaceId)}` - ) - const data = await response.json().catch(() => ({})) - - if (!response.ok) { - throw new Error(data?.error || `Failed to fetch ${kind} entries: ${response.status}`) - } - - return config.extractList(data) -} - -export async function listCanonicalEntityEntries( - kind: EntityDocumentKind, - workspaceId: string -): Promise { - const config = ENTITY_API_CONFIG[kind] - const items = await fetchEntityList(kind, workspaceId) - return items.map((item) => config.toListEntry(item)) -} - -export async function listCopilotIndicators( - workspaceId: string -): Promise { - const response = await fetch( - `/api/indicators/options?workspaceId=${encodeURIComponent(workspaceId)}&surface=copilot` - ) - const data = await response.json().catch(() => ({})) - - if (!response.ok) { - throw new Error(data?.error || `Failed to fetch indicators: ${response.status}`) - } - - const items = Array.isArray(data?.data) ? data.data : [] - - return items.flatMap((item: any) => { - const name = typeof item?.name === 'string' ? item.name : '' - const source = - item?.source === 'custom' ? 'custom' : item?.source === 'default' ? 'default' : null - if (!name || !source) return [] - - const entry: CopilotIndicatorListEntry = { - name, - source, - editable: item?.editable === true, - callableInFunctionBlock: item?.callableInFunctionBlock === true, - ...(Array.isArray(item?.inputTitles) - ? { - inputTitles: item.inputTitles.filter( - (value: unknown): value is string => typeof value === 'string' - ), - } - : {}), - ...(typeof item?.entityId === 'string' && item.entityId ? { entityId: item.entityId } : {}), - ...(typeof item?.runtimeId === 'string' && item.runtimeId - ? { runtimeId: item.runtimeId } - : {}), - } - - return [entry] - }) -} - -export async function readEntityFieldsFromContext( - executionContext: ClientToolExecutionContext, - kind: EntityDocumentKind, - target?: EntityReadTarget -): Promise<{ - entityId?: string - entityName: string - fields: Record -}> { - let resolvedEntityId = resolveOptionalCopilotEntityId(target) - const resolvedRuntimeId = - kind === 'indicator' ? target?.runtimeId?.trim() || undefined : undefined - - if (resolvedRuntimeId) { - if (resolvedEntityId) { - throw new Error('Use either runtimeId or entityId, not both') - } - - const indicator = getDefaultIndicator(resolvedRuntimeId) - if (indicator) { - return { - entityName: indicator.name, - fields: { - name: indicator.name, - pineCode: indicator.pineCode, - inputMeta: indicator.inputMeta ?? null, - }, - } - } - - resolvedEntityId = resolvedRuntimeId - } - - if (!resolvedEntityId) { - throw new Error('entityId is required') - } - - const lease = await resolveCopilotEntityYjsSessionLease(executionContext, kind, resolvedEntityId) - try { - const fields = getEntityFields(lease.session.doc, kind) - return { - entityId: lease.session.descriptor.entityId ?? resolvedEntityId, - entityName: getEntityDocumentName(kind, fields), - fields, - } - } finally { - lease.release() - } -} - -export function applyEntityFieldsToSession( - session: CopilotEntityYjsSession, - kind: EntityDocumentKind, - fields: Record -): void { - session.doc.transact(() => { - switch (kind) { - case 'skill': - setEntityField(session.doc, 'name', fields.name ?? '') - setEntityField(session.doc, 'description', fields.description ?? '') - setEntityField(session.doc, 'content', fields.content ?? '') - break - case 'custom_tool': - setEntityField(session.doc, 'title', fields.title ?? '') - replaceEntityTextField(session.doc, 'schemaText', String(fields.schemaText ?? '')) - replaceEntityTextField(session.doc, 'codeText', String(fields.codeText ?? '')) - break - case 'indicator': - setEntityField(session.doc, 'name', fields.name ?? '') - replaceEntityTextField(session.doc, 'pineCode', String(fields.pineCode ?? '')) - setEntityField(session.doc, 'inputMeta', fields.inputMeta ?? null) - break - case 'mcp_server': - setEntityField(session.doc, 'name', fields.name ?? '') - setEntityField(session.doc, 'description', fields.description ?? '') - setEntityField(session.doc, 'transport', fields.transport ?? 'http') - setEntityField(session.doc, 'url', fields.url ?? '') - setEntityField(session.doc, 'headers', fields.headers ?? {}) - setEntityField(session.doc, 'command', fields.command ?? '') - setEntityField(session.doc, 'args', fields.args ?? []) - setEntityField(session.doc, 'env', fields.env ?? {}) - setEntityField(session.doc, 'timeout', fields.timeout ?? 30000) - setEntityField(session.doc, 'retries', fields.retries ?? 3) - setEntityField(session.doc, 'enabled', fields.enabled ?? true) - break - } - }, YJS_ORIGINS.COPILOT_TOOL) -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/entities/entity-document-tools.ts b/apps/tradinggoose/lib/copilot/tools/client/entities/entity-document-tools.ts deleted file mode 100644 index 7a80d9c45..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/entities/entity-document-tools.ts +++ /dev/null @@ -1,585 +0,0 @@ -import type { LucideIcon } from 'lucide-react' -import { - BarChart3, - BookOpen, - Check, - Code2, - FileJson, - Loader2, - Server, - X, - XCircle, -} from 'lucide-react' -import { - type EntityDocumentKind, - getEntityDocumentFormat, - getEntityDocumentName, - parseEntityDocument, - serializeEntityDocument, -} from '@/lib/copilot/entity-documents' -import { CopilotTool } from '@/lib/copilot/registry' -import { - ENTITY_KIND_CUSTOM_TOOL, - ENTITY_KIND_INDICATOR, - ENTITY_KIND_MCP_SERVER, - ENTITY_KIND_SKILL, -} from '@/lib/copilot/review-sessions/types' -import { - BaseClientTool, - type BaseClientToolMetadata, - ClientToolCallState, - StagedReviewClientTool, -} from '@/lib/copilot/tools/client/base-tool' -import { - applyEntityFieldsToSession, - createCanonicalEntityFromFields, - type EntityReadTarget, - listCanonicalEntityEntries, - listCopilotIndicators, - readEntityFieldsFromContext, - resolveCopilotEntityYjsSessionLease, - resolveWorkspaceIdFromExecutionContext, -} from '@/lib/copilot/tools/client/entities/entity-document-tool-utils' -import { getEntityFields } from '@/lib/yjs/entity-session' -import { getCopilotStoreForToolCall } from '@/stores/copilot/store-access' - -type EntityToolConfig = { - kind: EntityDocumentKind - singularLabel: string - pluralLabel: string - icon: LucideIcon -} - -type ReadEntityDocumentArgs = EntityReadTarget - -type EditEntityDocumentArgs = ReadEntityDocumentArgs & { - entityDocument: string - documentFormat?: string -} - -type EntityMutationAction = 'create' | 'edit' | 'rename' - -function readStoredToolArgs(toolCallId: string): TArgs | undefined { - try { - const { toolCallsById } = getCopilotStoreForToolCall(toolCallId).getState() - return toolCallsById[toolCallId]?.params as TArgs | undefined - } catch { - return undefined - } -} - -function buildEntityDocumentDiff( - kind: EntityDocumentKind, - currentFields: Record, - nextFields: Record -): { before: string; after: string } { - return { - before: serializeEntityDocument(kind, currentFields), - after: serializeEntityDocument(kind, nextFields), - } -} - -function createListMetadata(config: EntityToolConfig): BaseClientToolMetadata { - return { - displayNames: { - [ClientToolCallState.generating]: { - text: `Listing ${config.pluralLabel}`, - icon: Loader2, - }, - [ClientToolCallState.pending]: { - text: `List ${config.pluralLabel}`, - icon: config.icon, - }, - [ClientToolCallState.executing]: { - text: `Listing ${config.pluralLabel}`, - icon: Loader2, - }, - [ClientToolCallState.success]: { - text: `Listed ${config.pluralLabel}`, - icon: config.icon, - }, - [ClientToolCallState.error]: { - text: `Failed to list ${config.pluralLabel}`, - icon: X, - }, - [ClientToolCallState.aborted]: { - text: `Aborted listing ${config.pluralLabel}`, - icon: XCircle, - }, - [ClientToolCallState.rejected]: { - text: `Skipped listing ${config.pluralLabel}`, - icon: XCircle, - }, - }, - } -} - -function createReadMetadata(config: EntityToolConfig): BaseClientToolMetadata { - return { - displayNames: { - [ClientToolCallState.generating]: { - text: `Reading ${config.singularLabel} document`, - icon: Loader2, - }, - [ClientToolCallState.pending]: { - text: `Read ${config.singularLabel} document`, - icon: FileJson, - }, - [ClientToolCallState.executing]: { - text: `Reading ${config.singularLabel} document`, - icon: Loader2, - }, - [ClientToolCallState.success]: { - text: `Read ${config.singularLabel} document`, - icon: FileJson, - }, - [ClientToolCallState.error]: { - text: `Failed to read ${config.singularLabel} document`, - icon: X, - }, - [ClientToolCallState.aborted]: { - text: `Aborted reading ${config.singularLabel} document`, - icon: XCircle, - }, - [ClientToolCallState.rejected]: { - text: `Skipped reading ${config.singularLabel} document`, - icon: XCircle, - }, - }, - } -} - -function createMutationMetadata( - config: EntityToolConfig, - action: EntityMutationAction -): BaseClientToolMetadata { - const actionLabels = - action === 'create' - ? { - gerund: 'Creating', - past: 'Created', - error: 'create', - aborted: 'creating', - } - : action === 'rename' - ? { - gerund: 'Renaming', - past: 'Renamed', - error: 'rename', - aborted: 'renaming', - } - : { - gerund: 'Editing', - past: 'Edited', - error: 'edit', - aborted: 'editing', - } - - return { - displayNames: { - [ClientToolCallState.generating]: { - text: `${actionLabels.gerund} ${config.singularLabel} document`, - icon: Loader2, - }, - [ClientToolCallState.pending]: { - text: `${actionLabels.gerund} ${config.singularLabel} document`, - icon: Loader2, - }, - [ClientToolCallState.executing]: { - text: `${actionLabels.gerund} ${config.singularLabel} document`, - icon: Loader2, - }, - [ClientToolCallState.review]: { - text: `Review your ${config.singularLabel} changes`, - icon: config.icon, - }, - [ClientToolCallState.success]: { - text: `${actionLabels.past} ${config.singularLabel} document`, - icon: Check, - }, - [ClientToolCallState.error]: { - text: `Failed to ${actionLabels.error} ${config.singularLabel} document`, - icon: X, - }, - [ClientToolCallState.aborted]: { - text: `Aborted ${actionLabels.aborted} ${config.singularLabel} document`, - icon: XCircle, - }, - [ClientToolCallState.rejected]: { - text: `Skipped ${actionLabels.aborted} ${config.singularLabel} document`, - icon: XCircle, - }, - }, - interrupt: { - accept: { text: 'Accept changes', icon: Check }, - reject: { text: 'Reject changes', icon: XCircle }, - }, - } -} - -function createListEntityTool(toolId: string, config: EntityToolConfig) { - return class ListEntityClientTool extends BaseClientTool { - static readonly id = toolId - static readonly metadata = createListMetadata(config) - - constructor(toolCallId: string) { - super(toolCallId, toolId, ListEntityClientTool.metadata) - } - - async execute(): Promise { - try { - this.setState(ClientToolCallState.executing) - const executionContext = this.requireExecutionContext() - const workspaceId = resolveWorkspaceIdFromExecutionContext(executionContext) - const entities = await listCanonicalEntityEntries(config.kind, workspaceId) - - await this.markToolComplete(200, `Listed ${config.pluralLabel}`, { - entityKind: config.kind, - entities, - count: entities.length, - }) - this.setState(ClientToolCallState.success) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - await this.markToolComplete(500, message) - this.setState(ClientToolCallState.error) - } - } - } -} - -function createReadEntityDocumentTool(toolId: string, config: EntityToolConfig) { - return class ReadEntityDocumentClientTool extends BaseClientTool { - static readonly id = toolId - static readonly metadata = createReadMetadata(config) - - constructor(toolCallId: string) { - super(toolCallId, toolId, ReadEntityDocumentClientTool.metadata) - } - - async execute(args?: ReadEntityDocumentArgs): Promise { - try { - this.setState(ClientToolCallState.executing) - const executionContext = this.requireExecutionContext() - - const { entityId, entityName, fields } = await readEntityFieldsFromContext( - executionContext, - config.kind, - args - ) - - await this.markToolComplete(200, `${config.singularLabel} document ready`, { - entityKind: config.kind, - entityId, - entityName, - documentFormat: getEntityDocumentFormat(config.kind), - entityDocument: serializeEntityDocument(config.kind, fields), - }) - this.setState(ClientToolCallState.success) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - await this.markToolComplete(500, message) - this.setState(ClientToolCallState.error) - } - } - } -} - -function createEntityDocumentMutationTool( - toolId: string, - config: EntityToolConfig, - action: EntityMutationAction -) { - return class EditEntityDocumentClientTool extends StagedReviewClientTool> { - static readonly id = toolId - static readonly metadata = createMutationMetadata(config, action) - private currentArgs?: EditEntityDocumentArgs - - constructor(toolCallId: string) { - super(toolCallId, toolId, EditEntityDocumentClientTool.metadata) - } - - async execute(args?: EditEntityDocumentArgs): Promise { - try { - this.currentArgs = args - this.setState(ClientToolCallState.executing) - const executionContext = this.requireExecutionContext() - const resolvedArgs = args || readStoredToolArgs(this.toolCallId) - - if (!resolvedArgs?.entityDocument?.trim()) { - throw new Error('entityDocument is required') - } - - if ( - resolvedArgs.documentFormat && - resolvedArgs.documentFormat !== getEntityDocumentFormat(config.kind) - ) { - throw new Error( - `Unsupported documentFormat "${resolvedArgs.documentFormat}". Expected ${getEntityDocumentFormat(config.kind)}` - ) - } - - const entityId = resolvedArgs.entityId?.trim() - if (action === 'create' && entityId) { - throw new Error(`${toolId} does not accept entityId`) - } - const nextFields = parseEntityDocument(config.kind, resolvedArgs.entityDocument) - let currentFields: Record = {} - let resolvedEntityId: string | null | undefined = entityId - - if (action !== 'create') { - const lease = await resolveCopilotEntityYjsSessionLease( - executionContext, - config.kind, - entityId - ) - try { - currentFields = getEntityFields(lease.session.doc, config.kind) - resolvedEntityId = lease.session.descriptor.entityId ?? entityId - } finally { - lease.release() - } - } - - const stagedResult = { - success: false, - entityKind: config.kind, - ...(resolvedEntityId ? { entityId: resolvedEntityId } : {}), - entityName: getEntityDocumentName(config.kind, nextFields), - documentFormat: getEntityDocumentFormat(config.kind), - entityDocument: serializeEntityDocument(config.kind, nextFields), - preview: { - documentDiff: buildEntityDocumentDiff(config.kind, currentFields, nextFields), - }, - } - this.stageReviewResult(stagedResult) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - await this.markToolComplete(500, message) - this.setState(ClientToolCallState.error) - } - } - - protected hasStagedReviewResult(result: Record | undefined): boolean { - return !!result?.entityDocument - } - - async handleAccept(args?: EditEntityDocumentArgs): Promise { - try { - this.setState(ClientToolCallState.executing) - - let stagedResult = this.getStagedReviewResult() - if (!stagedResult?.entityDocument) { - await this.execute(args) - stagedResult = this.getStagedReviewResult() - } - - if (!stagedResult?.entityDocument?.trim()) { - throw new Error('entityDocument is required') - } - - const executionContext = this.requireExecutionContext() - const entityId = - (typeof stagedResult.entityId === 'string' ? stagedResult.entityId.trim() : '') || - args?.entityId?.trim() || - this.currentArgs?.entityId?.trim() - const nextFields = parseEntityDocument(config.kind, stagedResult.entityDocument) - - if (action === 'create') { - if (entityId) { - throw new Error(`${toolId} does not accept entityId`) - } - - const workspaceId = resolveWorkspaceIdFromExecutionContext(executionContext) - const created = await createCanonicalEntityFromFields( - config.kind, - workspaceId, - nextFields - ) - - await this.markToolComplete(200, `${config.singularLabel} document created`, { - success: true, - entityKind: config.kind, - entityId: created.entityId, - entityName: created.entityName, - documentFormat: getEntityDocumentFormat(config.kind), - entityDocument: serializeEntityDocument(config.kind, created.fields), - preview: stagedResult.preview, - }) - this.setState(ClientToolCallState.success) - return - } - - const lease = await resolveCopilotEntityYjsSessionLease( - executionContext, - config.kind, - entityId - ) - try { - applyEntityFieldsToSession(lease.session, config.kind, nextFields) - const persistedFields = getEntityFields(lease.session.doc, config.kind) - const savedEntityId = lease.session.descriptor.entityId ?? entityId - - await this.markToolComplete(200, `${config.singularLabel} document updated`, { - success: true, - entityKind: config.kind, - ...(savedEntityId ? { entityId: savedEntityId } : {}), - entityName: getEntityDocumentName(config.kind, persistedFields), - documentFormat: getEntityDocumentFormat(config.kind), - entityDocument: serializeEntityDocument(config.kind, persistedFields), - preview: stagedResult.preview, - }) - } finally { - lease.release() - } - this.setState(ClientToolCallState.success) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - await this.markToolComplete(500, message) - this.setState(ClientToolCallState.error) - } - } - } -} - -const skillToolConfig: EntityToolConfig = { - kind: ENTITY_KIND_SKILL, - singularLabel: 'skill', - pluralLabel: 'skills', - icon: BookOpen, -} - -const customToolConfig: EntityToolConfig = { - kind: ENTITY_KIND_CUSTOM_TOOL, - singularLabel: 'custom tool', - pluralLabel: 'custom tools', - icon: Code2, -} - -const indicatorToolConfig: EntityToolConfig = { - kind: ENTITY_KIND_INDICATOR, - singularLabel: 'indicator', - pluralLabel: 'indicators', - icon: BarChart3, -} - -const mcpServerToolConfig: EntityToolConfig = { - kind: ENTITY_KIND_MCP_SERVER, - singularLabel: 'MCP server', - pluralLabel: 'MCP servers', - icon: Server, -} - -export const ListSkillsClientTool = createListEntityTool('list_skills', skillToolConfig) -export const ReadSkillClientTool = createReadEntityDocumentTool( - CopilotTool.read_skill, - skillToolConfig -) -export const CreateSkillClientTool = createEntityDocumentMutationTool( - 'create_skill', - skillToolConfig, - 'create' -) -export const EditSkillClientTool = createEntityDocumentMutationTool( - 'edit_skill', - skillToolConfig, - 'edit' -) -export const RenameSkillClientTool = createEntityDocumentMutationTool( - 'rename_skill', - skillToolConfig, - 'rename' -) - -export const ListCustomToolsClientTool = createListEntityTool('list_custom_tools', customToolConfig) -export const ReadCustomToolClientTool = createReadEntityDocumentTool( - CopilotTool.read_custom_tool, - customToolConfig -) -export const CreateCustomToolClientTool = createEntityDocumentMutationTool( - 'create_custom_tool', - customToolConfig, - 'create' -) -export const EditCustomToolClientTool = createEntityDocumentMutationTool( - 'edit_custom_tool', - customToolConfig, - 'edit' -) -export const RenameCustomToolClientTool = createEntityDocumentMutationTool( - 'rename_custom_tool', - customToolConfig, - 'rename' -) - -export class ListIndicatorsClientTool extends BaseClientTool { - static readonly id = CopilotTool.list_indicators - static readonly metadata = createListMetadata(indicatorToolConfig) - - constructor(toolCallId: string) { - super(toolCallId, ListIndicatorsClientTool.id, ListIndicatorsClientTool.metadata) - } - - async execute(): Promise { - try { - this.setState(ClientToolCallState.executing) - const executionContext = this.requireExecutionContext() - const workspaceId = resolveWorkspaceIdFromExecutionContext(executionContext) - const indicators = await listCopilotIndicators(workspaceId) - - await this.markToolComplete(200, 'Listed indicators', { - entityKind: 'indicator', - indicators, - count: indicators.length, - }) - this.setState(ClientToolCallState.success) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - await this.markToolComplete(500, message) - this.setState(ClientToolCallState.error) - } - } -} -export const ReadIndicatorClientTool = createReadEntityDocumentTool( - CopilotTool.read_indicator, - indicatorToolConfig -) -export const CreateIndicatorClientTool = createEntityDocumentMutationTool( - 'create_indicator', - indicatorToolConfig, - 'create' -) -export const EditIndicatorClientTool = createEntityDocumentMutationTool( - 'edit_indicator', - indicatorToolConfig, - 'edit' -) -export const RenameIndicatorClientTool = createEntityDocumentMutationTool( - 'rename_indicator', - indicatorToolConfig, - 'rename' -) - -export const ListMcpServersClientTool = createListEntityTool( - 'list_mcp_servers', - mcpServerToolConfig -) -export const ReadMcpServerClientTool = createReadEntityDocumentTool( - CopilotTool.read_mcp_server, - mcpServerToolConfig -) -export const CreateMcpServerClientTool = createEntityDocumentMutationTool( - 'create_mcp_server', - mcpServerToolConfig, - 'create' -) -export const EditMcpServerClientTool = createEntityDocumentMutationTool( - 'edit_mcp_server', - mcpServerToolConfig, - 'edit' -) -export const RenameMcpServerClientTool = createEntityDocumentMutationTool( - 'rename_mcp_server', - mcpServerToolConfig, - 'rename' -) diff --git a/apps/tradinggoose/lib/copilot/tools/client/entities/entity-tools.test.ts b/apps/tradinggoose/lib/copilot/tools/client/entities/entity-tools.test.ts deleted file mode 100644 index b4700a485..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/entities/entity-tools.test.ts +++ /dev/null @@ -1,745 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ToolArgSchemas, ToolResultSchemas } from '@/lib/copilot/registry' -import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' -import { resolveCopilotEntityYjsSessionLease } from '@/lib/copilot/tools/client/entities/entity-document-tool-utils' -import { - CreateSkillClientTool, - EditSkillClientTool, - ListIndicatorsClientTool, - ListSkillsClientTool, - ReadCustomToolClientTool, - ReadIndicatorClientTool, -} from '@/lib/copilot/tools/client/entities/entity-document-tools' - -const mockRegistryState = { - workflows: {} as Record, -} - -const mockCopilotState = { - toolCallsById: {} as Record }>, -} - -const mockEntityFieldState = { - values: {} as Record, -} -const mockBootstrapYjsProvider = vi.fn() -const mockWaitForYjsWriteSync = vi.fn() - -const originalFetch = globalThis.fetch - -vi.mock('@/stores/workflows/registry/store', () => ({ - useWorkflowRegistry: { - getState: () => mockRegistryState, - }, -})) - -vi.mock('@/stores/copilot/store-access', () => ({ - getCopilotStoreForToolCall: () => ({ - getState: () => mockCopilotState, - }), -})) - -vi.mock('@/lib/yjs/provider', () => ({ - bootstrapYjsProvider: (...args: any[]) => mockBootstrapYjsProvider(...args), - waitForYjsWriteSync: (...args: any[]) => mockWaitForYjsWriteSync(...args), -})) - -vi.mock('@/lib/yjs/entity-session', () => ({ - getEntityFields: () => ({ ...mockEntityFieldState.values }), - setEntityField: (_doc: unknown, key: string, value: unknown) => { - mockEntityFieldState.values[key] = value - }, - replaceEntityTextField: (_doc: unknown, key: string, value: string) => { - mockEntityFieldState.values[key] = value - }, -})) - -describe('entity document tools', () => { - beforeEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals?.() - globalThis.fetch = originalFetch - mockRegistryState.workflows = { - 'wf-context': { workspaceId: 'ws-1' }, - } - mockCopilotState.toolCallsById = {} - mockEntityFieldState.values = {} - mockBootstrapYjsProvider.mockReset() - mockWaitForYjsWriteSync.mockReset() - mockWaitForYjsWriteSync.mockResolvedValue(undefined) - }) - - function mockSavedEntitySession(entityKind: string, entityId: string, workspaceId = 'ws-1') { - const provider = { - synced: true, - on: vi.fn(), - off: vi.fn(), - disconnect: vi.fn(), - destroy: vi.fn(), - } - const doc = { - transact: (cb: () => void) => cb(), - destroy: vi.fn(), - } - const descriptor = { - workspaceId, - entityKind, - entityId, - reviewSessionId: null, - draftSessionId: null, - yjsSessionId: entityId, - } - mockBootstrapYjsProvider.mockResolvedValue({ - descriptor, - doc, - provider, - runtime: null, - }) - return descriptor - } - - it('list_skills returns generic entity list results', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const method = init?.method || 'GET' - - if (url === '/api/skills?workspaceId=ws-1' && method === 'GET') { - return { - ok: true, - status: 200, - json: async () => ({ - data: [ - { - id: 'skill-1', - name: 'market-research', - description: 'Research a market before trading.', - }, - ], - }), - } - } - - if (url === '/api/copilot/tools/mark-complete' && method === 'POST') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url} (${method})`) - }) - vi.stubGlobal('fetch', fetchMock) - - const toolCallId = 'list-skills' - const tool = new ListSkillsClientTool(toolCallId) - tool.setExecutionContext({ - toolCallId, - toolName: 'list_skills', - channelId: 'pair-yellow', - workspaceId: 'ws-1', - log: vi.fn(), - }) - - await tool.execute() - - expect(fetchMock).toHaveBeenCalledWith('/api/skills?workspaceId=ws-1') - expect(tool.getState()).toBe(ClientToolCallState.success) - - const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => { - const url = typeof input === 'string' ? input : input.toString() - return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST' - }) - const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body)) - expect(markCompleteBody.data).toMatchObject({ - entityKind: 'skill', - count: 1, - }) - expect(markCompleteBody.data.entities).toEqual([ - { - entityId: 'skill-1', - entityName: 'market-research', - entityDescription: 'Research a market before trading.', - }, - ]) - }) - - it('read_custom_tool reads the explicit target entity and returns an entity document', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const method = init?.method || 'GET' - - if (url === '/api/copilot/tools/mark-complete' && method === 'POST') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url} (${method})`) - }) - vi.stubGlobal('fetch', fetchMock) - mockEntityFieldState.values = { - title: 'market-tool', - schemaText: JSON.stringify( - { - type: 'function', - function: { - description: 'Fetch market data', - parameters: { type: 'object', properties: {} }, - }, - }, - null, - 2 - ), - codeText: 'return 1', - } - const descriptor = mockSavedEntitySession('custom_tool', 'tool-1') - - const toolCallId = 'get-custom-tool' - const tool = new ReadCustomToolClientTool(toolCallId) - tool.setExecutionContext({ - toolCallId, - toolName: 'read_custom_tool', - channelId: 'pair-orange', - workspaceId: 'ws-1', - log: vi.fn(), - }) - - await tool.execute({ entityId: 'tool-1' }) - - expect(tool.getState()).toBe(ClientToolCallState.success) - expect(mockBootstrapYjsProvider).toHaveBeenCalledWith(descriptor) - - const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => { - const url = typeof input === 'string' ? input : input.toString() - return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST' - }) - const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body)) - - expect(markCompleteBody.data).toMatchObject({ - entityKind: 'custom_tool', - entityId: 'tool-1', - entityName: 'market-tool', - documentFormat: 'tg-custom-tool-document-v1', - }) - expect(markCompleteBody.data.entityDocument).toContain('"title": "market-tool"') - expect(markCompleteBody.data.entityDocument).toContain('"codeText": "return 1"') - expect(fetchMock).not.toHaveBeenCalledWith('/api/tools/custom?workspaceId=ws-1') - }) - - it('create_skill inserts through the canonical skills API after approval', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const method = init?.method || 'GET' - - if (url === '/api/skills' && method === 'POST') { - expect(JSON.parse(String(init?.body))).toEqual({ - workspaceId: 'ws-1', - skills: [ - { - name: 'new-skill', - description: 'New skill description', - content: 'Do useful work.', - }, - ], - }) - - return { - ok: true, - status: 200, - json: async () => ({ - success: true, - data: [ - { - id: 'skill-new', - name: 'new-skill', - description: 'New skill description', - content: 'Do useful work.', - }, - ], - }), - } - } - - if (url === '/api/copilot/tools/mark-complete' && method === 'POST') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url} (${method})`) - }) - vi.stubGlobal('fetch', fetchMock) - - const toolCallId = 'create-skill' - const tool = new CreateSkillClientTool(toolCallId) - tool.setExecutionContext({ - toolCallId, - toolName: 'create_skill', - channelId: 'pair-yellow', - workspaceId: 'ws-1', - log: vi.fn(), - }) - - await tool.execute({ - entityDocument: JSON.stringify({ - name: 'new-skill', - description: 'New skill description', - content: 'Do useful work.', - }), - documentFormat: 'tg-skill-document-v1', - }) - - expect(tool.getState()).toBe(ClientToolCallState.review) - await tool.handleAccept() - - expect(tool.getState()).toBe(ClientToolCallState.success) - expect(mockBootstrapYjsProvider).not.toHaveBeenCalled() - - const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => { - const url = typeof input === 'string' ? input : input.toString() - return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST' - }) - const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body)) - expect(markCompleteBody.name).toBe('create_skill') - expect(markCompleteBody.data).toMatchObject({ - success: true, - entityKind: 'skill', - entityId: 'skill-new', - entityName: 'new-skill', - documentFormat: 'tg-skill-document-v1', - }) - expect(markCompleteBody.data.entityDocument).toContain('"name": "new-skill"') - expect(markCompleteBody.data).not.toHaveProperty('reviewSessionId') - expect(markCompleteBody.data).not.toHaveProperty('draftSessionId') - }) - - it('list_indicators returns built-in and custom indicators with capability flags', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const method = init?.method || 'GET' - - if (url === '/api/indicators/options?workspaceId=ws-1&surface=copilot' && method === 'GET') { - return { - ok: true, - status: 200, - json: async () => ({ - data: [ - { - id: 'RSI', - name: 'Relative Strength Index', - source: 'default', - editable: false, - callableInFunctionBlock: true, - inputTitles: ['Length'], - runtimeId: 'RSI', - }, - { - id: 'indicator-1', - name: 'My Custom Indicator', - source: 'custom', - editable: true, - callableInFunctionBlock: true, - inputTitles: ['Fast Length'], - entityId: 'indicator-1', - runtimeId: 'indicator-1', - }, - ], - }), - } - } - - if (url === '/api/copilot/tools/mark-complete' && method === 'POST') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url} (${method})`) - }) - vi.stubGlobal('fetch', fetchMock) - - const toolCallId = 'list-indicators' - const tool = new ListIndicatorsClientTool(toolCallId) - tool.setExecutionContext({ - toolCallId, - toolName: 'list_indicators', - channelId: 'pair-cyan', - workspaceId: 'ws-1', - log: vi.fn(), - }) - - await tool.execute() - - expect(tool.getState()).toBe(ClientToolCallState.success) - - const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => { - const url = typeof input === 'string' ? input : input.toString() - return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST' - }) - const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body)) - expect(markCompleteBody.data).toMatchObject({ - entityKind: 'indicator', - count: 2, - }) - expect(markCompleteBody.data.indicators).toEqual([ - { - name: 'Relative Strength Index', - source: 'default', - editable: false, - callableInFunctionBlock: true, - inputTitles: ['Length'], - runtimeId: 'RSI', - }, - { - name: 'My Custom Indicator', - source: 'custom', - editable: true, - callableInFunctionBlock: true, - inputTitles: ['Fast Length'], - entityId: 'indicator-1', - runtimeId: 'indicator-1', - }, - ]) - }) - - it('read_indicator reads a built-in default indicator by runtimeId', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const method = init?.method || 'GET' - - if (url === '/api/copilot/tools/mark-complete' && method === 'POST') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url} (${method})`) - }) - vi.stubGlobal('fetch', fetchMock) - - const toolCallId = 'get-indicator-default' - const tool = new ReadIndicatorClientTool(toolCallId) - tool.setExecutionContext({ - toolCallId, - toolName: 'read_indicator', - channelId: 'pair-yellow', - workspaceId: 'ws-1', - log: vi.fn(), - }) - - await tool.execute({ runtimeId: 'RSI' }) - - expect(tool.getState()).toBe(ClientToolCallState.success) - - const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => { - const url = typeof input === 'string' ? input : input.toString() - return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST' - }) - const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body)) - - expect(markCompleteBody.data).toMatchObject({ - entityKind: 'indicator', - entityName: 'Relative Strength Index', - documentFormat: 'tg-indicator-document-v1', - }) - expect(markCompleteBody.data.entityDocument).toContain('"name": "Relative Strength Index"') - expect(markCompleteBody.data.entityDocument).toContain('"pineCode"') - expect(markCompleteBody.data.entityDocument).toContain('"Length"') - }) - - it('read_indicator reads a custom indicator by runtimeId', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const method = init?.method || 'GET' - - if (url === '/api/copilot/tools/mark-complete' && method === 'POST') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url} (${method})`) - }) - vi.stubGlobal('fetch', fetchMock) - mockEntityFieldState.values = { - name: 'My Custom Indicator', - pineCode: 'indicator("My Custom Indicator")', - inputMeta: { Length: { defaultValue: 14 } }, - } - const descriptor = mockSavedEntitySession('indicator', 'indicator-1') - - const toolCallId = 'get-indicator-custom-runtime' - const tool = new ReadIndicatorClientTool(toolCallId) - tool.setExecutionContext({ - toolCallId, - toolName: 'read_indicator', - channelId: 'pair-yellow', - workspaceId: 'ws-1', - log: vi.fn(), - }) - - await tool.execute({ runtimeId: 'indicator-1' }) - - expect(tool.getState()).toBe(ClientToolCallState.success) - expect(mockBootstrapYjsProvider).toHaveBeenCalledWith(descriptor) - - const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => { - const url = typeof input === 'string' ? input : input.toString() - return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST' - }) - const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body)) - - expect(markCompleteBody.data).toMatchObject({ - entityKind: 'indicator', - entityId: 'indicator-1', - entityName: 'My Custom Indicator', - documentFormat: 'tg-indicator-document-v1', - }) - expect(markCompleteBody.data.entityDocument).toContain('"name": "My Custom Indicator"') - expect(markCompleteBody.data.entityDocument).toContain('"pineCode"') - }) - - it('edit_skill bootstraps the canonical saved-entity Yjs session', async () => { - vi.useFakeTimers() - try { - const provider = { - on: vi.fn(), - off: vi.fn(), - disconnect: vi.fn(), - destroy: vi.fn(), - } - const doc = { - transact: (cb: () => void) => cb(), - destroy: vi.fn(), - } - const descriptor = { - workspaceId: 'ws-1', - entityKind: 'skill', - entityId: 'skill-1', - reviewSessionId: null, - draftSessionId: null, - yjsSessionId: 'skill-1', - } - - mockBootstrapYjsProvider.mockResolvedValue({ - descriptor, - doc, - provider, - runtime: null, - accessMode: 'write', - }) - - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const method = init?.method || 'GET' - - if (url === '/api/copilot/tools/mark-complete' && method === 'POST') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url} (${method})`) - }) - vi.stubGlobal('fetch', fetchMock) - - const toolCallId = 'edit-skill-bootstrap' - const tool = new EditSkillClientTool(toolCallId) - tool.setExecutionContext({ - toolCallId, - toolName: 'edit_skill', - workspaceId: 'ws-1', - log: vi.fn(), - }) - - await tool.execute({ - entityId: 'skill-1', - entityDocument: JSON.stringify({ - name: 'bootstrapped-skill', - description: 'Updated through tool lease', - content: 'Updated content', - }), - documentFormat: 'tg-skill-document-v1', - }) - await tool.handleAccept() - - expect(tool.getState()).toBe(ClientToolCallState.success) - expect(mockBootstrapYjsProvider).toHaveBeenCalledWith(descriptor) - expect(mockEntityFieldState.values).toMatchObject({ - name: 'bootstrapped-skill', - description: 'Updated through tool lease', - content: 'Updated content', - }) - - await vi.runOnlyPendingTimersAsync() - expect(provider.disconnect).toHaveBeenCalled() - expect(provider.destroy).toHaveBeenCalled() - expect(doc.destroy).toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) - - it('requires write-authorized bootstrap for saved entity sessions', async () => { - mockBootstrapYjsProvider.mockRejectedValue(new Error('Snapshot fetch failed: 403')) - - await expect( - resolveCopilotEntityYjsSessionLease( - { toolCallId: 'edit-skill', toolName: 'edit_skill', workspaceId: 'ws-1' }, - 'skill', - 'skill-1' - ) - ).rejects.toThrow('Snapshot fetch failed: 403') - expect(mockBootstrapYjsProvider).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - entityKind: 'skill', - entityId: 'skill-1', - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: 'skill-1', - }) - }) - - it('edit_skill rejects edits without an explicit entityId', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const method = init?.method || 'GET' - - if (url === '/api/copilot/tools/mark-complete' && method === 'POST') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url} (${method})`) - }) - vi.stubGlobal('fetch', fetchMock) - - const toolCallId = 'edit-skill-without-entity-id' - const tool = new EditSkillClientTool(toolCallId) - tool.setExecutionContext({ - toolCallId, - toolName: 'edit_skill', - channelId: 'pair-purple', - workspaceId: 'ws-1', - log: vi.fn(), - }) - - await tool.execute({ - entityDocument: JSON.stringify({ - name: 'new-skill', - description: '', - content: '', - }), - documentFormat: 'tg-skill-document-v1', - } as any) - await tool.handleAccept() - - expect(tool.getState()).toBe(ClientToolCallState.error) - expect(mockEntityFieldState.values).toEqual({}) - - const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => { - const url = typeof input === 'string' ? input : input.toString() - return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST' - }) - const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body)) - expect(markCompleteBody.status).toBe(500) - expect(markCompleteBody.message).toContain('entityId is required to update a saved skill') - }) - - it('registry schemas accept optional explicit entity ids for entity document tools', () => { - expect(ToolArgSchemas.list_skills.parse({})).toMatchObject({}) - expect(ToolArgSchemas.read_skill.parse({ entityId: 'skill-1' })).toMatchObject({ - entityId: 'skill-1', - }) - expect(() => ToolArgSchemas.read_skill.parse({})).toThrow() - expect(ToolArgSchemas.read_indicator.parse({ runtimeId: 'RSI' })).toMatchObject({ - runtimeId: 'RSI', - }) - expect(() => ToolArgSchemas.read_indicator.parse({})).toThrow() - expect( - ToolArgSchemas.create_skill.parse({ - entityDocument: '{"name":"skill","description":"","content":""}', - }) - ).toMatchObject({ - entityDocument: '{"name":"skill","description":"","content":""}', - }) - expect(() => - ToolArgSchemas.create_skill.parse({ - entityId: 'skill-1', - entityDocument: '{"name":"skill","description":"","content":""}', - }) - ).toThrow() - expect( - ToolArgSchemas.edit_skill.parse({ - entityId: 'skill-1', - entityDocument: '{"name":"skill","description":"","content":""}', - }) - ).toMatchObject({ - entityId: 'skill-1', - entityDocument: '{"name":"skill","description":"","content":""}', - }) - expect(() => - ToolArgSchemas.edit_skill.parse({ - entityDocument: '{"name":"skill","description":"","content":""}', - }) - ).toThrow() - expect( - ToolResultSchemas.read_custom_tool.parse({ - entityKind: 'custom_tool', - entityId: 'tool-1', - entityName: 'market-tool', - documentFormat: 'tg-custom-tool-document-v1', - entityDocument: '{}', - }) - ).toBeDefined() - expect( - ToolResultSchemas.list_skills.parse({ - entityKind: 'skill', - entities: [], - count: 0, - }) - ).toBeDefined() - expect( - ToolResultSchemas.list_indicators.parse({ - entityKind: 'indicator', - indicators: [ - { - name: 'Relative Strength Index', - source: 'default', - editable: false, - callableInFunctionBlock: true, - runtimeId: 'RSI', - inputTitles: ['Length'], - }, - ], - count: 1, - }) - ).toBeDefined() - expect( - ToolResultSchemas.rename_skill.parse({ - success: true, - entityKind: 'skill', - entityId: 'skill-1', - entityName: 'renamed-skill', - documentFormat: 'tg-skill-document-v1', - entityDocument: '{"name":"renamed-skill","description":"","content":""}', - }) - ).toBeDefined() - }) -}) diff --git a/apps/tradinggoose/lib/copilot/tools/client/execution-context.ts b/apps/tradinggoose/lib/copilot/tools/client/execution-context.ts new file mode 100644 index 000000000..06e16366e --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/client/execution-context.ts @@ -0,0 +1,13 @@ +import type { ClientToolExecutionContext } from '@/lib/copilot/tools/client/base-tool' + +export function resolveWorkspaceIdFromExecutionContext( + executionContext: ClientToolExecutionContext +): string { + if (executionContext.workspaceId) { + return executionContext.workspaceId + } + + throw new Error( + 'No active workspace found in execution context. Ensure workspaceId is included in tool provenance.' + ) +} diff --git a/apps/tradinggoose/lib/copilot/tools/client/knowledge/knowledge-base.ts b/apps/tradinggoose/lib/copilot/tools/client/knowledge/knowledge-base.ts deleted file mode 100644 index bd4afeb64..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/knowledge/knowledge-base.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Database, Loader2, MinusCircle, PlusCircle, XCircle } from 'lucide-react' -import { - BaseClientTool, - type BaseClientToolMetadata, - ClientToolCallState, -} from '@/lib/copilot/tools/client/base-tool' -import { - executeCopilotServerTool, - getCopilotServerToolErrorStatus, -} from '@/lib/copilot/tools/client/server-tool-response' -import type { KnowledgeBaseArgs } from '@/lib/copilot/tools/shared/schemas' -import { createLogger } from '@/lib/logs/console/logger' -import { getCopilotStoreForToolCall } from '@/stores/copilot/store-access' - -/** - * Client tool for knowledge base operations - */ -export class KnowledgeBaseClientTool extends BaseClientTool { - static readonly id = 'knowledge_base' - - constructor(toolCallId: string) { - super(toolCallId, KnowledgeBaseClientTool.id, KnowledgeBaseClientTool.metadata) - } - - getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined { - const toolCallsById = getCopilotStoreForToolCall(this.toolCallId).getState().toolCallsById - const toolCall = toolCallsById[this.toolCallId] - const params = toolCall?.params as KnowledgeBaseArgs | undefined - - if (params?.operation === 'create') { - const name = params?.args?.name || 'new knowledge base' - return { - accept: { text: `Create "${name}"`, icon: PlusCircle }, - reject: { text: 'Skip', icon: XCircle }, - } - } - - return undefined - } - - static readonly metadata: BaseClientToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Accessing knowledge base', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Accessing knowledge base', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Accessing knowledge base', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Accessed knowledge base', icon: Database }, - [ClientToolCallState.error]: { text: 'Failed to access knowledge base', icon: XCircle }, - [ClientToolCallState.aborted]: { text: 'Aborted knowledge base access', icon: MinusCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped knowledge base access', icon: MinusCircle }, - }, - getDynamicText: (params: Record, state: ClientToolCallState) => { - const operation = params?.operation as string | undefined - const name = params?.args?.name as string | undefined - - const opVerbs: Record = { - create: { - active: 'Creating knowledge base', - past: 'Created knowledge base', - pending: name ? `Create knowledge base "${name}"?` : 'Create knowledge base?', - }, - list: { active: 'Listing knowledge bases', past: 'Listed knowledge bases' }, - get: { active: 'Getting knowledge base', past: 'Retrieved knowledge base' }, - query: { active: 'Querying knowledge base', past: 'Queried knowledge base' }, - } - const defaultVerb: { active: string; past: string; pending?: string } = { - active: 'Accessing knowledge base', - past: 'Accessed knowledge base', - } - const verb = operation ? opVerbs[operation] || defaultVerb : defaultVerb - - if (state === ClientToolCallState.success) { - return verb.past - } - if (state === ClientToolCallState.pending && verb.pending) { - return verb.pending - } - if ( - state === ClientToolCallState.generating || - state === ClientToolCallState.pending || - state === ClientToolCallState.executing - ) { - return verb.active - } - return undefined - }, - } - - async handleReject(): Promise { - await super.handleReject() - this.setState(ClientToolCallState.rejected) - } - - async handleAccept(args?: KnowledgeBaseArgs): Promise { - await this.execute(args) - } - - async execute(args?: KnowledgeBaseArgs): Promise { - const logger = createLogger('KnowledgeBaseClientTool') - try { - this.setState(ClientToolCallState.executing) - const executionContext = this.getExecutionContext() - const payload: KnowledgeBaseArgs = { ...(args || { operation: 'list' }) } - if ( - executionContext?.workspaceId && - (payload.operation === 'create' || payload.operation === 'list') && - !payload.args?.workspaceId - ) { - payload.args = { ...(payload.args ?? {}), workspaceId: executionContext.workspaceId } - } - const result = await executeCopilotServerTool({ - toolName: 'knowledge_base', - payload, - context: executionContext?.workspaceId - ? { workspaceId: executionContext.workspaceId } - : undefined, - signal: this.getAbortSignal(), - }) - await this.markToolComplete(200, 'Knowledge base operation completed', result) - this.setState(ClientToolCallState.success) - } catch (e: any) { - logger.error('execute failed', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete( - getCopilotServerToolErrorStatus(e) ?? 500, - e?.message || 'Failed to access knowledge base' - ) - } - } -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/monitor/edit-monitor.ts b/apps/tradinggoose/lib/copilot/tools/client/monitor/edit-monitor.ts deleted file mode 100644 index 52cad8440..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/monitor/edit-monitor.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { Activity, Check, Loader2, X, XCircle } from 'lucide-react' -import { - MONITOR_DOCUMENT_FORMAT, - parseMonitorDocument, - readMonitorDocumentName, - serializeMonitorDocument, -} from '@/lib/copilot/monitor/monitor-documents' -import { - BaseClientTool, - type BaseClientToolMetadata, - ClientToolCallState, -} from '@/lib/copilot/tools/client/base-tool' -import { resolveWorkspaceIdFromExecutionContext } from '@/lib/copilot/tools/client/entities/entity-document-tool-utils' -import { - type EditMonitorArgs, - type MonitorRecord, - readStoredToolArgs, - toMonitorDocumentFields, -} from '@/lib/copilot/tools/client/monitor/monitor-tool-utils' - -export class EditMonitorClientTool extends BaseClientTool { - static readonly id = 'edit_monitor' - private currentArgs?: EditMonitorArgs - - static readonly metadata: BaseClientToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Editing monitor document', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Edit monitor document?', icon: Activity }, - [ClientToolCallState.executing]: { text: 'Editing monitor document', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Edited monitor document', icon: Check }, - [ClientToolCallState.error]: { text: 'Failed to edit monitor document', icon: X }, - [ClientToolCallState.aborted]: { text: 'Aborted editing monitor document', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped editing monitor document', icon: XCircle }, - }, - interrupt: { - accept: { text: 'Allow', icon: Check }, - reject: { text: 'Skip', icon: XCircle }, - }, - } - - constructor(toolCallId: string) { - super(toolCallId, EditMonitorClientTool.id, EditMonitorClientTool.metadata) - } - - getInterruptDisplays(): BaseClientToolMetadata['interrupt'] | undefined { - const args = this.currentArgs || readStoredToolArgs(this.toolCallId) - return args?.monitorDocument ? this.metadata.interrupt : undefined - } - - async execute(args?: EditMonitorArgs): Promise { - this.currentArgs = args - } - - async handleAccept(args?: EditMonitorArgs): Promise { - try { - this.setState(ClientToolCallState.executing) - - const resolvedArgs = - args || this.currentArgs || readStoredToolArgs(this.toolCallId) - - if (!resolvedArgs?.monitorId?.trim()) { - throw new Error('monitorId is required') - } - if (!resolvedArgs.monitorDocument?.trim()) { - throw new Error('monitorDocument is required') - } - if (resolvedArgs.documentFormat && resolvedArgs.documentFormat !== MONITOR_DOCUMENT_FORMAT) { - throw new Error( - `Unsupported documentFormat "${resolvedArgs.documentFormat}". Expected ${MONITOR_DOCUMENT_FORMAT}` - ) - } - - const executionContext = this.requireExecutionContext() - const workspaceId = resolveWorkspaceIdFromExecutionContext(executionContext) - const nextFields = parseMonitorDocument(resolvedArgs.monitorDocument) - - const response = await fetch(`/api/monitors/${encodeURIComponent(resolvedArgs.monitorId)}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - source: nextFields.source, - workspaceId, - workflowId: nextFields.workflowId, - blockId: nextFields.blockId, - providerId: nextFields.providerId, - ...(nextFields.source === 'portfolio' - ? { - serviceId: nextFields.serviceId, - credentialId: nextFields.credentialId, - accountId: nextFields.accountId, - condition: nextFields.condition, - fireMode: nextFields.fireMode, - cooldownSeconds: nextFields.cooldownSeconds, - pollIntervalSeconds: nextFields.pollIntervalSeconds, - } - : { - interval: nextFields.interval, - indicatorId: nextFields.indicatorId, - listing: nextFields.listing, - ...(nextFields.providerParams ? { providerParams: nextFields.providerParams } : {}), - ...(nextFields.auth ? { auth: nextFields.auth } : {}), - }), - isActive: nextFields.isActive, - }), - }) - const payload = await response.json().catch(() => ({})) - - if (!response.ok) { - throw new Error(payload?.error || `Failed to update monitor: ${response.status}`) - } - - const updatedMonitor = - payload?.data && typeof payload.data === 'object' ? (payload.data as MonitorRecord) : null - - if (!updatedMonitor) { - throw new Error('Invalid updated monitor response') - } - - const persistedFields = toMonitorDocumentFields(updatedMonitor) - await this.markToolComplete(200, 'Monitor updated', { - success: true, - surfaceKind: 'monitor', - monitorId: updatedMonitor.monitorId, - monitorName: readMonitorDocumentName(persistedFields), - documentFormat: MONITOR_DOCUMENT_FORMAT, - monitorDocument: serializeMonitorDocument(persistedFields), - }) - this.setState(ClientToolCallState.success) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - await this.markToolComplete(500, message) - this.setState(ClientToolCallState.error) - } - } -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/monitor/list-monitors.ts b/apps/tradinggoose/lib/copilot/tools/client/monitor/list-monitors.ts deleted file mode 100644 index 1552854cf..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/monitor/list-monitors.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Activity, Loader2, X, XCircle } from 'lucide-react' -import { - BaseClientTool, - type BaseClientToolMetadata, - ClientToolCallState, -} from '@/lib/copilot/tools/client/base-tool' -import { resolveWorkspaceIdFromExecutionContext } from '@/lib/copilot/tools/client/entities/entity-document-tool-utils' -import { - buildMonitorName, - type ListMonitorArgs, - type MonitorRecord, -} from '@/lib/copilot/tools/client/monitor/monitor-tool-utils' -import { resolveOptionalCopilotEntityId } from '@/lib/copilot/tools/entity-target' - -export class ListMonitorsClientTool extends BaseClientTool { - static readonly id = 'list_monitors' - - static readonly metadata: BaseClientToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Listing monitors', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'List monitors', icon: Activity }, - [ClientToolCallState.executing]: { text: 'Listing monitors', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Listed monitors', icon: Activity }, - [ClientToolCallState.error]: { text: 'Failed to list monitors', icon: X }, - [ClientToolCallState.aborted]: { text: 'Aborted listing monitors', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped listing monitors', icon: XCircle }, - }, - } - - constructor(toolCallId: string) { - super(toolCallId, ListMonitorsClientTool.id, ListMonitorsClientTool.metadata) - } - - async execute(args?: ListMonitorArgs): Promise { - try { - this.setState(ClientToolCallState.executing) - const executionContext = this.requireExecutionContext() - const workspaceId = resolveWorkspaceIdFromExecutionContext(executionContext) - const searchParams = new URLSearchParams({ workspaceId }) - - const entityId = resolveOptionalCopilotEntityId(args) - if (entityId) { - searchParams.set('workflowId', entityId) - } - if (args?.blockId) { - searchParams.set('blockId', args.blockId) - } - - const response = await fetch(`/api/monitors?${searchParams.toString()}`) - const payload = await response.json().catch(() => ({})) - - if (!response.ok) { - throw new Error(payload?.error || `Failed to fetch monitors: ${response.status}`) - } - - const monitors = Array.isArray(payload?.data) ? (payload.data as MonitorRecord[]) : [] - const monitorsList = monitors.map((monitor) => ({ - monitorId: monitor.monitorId, - monitorName: buildMonitorName(monitor), - monitorDescription: `Workflow ${monitor.workflowId}, block ${monitor.blockId}`, - workflowId: monitor.workflowId, - blockId: monitor.blockId, - source: monitor.source, - providerId: monitor.providerConfig.monitor.providerId, - indicatorId: monitor.providerConfig.monitor.indicatorId, - interval: monitor.providerConfig.monitor.interval, - isActive: monitor.isActive, - createdAt: monitor.createdAt, - updatedAt: monitor.updatedAt, - })) - - await this.markToolComplete(200, 'Listed monitors', { - surfaceKind: 'monitor', - monitors: monitorsList, - count: monitorsList.length, - }) - this.setState(ClientToolCallState.success) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - await this.markToolComplete(500, message) - this.setState(ClientToolCallState.error) - } - } -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tools.test.ts b/apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tools.test.ts deleted file mode 100644 index 1c84dfcff..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tools.test.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ToolArgSchemas, ToolResultSchemas } from '@/lib/copilot/registry' -import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' -import { EditMonitorClientTool } from '@/lib/copilot/tools/client/monitor/edit-monitor' -import { ListMonitorsClientTool } from '@/lib/copilot/tools/client/monitor/list-monitors' -import { ReadMonitorClientTool } from '@/lib/copilot/tools/client/monitor/read-monitor' - -const mockRegistryState = { - workflows: {} as Record, -} - -const mockCopilotState = { - toolCallsById: {} as Record }>, -} - -vi.mock('@/stores/workflows/registry/store', () => ({ - useWorkflowRegistry: { - getState: () => mockRegistryState, - }, -})) - -vi.mock('@/stores/copilot/store-access', () => ({ - getCopilotStoreForToolCall: () => ({ - getState: () => mockCopilotState, - }), -})) - -describe('monitor tools', () => { - beforeEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals?.() - mockRegistryState.workflows = {} - mockCopilotState.toolCallsById = {} - }) - - it('list_monitors returns workspace monitor entries', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const method = init?.method || 'GET' - - if (url === '/api/monitors?workspaceId=ws-1' && method === 'GET') { - return { - ok: true, - status: 200, - json: async () => ({ - data: [ - { - monitorId: 'monitor-1', - source: 'indicator', - workflowId: 'wf-1', - blockId: 'trigger-1', - isActive: true, - providerConfig: { - triggerId: 'indicator_trigger', - version: 1, - monitor: { - providerId: 'alpaca', - interval: '1m', - indicatorId: 'rsi', - listing: { - listing_type: 'default', - listing_id: 'AAPL', - base_id: '', - quote_id: '', - name: 'Apple Inc.', - }, - }, - }, - createdAt: '2026-04-11T00:00:00.000Z', - updatedAt: '2026-04-11T01:00:00.000Z', - }, - ], - }), - } - } - - if (url === '/api/copilot/tools/mark-complete' && method === 'POST') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url} (${method})`) - }) - vi.stubGlobal('fetch', fetchMock) - - const tool = new ListMonitorsClientTool('list-monitors') - tool.setExecutionContext({ - toolCallId: 'list-monitors', - toolName: 'list_monitors', - channelId: 'pair-blue', - workspaceId: 'ws-1', - log: vi.fn(), - }) - - await tool.execute() - - expect(tool.getState()).toBe(ClientToolCallState.success) - expect(fetchMock).toHaveBeenCalledWith('/api/monitors?workspaceId=ws-1') - - const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => { - const url = typeof input === 'string' ? input : input.toString() - return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST' - }) - const body = JSON.parse(String(markCompleteCall?.[1]?.body)) - expect(body.data).toMatchObject({ - surfaceKind: 'monitor', - count: 1, - }) - expect(body.data.monitors[0]).toMatchObject({ - monitorId: 'monitor-1', - monitorName: 'rsi on Apple Inc. (1m)', - workflowId: 'wf-1', - blockId: 'trigger-1', - providerId: 'alpaca', - indicatorId: 'rsi', - interval: '1m', - isActive: true, - }) - }) - - it('read_monitor returns a monitor document', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const method = init?.method || 'GET' - - if (url === '/api/monitors/monitor-1' && method === 'GET') { - return { - ok: true, - status: 200, - json: async () => ({ - data: { - monitorId: 'monitor-1', - source: 'indicator', - workflowId: 'wf-1', - blockId: 'trigger-1', - isActive: true, - providerConfig: { - triggerId: 'indicator_trigger', - version: 1, - monitor: { - providerId: 'alpaca', - interval: '5m', - indicatorId: 'rsi', - listing: { - listing_type: 'default', - listing_id: 'AAPL', - base_id: '', - quote_id: '', - }, - auth: { - hasEncryptedSecrets: true, - encryptedSecretFieldIds: ['apiKey'], - }, - providerParams: { - exchange: 'NASDAQ', - }, - }, - }, - createdAt: '2026-04-11T00:00:00.000Z', - updatedAt: '2026-04-11T01:00:00.000Z', - }, - }), - } - } - - if (url === '/api/copilot/tools/mark-complete' && method === 'POST') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url} (${method})`) - }) - vi.stubGlobal('fetch', fetchMock) - - const tool = new ReadMonitorClientTool('read-monitor') - tool.setExecutionContext({ - toolCallId: 'read-monitor', - toolName: 'read_monitor', - channelId: 'pair-green', - workspaceId: 'ws-1', - log: vi.fn(), - }) - - await tool.execute({ monitorId: 'monitor-1' }) - - expect(tool.getState()).toBe(ClientToolCallState.success) - - const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => { - const url = typeof input === 'string' ? input : input.toString() - return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST' - }) - const body = JSON.parse(String(markCompleteCall?.[1]?.body)) - expect(body.data).toMatchObject({ - surfaceKind: 'monitor', - monitorId: 'monitor-1', - documentFormat: 'tg-monitor-document-v1', - }) - expect(body.data.monitorDocument).toContain('"providerId": "alpaca"') - expect(body.data.monitorDocument).toContain('"interval": "5m"') - expect(body.data.monitorDocument).not.toContain('"secrets"') - }) - - it('edit_monitor patches the monitor after accept', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const method = init?.method || 'GET' - - if (url === '/api/monitors/monitor-1' && method === 'PATCH') { - const payload = JSON.parse(String(init?.body)) - expect(payload).toMatchObject({ - source: 'indicator', - workspaceId: 'ws-1', - workflowId: 'wf-1', - blockId: 'trigger-1', - providerId: 'alpaca', - interval: '15m', - indicatorId: 'rsi', - isActive: false, - }) - - return { - ok: true, - status: 200, - json: async () => ({ - data: { - monitorId: 'monitor-1', - source: 'indicator', - workflowId: 'wf-1', - blockId: 'trigger-1', - isActive: false, - providerConfig: { - triggerId: 'indicator_trigger', - version: 1, - monitor: { - providerId: 'alpaca', - interval: '15m', - indicatorId: 'rsi', - listing: { - listing_type: 'default', - listing_id: 'AAPL', - base_id: '', - quote_id: '', - }, - providerParams: { - exchange: 'NASDAQ', - }, - }, - }, - createdAt: '2026-04-11T00:00:00.000Z', - updatedAt: '2026-04-11T02:00:00.000Z', - }, - }), - } - } - - if (url === '/api/copilot/tools/mark-complete' && method === 'POST') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url} (${method})`) - }) - vi.stubGlobal('fetch', fetchMock) - - const tool = new EditMonitorClientTool('edit-monitor') - tool.setExecutionContext({ - toolCallId: 'edit-monitor', - toolName: 'edit_monitor', - channelId: 'pair-red', - workspaceId: 'ws-1', - log: vi.fn(), - }) - - const monitorDocument = JSON.stringify( - { - source: 'indicator', - workflowId: 'wf-1', - blockId: 'trigger-1', - providerId: 'alpaca', - interval: '15m', - indicatorId: 'rsi', - listing: { - listing_type: 'default', - listing_id: 'AAPL', - base_id: '', - quote_id: '', - }, - isActive: false, - providerParams: { - exchange: 'NASDAQ', - }, - }, - null, - 2 - ) - - await tool.execute({ - monitorId: 'monitor-1', - monitorDocument, - documentFormat: 'tg-monitor-document-v1', - }) - await tool.handleAccept() - - expect(tool.getState()).toBe(ClientToolCallState.success) - - const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => { - const url = typeof input === 'string' ? input : input.toString() - return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST' - }) - const body = JSON.parse(String(markCompleteCall?.[1]?.body)) - expect(body.data).toMatchObject({ - success: true, - surfaceKind: 'monitor', - monitorId: 'monitor-1', - documentFormat: 'tg-monitor-document-v1', - }) - expect(body.data.monitorDocument).toContain('"interval": "15m"') - expect(body.data.monitorDocument).toContain('"isActive": false') - }) - - it('exposes monitor tool schemas', () => { - expect( - ToolArgSchemas.list_monitors.parse({ - entityId: 'wf-1', - }) - ).toMatchObject({ entityId: 'wf-1' }) - - expect( - ToolArgSchemas.edit_monitor.parse({ - monitorId: 'monitor-1', - monitorDocument: - '{"source":"indicator","workflowId":"wf-1","blockId":"trigger-1","providerId":"alpaca","interval":"1m","indicatorId":"rsi","listing":{"listing_type":"default","listing_id":"AAPL","base_id":"","quote_id":""},"isActive":true}', - }) - ).toMatchObject({ - monitorId: 'monitor-1', - }) - - expect( - ToolResultSchemas.read_monitor.parse({ - surfaceKind: 'monitor', - monitorId: 'monitor-1', - monitorName: 'rsi on AAPL (1m)', - documentFormat: 'tg-monitor-document-v1', - monitorDocument: - '{"source":"indicator","workflowId":"wf-1","blockId":"trigger-1","providerId":"alpaca","interval":"1m","indicatorId":"rsi","listing":{"listing_type":"default","listing_id":"AAPL","base_id":"","quote_id":""},"isActive":true}', - }) - ).toMatchObject({ - surfaceKind: 'monitor', - monitorId: 'monitor-1', - }) - }) -}) diff --git a/apps/tradinggoose/lib/copilot/tools/client/monitor/read-monitor.ts b/apps/tradinggoose/lib/copilot/tools/client/monitor/read-monitor.ts deleted file mode 100644 index 6345b12dc..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/monitor/read-monitor.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { FileJson, Loader2, X, XCircle } from 'lucide-react' -import { - MONITOR_DOCUMENT_FORMAT, - readMonitorDocumentName, - serializeMonitorDocument, -} from '@/lib/copilot/monitor/monitor-documents' -import { CopilotTool } from '@/lib/copilot/registry' -import { - BaseClientTool, - type BaseClientToolMetadata, - ClientToolCallState, -} from '@/lib/copilot/tools/client/base-tool' -import { - fetchMonitorById, - type ReadMonitorArgs, - toMonitorDocumentFields, -} from '@/lib/copilot/tools/client/monitor/monitor-tool-utils' - -export class ReadMonitorClientTool extends BaseClientTool { - static readonly id = CopilotTool.read_monitor - - static readonly metadata: BaseClientToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Reading monitor document', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Read monitor document', icon: FileJson }, - [ClientToolCallState.executing]: { text: 'Reading monitor document', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Read monitor document', icon: FileJson }, - [ClientToolCallState.error]: { text: 'Failed to read monitor document', icon: X }, - [ClientToolCallState.aborted]: { text: 'Aborted reading monitor document', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped reading monitor document', icon: XCircle }, - }, - } - - constructor(toolCallId: string) { - super(toolCallId, ReadMonitorClientTool.id, ReadMonitorClientTool.metadata) - } - - async execute(args?: ReadMonitorArgs): Promise { - try { - this.setState(ClientToolCallState.executing) - - if (!args?.monitorId?.trim()) { - throw new Error('monitorId is required') - } - - const monitor = await fetchMonitorById(args.monitorId) - const fields = toMonitorDocumentFields(monitor) - - await this.markToolComplete(200, 'Monitor document ready', { - surfaceKind: 'monitor', - monitorId: monitor.monitorId, - monitorName: readMonitorDocumentName(fields), - documentFormat: MONITOR_DOCUMENT_FORMAT, - monitorDocument: serializeMonitorDocument(fields), - }) - this.setState(ClientToolCallState.success) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - await this.markToolComplete(500, message) - this.setState(ClientToolCallState.error) - } - } -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts b/apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts index 9c2b763f1..6c4b785d6 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts @@ -1,20 +1,34 @@ +import type { LucideIcon } from 'lucide-react' import { + Activity, + BarChart3, Blocks, BookOpen, BookOpenText, Bot, + Check, + Code2, + Database, + FileJson, FileSearch, FileText, FolderOpen, + GitBranch, Globe, Globe2, + Grid2x2, Key, KeyRound, ListFilter, + ListChecks, Loader2, MinusCircle, + Rocket, + Server, Settings2, + Tag, TerminalSquare, + Workflow, X, XCircle, } from 'lucide-react' @@ -24,6 +38,72 @@ import { ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' +function createEntityListMetadata(pluralLabel: string, icon: LucideIcon): BaseClientToolMetadata { + return { + displayNames: { + [ClientToolCallState.generating]: { text: `Listing ${pluralLabel}`, icon: Loader2 }, + [ClientToolCallState.pending]: { text: `Listing ${pluralLabel}`, icon: Loader2 }, + [ClientToolCallState.executing]: { text: `Listing ${pluralLabel}`, icon: Loader2 }, + [ClientToolCallState.success]: { text: `Listed ${pluralLabel}`, icon }, + [ClientToolCallState.error]: { text: `Failed to list ${pluralLabel}`, icon: XCircle }, + [ClientToolCallState.aborted]: { text: `Aborted listing ${pluralLabel}`, icon: XCircle }, + [ClientToolCallState.rejected]: { text: `Skipped listing ${pluralLabel}`, icon: MinusCircle }, + }, + } +} + +function createEntityReadMetadata(label: string): BaseClientToolMetadata { + return { + displayNames: { + [ClientToolCallState.generating]: { text: `Reading ${label} document`, icon: Loader2 }, + [ClientToolCallState.pending]: { text: `Reading ${label} document`, icon: Loader2 }, + [ClientToolCallState.executing]: { text: `Reading ${label} document`, icon: Loader2 }, + [ClientToolCallState.success]: { text: `Read ${label} document`, icon: FileJson }, + [ClientToolCallState.error]: { text: `Failed to read ${label} document`, icon: XCircle }, + [ClientToolCallState.aborted]: { text: `Aborted reading ${label} document`, icon: XCircle }, + [ClientToolCallState.rejected]: { + text: `Skipped reading ${label} document`, + icon: MinusCircle, + }, + }, + } +} + +function createEntityMutationMetadata( + label: string, + action: 'create' | 'edit' | 'rename', + icon: LucideIcon +): BaseClientToolMetadata { + const gerund = action === 'create' ? 'Creating' : action === 'rename' ? 'Renaming' : 'Editing' + const gerundLower = gerund.toLowerCase() + const past = action === 'create' ? 'Created' : action === 'rename' ? 'Renamed' : 'Edited' + + return { + displayNames: { + [ClientToolCallState.generating]: { text: `${gerund} ${label} document`, icon: Loader2 }, + [ClientToolCallState.pending]: { text: `${gerund} ${label} document`, icon: Loader2 }, + [ClientToolCallState.executing]: { text: `${gerund} ${label} document`, icon: Loader2 }, + [ClientToolCallState.success]: { text: `${past} ${label} document`, icon }, + [ClientToolCallState.error]: { + text: `Failed to ${action} ${label} document`, + icon: XCircle, + }, + [ClientToolCallState.aborted]: { + text: `Aborted ${gerundLower} ${label} document`, + icon: XCircle, + }, + [ClientToolCallState.rejected]: { + text: `Skipped ${gerundLower} ${label} document`, + icon: MinusCircle, + }, + }, + interrupt: { + accept: { text: 'Apply changes', icon: Check }, + reject: { text: 'Skip', icon: MinusCircle }, + }, + } +} + export const SERVER_TOOL_METADATA = { [CopilotTool.read_workflow_logs]: { displayNames: { @@ -210,6 +290,252 @@ export const SERVER_TOOL_METADATA = { }, }, }, + list_knowledge_bases: createEntityListMetadata('knowledge bases', Database), + read_knowledge_base: createEntityReadMetadata('knowledge base'), + create_knowledge_base: createEntityMutationMetadata('knowledge base', 'create', Database), + edit_knowledge_base: createEntityMutationMetadata('knowledge base', 'edit', Database), + rename_knowledge_base: createEntityMutationMetadata('knowledge base', 'rename', Database), + query_knowledge_base: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Querying knowledge base', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Querying knowledge base', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Querying knowledge base', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Queried knowledge base', icon: Database }, + [ClientToolCallState.error]: { text: 'Failed to query knowledge base', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted querying knowledge base', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped querying knowledge base', icon: MinusCircle }, + }, + }, + [CopilotTool.list_workflows]: createEntityListMetadata('workflows', ListChecks), + [CopilotTool.read_workflow]: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Analyzing your workflow', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Analyzing your workflow', icon: Workflow }, + [ClientToolCallState.executing]: { text: 'Analyzing your workflow', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Analyzed your workflow', icon: Workflow }, + [ClientToolCallState.error]: { text: 'Failed to analyze your workflow', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted analyzing your workflow', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped analyzing your workflow', icon: MinusCircle }, + }, + }, + [CopilotTool.edit_workflow_variable]: { + displayNames: { + [ClientToolCallState.generating]: { + text: 'Preparing workflow variable changes', + icon: Loader2, + }, + [ClientToolCallState.pending]: { text: 'Set workflow variables?', icon: Settings2 }, + [ClientToolCallState.executing]: { text: 'Editing workflow variables', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Workflow variables updated', icon: Settings2 }, + [ClientToolCallState.error]: { text: 'Failed to edit workflow variables', icon: XCircle }, + [ClientToolCallState.review]: { text: 'Review workflow variable changes', icon: Settings2 }, + [ClientToolCallState.aborted]: { text: 'Aborted editing workflow variables', icon: XCircle }, + [ClientToolCallState.rejected]: { + text: 'Rejected workflow variable changes', + icon: MinusCircle, + }, + }, + interrupt: { + accept: { text: 'Accept changes', icon: Check }, + reject: { text: 'Reject changes', icon: MinusCircle }, + }, + }, + create_workflow: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Creating workflow', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Create workflow?', icon: Grid2x2 }, + [ClientToolCallState.executing]: { text: 'Creating workflow', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Created workflow', icon: Check }, + [ClientToolCallState.error]: { text: 'Failed to create workflow', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted creating workflow', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped creating workflow', icon: MinusCircle }, + }, + interrupt: { + accept: { text: 'Allow', icon: Check }, + reject: { text: 'Skip', icon: MinusCircle }, + }, + }, + edit_workflow: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Editing your workflow', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Editing your workflow', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Editing your workflow', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Edited your workflow', icon: Grid2x2 }, + [ClientToolCallState.error]: { text: 'Failed to edit your workflow', icon: XCircle }, + [ClientToolCallState.review]: { text: 'Review your workflow changes', icon: Grid2x2 }, + [ClientToolCallState.rejected]: { text: 'Rejected workflow changes', icon: MinusCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted editing your workflow', icon: XCircle }, + }, + interrupt: { + accept: { text: 'Accept changes', icon: Check }, + reject: { text: 'Reject changes', icon: MinusCircle }, + }, + }, + edit_workflow_block: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Editing your workflow block', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Editing your workflow block', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Editing your workflow block', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Edited your workflow block', icon: Grid2x2 }, + [ClientToolCallState.error]: { text: 'Failed to edit workflow block', icon: XCircle }, + [ClientToolCallState.review]: { text: 'Review your workflow block changes', icon: Grid2x2 }, + [ClientToolCallState.rejected]: { text: 'Rejected workflow block changes', icon: MinusCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted editing workflow block', icon: XCircle }, + }, + interrupt: { + accept: { text: 'Accept changes', icon: Check }, + reject: { text: 'Reject changes', icon: MinusCircle }, + }, + }, + rename_workflow: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Renaming workflow', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Rename workflow?', icon: Grid2x2 }, + [ClientToolCallState.executing]: { text: 'Renaming workflow', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Renamed workflow', icon: Check }, + [ClientToolCallState.error]: { text: 'Failed to rename workflow', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted renaming workflow', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped renaming workflow', icon: MinusCircle }, + }, + interrupt: { + accept: { text: 'Allow', icon: Check }, + reject: { text: 'Skip', icon: MinusCircle }, + }, + }, + check_deployment_status: { + displayNames: { + [ClientToolCallState.generating]: { + text: 'Checking deployment status', + icon: Loader2, + }, + [ClientToolCallState.pending]: { text: 'Checking deployment status', icon: Loader2 }, + [ClientToolCallState.executing]: { text: 'Checking deployment status', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Checked deployment status', icon: Rocket }, + [ClientToolCallState.error]: { text: 'Failed to check deployment status', icon: XCircle }, + [ClientToolCallState.aborted]: { + text: 'Aborted checking deployment status', + icon: XCircle, + }, + [ClientToolCallState.rejected]: { + text: 'Skipped checking deployment status', + icon: MinusCircle, + }, + }, + }, + list_monitors: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Listing monitors', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Listing monitors', icon: Activity }, + [ClientToolCallState.executing]: { text: 'Listing monitors', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Listed monitors', icon: Activity }, + [ClientToolCallState.error]: { text: 'Failed to list monitors', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted listing monitors', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped listing monitors', icon: MinusCircle }, + }, + }, + [CopilotTool.read_monitor]: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Reading monitor document', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Reading monitor document', icon: FileJson }, + [ClientToolCallState.executing]: { text: 'Reading monitor document', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Read monitor document', icon: FileJson }, + [ClientToolCallState.error]: { text: 'Failed to read monitor document', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted reading monitor document', icon: XCircle }, + [ClientToolCallState.rejected]: { + text: 'Skipped reading monitor document', + icon: MinusCircle, + }, + }, + }, + edit_monitor: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Editing monitor document', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Edit monitor document?', icon: Activity }, + [ClientToolCallState.executing]: { text: 'Editing monitor document', icon: Loader2 }, + [ClientToolCallState.success]: { text: 'Edited monitor document', icon: Check }, + [ClientToolCallState.error]: { text: 'Failed to edit monitor document', icon: XCircle }, + [ClientToolCallState.aborted]: { text: 'Aborted editing monitor document', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped editing monitor document', icon: XCircle }, + }, + interrupt: { + accept: { text: 'Allow', icon: Check }, + reject: { text: 'Skip', icon: XCircle }, + }, + }, + [CopilotTool.read_block_outputs]: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Getting block outputs', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Getting block outputs', icon: Tag }, + [ClientToolCallState.executing]: { text: 'Getting block outputs', icon: Loader2 }, + [ClientToolCallState.aborted]: { text: 'Aborted getting outputs', icon: XCircle }, + [ClientToolCallState.success]: { text: 'Retrieved block outputs', icon: Tag }, + [ClientToolCallState.error]: { text: 'Failed to get outputs', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped getting outputs', icon: MinusCircle }, + }, + getDynamicText: (params, state) => { + const blockIds = params?.blockIds + if (!Array.isArray(blockIds) || blockIds.length === 0) return undefined + const count = blockIds.length + switch (state) { + case ClientToolCallState.success: + return `Retrieved outputs for ${count} block${count > 1 ? 's' : ''}` + case ClientToolCallState.executing: + case ClientToolCallState.generating: + case ClientToolCallState.pending: + return `Getting outputs for ${count} block${count > 1 ? 's' : ''}` + case ClientToolCallState.error: + return `Failed to get outputs for ${count} block${count > 1 ? 's' : ''}` + } + return undefined + }, + }, + [CopilotTool.read_block_upstream_references]: { + displayNames: { + [ClientToolCallState.generating]: { text: 'Getting upstream references', icon: Loader2 }, + [ClientToolCallState.pending]: { text: 'Getting upstream references', icon: GitBranch }, + [ClientToolCallState.executing]: { text: 'Getting upstream references', icon: Loader2 }, + [ClientToolCallState.aborted]: { text: 'Aborted getting references', icon: XCircle }, + [ClientToolCallState.success]: { text: 'Retrieved upstream references', icon: GitBranch }, + [ClientToolCallState.error]: { text: 'Failed to get references', icon: XCircle }, + [ClientToolCallState.rejected]: { text: 'Skipped getting references', icon: MinusCircle }, + }, + getDynamicText: (params, state) => { + const blockIds = params?.blockIds + if (!Array.isArray(blockIds) || blockIds.length === 0) return undefined + const count = blockIds.length + switch (state) { + case ClientToolCallState.success: + return `Retrieved references for ${count} block${count > 1 ? 's' : ''}` + case ClientToolCallState.executing: + case ClientToolCallState.generating: + case ClientToolCallState.pending: + return `Getting references for ${count} block${count > 1 ? 's' : ''}` + case ClientToolCallState.error: + return `Failed to get references for ${count} block${count > 1 ? 's' : ''}` + } + return undefined + }, + }, + list_custom_tools: createEntityListMetadata('custom tools', Code2), + [CopilotTool.read_custom_tool]: createEntityReadMetadata('custom tool'), + create_custom_tool: createEntityMutationMetadata('custom tool', 'create', Code2), + edit_custom_tool: createEntityMutationMetadata('custom tool', 'edit', Code2), + rename_custom_tool: createEntityMutationMetadata('custom tool', 'rename', Code2), + [CopilotTool.list_indicators]: createEntityListMetadata('indicators', BarChart3), + [CopilotTool.read_indicator]: createEntityReadMetadata('indicator'), + create_indicator: createEntityMutationMetadata('indicator', 'create', BarChart3), + edit_indicator: createEntityMutationMetadata('indicator', 'edit', BarChart3), + rename_indicator: createEntityMutationMetadata('indicator', 'rename', BarChart3), + list_skills: createEntityListMetadata('skills', BookOpen), + [CopilotTool.read_skill]: createEntityReadMetadata('skill'), + create_skill: createEntityMutationMetadata('skill', 'create', BookOpen), + edit_skill: createEntityMutationMetadata('skill', 'edit', BookOpen), + rename_skill: createEntityMutationMetadata('skill', 'rename', BookOpen), + list_mcp_servers: createEntityListMetadata('MCP servers', Server), + [CopilotTool.read_mcp_server]: createEntityReadMetadata('MCP server'), + create_mcp_server: createEntityMutationMetadata('MCP server', 'create', Server), + edit_mcp_server: createEntityMutationMetadata('MCP server', 'edit', Server), + rename_mcp_server: createEntityMutationMetadata('MCP server', 'rename', Server), list_gdrive_files: { displayNames: { [ClientToolCallState.generating]: { text: 'Listing GDrive files', icon: Loader2 }, diff --git a/apps/tradinggoose/lib/copilot/tools/client/server-tool-response.ts b/apps/tradinggoose/lib/copilot/tools/client/server-tool-response.ts index 7f9610d3c..b287c090e 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/server-tool-response.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/server-tool-response.ts @@ -76,6 +76,8 @@ export async function executeCopilotServerTool(input: { } signal?: AbortSignal }): Promise { + const context = + input.context && Object.keys(input.context).length > 0 ? input.context : undefined const response = await fetch('/api/copilot/execute-copilot-server-tool', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -83,7 +85,50 @@ export async function executeCopilotServerTool(input: { body: JSON.stringify({ toolName: input.toolName, payload: input.payload ?? {}, - ...(input.context ? { context: input.context } : {}), + ...(context ? { context } : {}), + }), + }) + + if (!response.ok) { + throw await buildCopilotServerToolError(response) + } + + const json = await response.json() + const parsed = ExecuteResponseSuccessSchema.parse(json) + return parsed.result as TResult +} + +export function isCopilotServerToolReviewResult(result: unknown): result is { + requiresReview: true +} { + return ( + !!result && + typeof result === 'object' && + (result as { requiresReview?: unknown }).requiresReview === true + ) +} + +export async function acceptCopilotServerToolReview(input: { + toolName: string + reviewResult: unknown + context?: { + contextEntityKind?: ReviewEntityKind + contextEntityId?: string + workspaceId?: string + } + signal?: AbortSignal +}): Promise { + const context = + input.context && Object.keys(input.context).length > 0 ? input.context : undefined + const response = await fetch('/api/copilot/execute-copilot-server-tool', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: input.signal, + body: JSON.stringify({ + toolName: input.toolName, + reviewAction: 'accept', + reviewResult: input.reviewResult, + ...(context ? { context } : {}), }), }) diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/block-output-tools.test.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/block-output-tools.test.ts deleted file mode 100644 index da3e6aaba..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/block-output-tools.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ToolResultSchemas } from '@/lib/copilot/registry' -import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' -import { ReadBlockOutputsClientTool } from '@/lib/copilot/tools/client/workflow/read-block-outputs' -import { ReadBlockUpstreamReferencesClientTool } from '@/lib/copilot/tools/client/workflow/read-block-upstream-references' - -const mockGetReadableWorkflowState = vi.fn() -const originalFetch = globalThis.fetch -const mockCopilotState = { - toolCallsById: {} as Record; state?: string }>, -} - -vi.mock('@/stores/copilot/store-access', () => ({ - getCopilotStoreForToolCall: () => ({ - getState: () => mockCopilotState, - }), -})) - -vi.mock('@/lib/copilot/tools/client/workflow/workflow-review-tool-utils', () => ({ - getReadableWorkflowState: (...args: unknown[]) => mockGetReadableWorkflowState(...args), -})) - -vi.mock('@/blocks', () => ({ - getBlock: (blockType: string) => { - const registry: Record = { - agent: { - outputs: { - content: { type: 'string', description: 'Agent content' }, - meta: { - sentiment: { type: 'string', description: 'Sentiment label' }, - }, - }, - }, - function: { - outputs: { - result: { type: 'json', description: 'Return value' }, - stdout: { type: 'string', description: 'Console output' }, - }, - }, - loop: { - outputs: {}, - }, - } - - return registry[blockType] - }, -})) - -describe('workflow output tools', () => { - beforeEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals?.() - globalThis.fetch = originalFetch - mockGetReadableWorkflowState.mockReset() - mockCopilotState.toolCallsById = {} - }) - - it('read_block_outputs returns structured output entries with paths and types', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const method = init?.method || 'GET' - - if (url === '/api/copilot/tools/mark-complete' && method === 'POST') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url} (${method})`) - }) - vi.stubGlobal('fetch', fetchMock) - - mockGetReadableWorkflowState.mockResolvedValue({ - workflowId: 'wf-1', - workflowState: { - blocks: { - 'agent-1': { id: 'agent-1', type: 'agent', name: 'agent', subBlocks: {} }, - 'loop-1': { id: 'loop-1', type: 'loop', name: 'loop', subBlocks: {} }, - }, - edges: [], - loops: { - 'loop-1': { id: 'loop-1', nodes: [], loopType: 'forEach' }, - }, - parallels: {}, - }, - workspaceId: 'ws-1', - variables: { - 'var-1': { id: 'var-1', name: 'riskLimit', type: 'number' }, - }, - }) - - const toolCallId = 'read-block-outputs' - const tool = new ReadBlockOutputsClientTool(toolCallId) - tool.setExecutionContext({ - toolCallId, - toolName: 'read_block_outputs', - contextEntityKind: 'workflow', - contextEntityId: 'wf-1', - log: vi.fn(), - }) - - await tool.execute({ entityId: 'wf-1', blockIds: ['agent-1', 'loop-1'] }) - - expect(tool.getState()).toBe(ClientToolCallState.success) - - const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => { - const url = typeof input === 'string' ? input : input.toString() - return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST' - }) - const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body)) - - expect(markCompleteBody.data.blocks).toEqual([ - { - blockId: 'agent-1', - blockName: 'agent', - blockType: 'agent', - outputs: [ - { path: 'agent.content', type: 'string' }, - { path: 'agent.meta.sentiment', type: 'string' }, - ], - }, - { - blockId: 'loop-1', - blockName: 'loop', - blockType: 'loop', - outputs: [], - insideSubflowOutputs: [ - { path: 'loop.index', type: 'number' }, - { path: 'loop.currentItem', type: 'any' }, - { path: 'loop.items', type: 'json' }, - ], - outsideSubflowOutputs: [{ path: 'loop.results', type: 'json' }], - }, - ]) - expect( - ToolResultSchemas.read_block_outputs.parse({ - blocks: markCompleteBody.data.blocks, - }) - ).toBeDefined() - }) - - it('read_block_upstream_references returns structured accessible output entries with paths and types', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const method = init?.method || 'GET' - - if (url === '/api/copilot/tools/mark-complete' && method === 'POST') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url} (${method})`) - }) - vi.stubGlobal('fetch', fetchMock) - - mockGetReadableWorkflowState.mockResolvedValue({ - workflowId: 'wf-1', - workflowState: { - blocks: { - 'agent-1': { id: 'agent-1', type: 'agent', name: 'agent', subBlocks: {} }, - 'fn-1': { id: 'fn-1', type: 'function', name: 'function', subBlocks: {} }, - }, - edges: [{ source: 'agent-1', target: 'fn-1' }], - loops: {}, - parallels: {}, - }, - workspaceId: 'ws-1', - variables: { - 'var-1': { id: 'var-1', name: 'riskLimit', type: 'number' }, - }, - }) - - const toolCallId = 'read-block-upstream-references' - const tool = new ReadBlockUpstreamReferencesClientTool(toolCallId) - tool.setExecutionContext({ - toolCallId, - toolName: 'read_block_upstream_references', - contextEntityKind: 'workflow', - contextEntityId: 'wf-1', - log: vi.fn(), - }) - - await tool.execute({ entityId: 'wf-1', blockIds: ['fn-1'] }) - - expect(tool.getState()).toBe(ClientToolCallState.success) - - const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => { - const url = typeof input === 'string' ? input : input.toString() - return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST' - }) - const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body)) - - expect(markCompleteBody.data.results).toEqual([ - { - blockId: 'fn-1', - blockName: 'function', - accessibleBlocks: [ - { - blockId: 'agent-1', - blockName: 'agent', - blockType: 'agent', - outputs: [ - { path: 'agent.content', type: 'string' }, - { path: 'agent.meta.sentiment', type: 'string' }, - ], - }, - ], - variables: [ - { - id: 'var-1', - name: 'riskLimit', - type: 'number', - tag: 'variable.risklimit', - }, - ], - }, - ]) - expect( - ToolResultSchemas.read_block_upstream_references.parse(markCompleteBody.data) - ).toBeDefined() - }) -}) diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/check-deployment-status.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/check-deployment-status.ts deleted file mode 100644 index 30f2eb660..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/check-deployment-status.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Loader2, Rocket, X, XCircle } from 'lucide-react' -import { - BaseClientTool, - type BaseClientToolMetadata, - ClientToolCallState, -} from '@/lib/copilot/tools/client/base-tool' -import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target' -import { createLogger } from '@/lib/logs/console/logger' - -interface CheckDeploymentStatusArgs { - entityId: string -} - -export class CheckDeploymentStatusClientTool extends BaseClientTool { - static readonly id = 'check_deployment_status' - - constructor(toolCallId: string) { - super(toolCallId, CheckDeploymentStatusClientTool.id, CheckDeploymentStatusClientTool.metadata) - } - - static readonly metadata: BaseClientToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { - text: 'Checking deployment status', - icon: Loader2, - }, - [ClientToolCallState.pending]: { text: 'Checking deployment status', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Checking deployment status', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Checked deployment status', icon: Rocket }, - [ClientToolCallState.error]: { text: 'Failed to check deployment status', icon: X }, - [ClientToolCallState.aborted]: { - text: 'Aborted checking deployment status', - icon: XCircle, - }, - [ClientToolCallState.rejected]: { - text: 'Skipped checking deployment status', - icon: XCircle, - }, - }, - interrupt: undefined, - } - - async execute(args?: CheckDeploymentStatusArgs): Promise { - const logger = createLogger('CheckDeploymentStatusClientTool') - try { - this.setState(ClientToolCallState.executing) - const workflowId = requireCopilotEntityId(args) - - // Fetch deployment status from API - const [apiDeployRes, chatDeployRes] = await Promise.all([ - fetch(`/api/workflows/${workflowId}/deploy`), - fetch(`/api/workflows/${workflowId}/chat/status`), - ]) - - const apiDeploy = apiDeployRes.ok ? await apiDeployRes.json() : null - const chatDeploy = chatDeployRes.ok ? await chatDeployRes.json() : null - - const isApiDeployed = apiDeploy?.isDeployed || false - const isChatDeployed = !!(chatDeploy?.isDeployed && chatDeploy?.deployment) - - const deploymentTypes: string[] = [] - - if (isApiDeployed) { - // Default to sync API, could be extended to detect streaming/async - deploymentTypes.push('api') - } - - if (isChatDeployed) { - deploymentTypes.push('chat') - } - - const isDeployed = isApiDeployed || isChatDeployed - - this.setState(ClientToolCallState.success) - await this.markToolComplete( - 200, - isDeployed - ? `Workflow is deployed as: ${deploymentTypes.join(', ')}` - : 'Workflow is not deployed', - { - isDeployed, - deploymentTypes, - apiDeployed: isApiDeployed, - chatDeployed: isChatDeployed, - deployedAt: apiDeploy?.deployedAt || null, - } - ) - } catch (e: any) { - logger.error('Check deployment status failed', { message: e?.message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, e?.message || 'Failed to check deployment status') - } - } -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/create-workflow.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/create-workflow.ts deleted file mode 100644 index c6a4e1eab..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/create-workflow.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Check, Grid2x2, Loader2, X, XCircle } from 'lucide-react' -import { - BaseClientTool, - type BaseClientToolMetadata, - ClientToolCallState, -} from '@/lib/copilot/tools/client/base-tool' -import { createLogger } from '@/lib/logs/console/logger' -import { getCopilotStoreForToolCall } from '@/stores/copilot/store-access' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -type CreateWorkflowArgs = { - name?: string - description?: string - folderId?: string | null - workspaceId?: string -} - -function readStoredToolArgs(toolCallId: string): TArgs | undefined { - try { - const { toolCallsById } = getCopilotStoreForToolCall(toolCallId).getState() - return toolCallsById[toolCallId]?.params as TArgs | undefined - } catch { - return undefined - } -} - -export class CreateWorkflowClientTool extends BaseClientTool { - static readonly id = 'create_workflow' - private currentArgs?: CreateWorkflowArgs - - static readonly metadata: BaseClientToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Creating workflow', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Create workflow?', icon: Grid2x2 }, - [ClientToolCallState.executing]: { text: 'Creating workflow', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Created workflow', icon: Check }, - [ClientToolCallState.error]: { text: 'Failed to create workflow', icon: X }, - [ClientToolCallState.aborted]: { text: 'Aborted creating workflow', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped creating workflow', icon: XCircle }, - }, - interrupt: { - accept: { text: 'Allow', icon: Check }, - reject: { text: 'Skip', icon: XCircle }, - }, - } - - constructor(toolCallId: string) { - super(toolCallId, CreateWorkflowClientTool.id, CreateWorkflowClientTool.metadata) - } - - async execute(args?: CreateWorkflowArgs): Promise { - this.currentArgs = args - } - - async handleAccept(args?: CreateWorkflowArgs): Promise { - const logger = createLogger('CreateWorkflowClientTool') - - try { - this.setState(ClientToolCallState.executing) - - const executionContext = this.requireExecutionContext() - const resolvedArgs = - args || this.currentArgs || readStoredToolArgs(this.toolCallId) - const workspaceId = - resolvedArgs?.workspaceId?.trim() || executionContext.workspaceId?.trim() || undefined - - if (!workspaceId) { - throw new Error('workspaceId is required to create a workflow') - } - - const workflowId = await useWorkflowRegistry.getState().createWorkflow({ - workspaceId, - ...(resolvedArgs?.name?.trim() ? { name: resolvedArgs.name.trim() } : {}), - ...(typeof resolvedArgs?.description === 'string' - ? { description: resolvedArgs.description } - : {}), - ...(resolvedArgs?.folderId !== undefined ? { folderId: resolvedArgs.folderId } : {}), - }) - - const workflow = useWorkflowRegistry.getState().workflows[workflowId] - const entityName = workflow?.name?.trim() - - await this.markToolComplete(200, 'Workflow created', { - success: true, - entityKind: 'workflow', - entityId: workflowId, - ...(entityName ? { entityName } : {}), - workspaceId: workflow?.workspaceId ?? workspaceId, - }) - this.setState(ClientToolCallState.success) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - logger.error('Failed to create workflow', { toolCallId: this.toolCallId, message }) - await this.markToolComplete(500, message) - this.setState(ClientToolCallState.error) - } - } -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.test.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.test.ts deleted file mode 100644 index 1724c4d56..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' -import { EditWorkflowBlockClientTool } from '@/lib/copilot/tools/client/workflow/edit-workflow-block' - -const mockGetReadableWorkflowState = vi.fn() -const mockResolveWorkflowTarget = vi.fn() -const mockSetWorkflowState = vi.fn() -const mockAcquireWritableWorkflowSessionLease = vi.fn() - -vi.mock('@/lib/copilot/tools/client/workflow/workflow-review-tool-utils', () => ({ - getReadableWorkflowState: (...args: any[]) => mockGetReadableWorkflowState(...args), - resolveWorkflowTarget: (...args: any[]) => mockResolveWorkflowTarget(...args), - buildWorkflowDocumentToolResult: ({ - workflowId, - entityName, - entityDocument, - }: { - workflowId: string - entityName?: string - entityDocument: string - }) => ({ - entityKind: 'workflow', - entityId: workflowId, - ...(entityName ? { entityName } : {}), - entityDocument, - documentFormat: 'tg-mermaid-v1', - }), -})) - -vi.mock('@/lib/yjs/workflow-shared-session', () => ({ - acquireWritableWorkflowSessionLease: (...args: any[]) => - mockAcquireWritableWorkflowSessionLease(...args), -})) - -vi.mock('@/lib/yjs/workflow-session', () => ({ - setWorkflowState: (...args: any[]) => mockSetWorkflowState(...args), -})) - -vi.mock('@/stores/copilot/store-access', () => ({ - getCopilotStoreForToolCall: () => ({ - getState: () => ({ - toolCallsById: {}, - }), - }), -})) - -describe('EditWorkflowBlockClientTool', () => { - beforeEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals?.() - mockGetReadableWorkflowState.mockReset() - mockResolveWorkflowTarget.mockReset() - mockSetWorkflowState.mockReset() - mockAcquireWritableWorkflowSessionLease.mockReset() - - mockResolveWorkflowTarget.mockResolvedValue({ - workflowId: 'wf-1', - workspaceId: 'workspace-1', - }) - - mockGetReadableWorkflowState.mockResolvedValue({ - workflowId: 'wf-1', - entityName: 'Workflow 1', - workspaceId: 'workspace-1', - workflowState: { - direction: 'TD', - blocks: { - fn1: { - id: 'fn1', - type: 'function', - name: 'Compute Indicators', - position: { x: 0, y: 0 }, - subBlocks: { - code: { id: 'code', type: 'code', value: 'return { ok: true }' }, - }, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - }, - }) - - mockAcquireWritableWorkflowSessionLease.mockImplementation(async ({ workflowId }) => ({ - session: { - workflowId, - doc: { id: 'doc-1' }, - }, - release: vi.fn(), - })) - }) - - it('stages block edits for review through the shared workflow review flow', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL) => { - const url = typeof input === 'string' ? input : input.toString() - - if (url === '/api/copilot/execute-copilot-server-tool') { - return { - ok: true, - status: 200, - json: async () => ({ - success: true, - result: { - entityDocument: - 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', - workflowState: { - direction: 'TD', - blocks: { - fn1: { - id: 'fn1', - type: 'function', - name: 'Compute Market Indicators', - position: { x: 0, y: 0 }, - subBlocks: { - code: { id: 'code', type: 'code', value: 'return { rsi: 50 }' }, - }, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - }, - }, - }), - } - } - - if (url === '/api/copilot/tools/mark-complete') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url}`) - }) - vi.stubGlobal('fetch', fetchMock) - - const tool = new EditWorkflowBlockClientTool('tool-review') - tool.setExecutionContext({ - toolCallId: 'tool-review', - toolName: 'edit_workflow_block', - contextEntityKind: 'workflow', - contextEntityId: 'wf-1', - log: vi.fn(), - }) - - await tool.handleUserAction({ - entityId: 'wf-1', - blockId: 'fn1', - blockType: 'function', - subBlocks: { - code: 'return { rsi: 50 }', - }, - }) - - expect(tool.getState()).toBe(ClientToolCallState.review) - expect(tool.getInterruptDisplays()).toBeDefined() - expect(mockSetWorkflowState).not.toHaveBeenCalled() - }) -}) diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.ts deleted file mode 100644 index 17494d7fc..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow-block.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Grid2x2, Grid2x2Check, Grid2x2X, Loader2, MinusCircle, XCircle } from 'lucide-react' -import type { BaseClientToolMetadata } from '@/lib/copilot/tools/client/base-tool' -import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' -import { EditWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/edit-workflow' - -export class EditWorkflowBlockClientTool extends EditWorkflowClientTool { - static readonly id: string = 'edit_workflow_block' - - static readonly metadata: BaseClientToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Editing your workflow block', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Editing your workflow block', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Edited your workflow block', icon: Grid2x2Check }, - [ClientToolCallState.error]: { text: 'Failed to edit your workflow block', icon: XCircle }, - [ClientToolCallState.review]: { text: 'Review your workflow block changes', icon: Grid2x2 }, - [ClientToolCallState.rejected]: { text: 'Rejected workflow block changes', icon: Grid2x2X }, - [ClientToolCallState.aborted]: { - text: 'Aborted editing your workflow block', - icon: MinusCircle, - }, - [ClientToolCallState.pending]: { text: 'Editing your workflow block', icon: Loader2 }, - }, - interrupt: { - accept: { text: 'Accept changes', icon: Grid2x2Check }, - reject: { text: 'Reject changes', icon: Grid2x2X }, - }, - } - - constructor( - toolCallId: string, - toolName = EditWorkflowBlockClientTool.id, - metadata: BaseClientToolMetadata = EditWorkflowBlockClientTool.metadata - ) { - super(toolCallId, toolName, metadata) - } - - protected getServerToolName(): string { - return EditWorkflowBlockClientTool.id - } - - protected buildServerPayload( - workflowId: string, - args: Record | undefined, - currentWorkflowState: string - ): Record { - const blockId = args?.blockId?.trim() - if (!blockId) { - throw new Error(`blockId is required for ${this.getServerToolName()}`) - } - - return { - entityId: workflowId, - blockId, - ...(args?.blockType?.trim() ? { blockType: args.blockType.trim() } : {}), - ...(args?.name?.trim() ? { name: args.name.trim() } : {}), - ...(typeof args?.enabled === 'boolean' ? { enabled: args.enabled } : {}), - ...(args?.subBlocks ? { subBlocks: args.subBlocks } : {}), - currentWorkflowState, - } - } -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.test.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.test.ts deleted file mode 100644 index 246555447..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.test.ts +++ /dev/null @@ -1,497 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - ClientToolCallState, - REJECTED_TOOL_COMPLETION_STATUS, -} from '@/lib/copilot/tools/client/base-tool' -import { EditWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/edit-workflow' -import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' - -const mockGetReadableWorkflowState = vi.fn() -const mockResolveWorkflowTarget = vi.fn() -const mockSetWorkflowState = vi.fn() -const mockAcquireWritableWorkflowSessionLease = vi.fn() - -const workflowDocument = [ - 'flowchart TD', - '%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', - '%% TG_BLOCK {"id":"block-1","type":"trigger","name":"Trigger","position":{"x":0,"y":0},"subBlocks":{},"outputs":{},"enabled":true}', -].join('\n') -const editWorkflowDocument = [ - 'flowchart TD', - ' n1["Trigger
id: block-1
type: trigger"]', -].join('\n') -const workflowGraphDocumentFormat = 'tg-workflow-graph-mermaid-v1' - -let persistedToolCalls: Record = {} - -vi.mock('@/lib/copilot/tools/client/workflow/workflow-review-tool-utils', () => ({ - getReadableWorkflowState: (...args: any[]) => mockGetReadableWorkflowState(...args), - resolveWorkflowTarget: (...args: any[]) => mockResolveWorkflowTarget(...args), - buildWorkflowDocumentToolResult: ({ - workflowId, - entityName, - entityDocument, - documentFormat, - }: { - workflowId: string - entityName?: string - entityDocument: string - documentFormat?: string - }) => ({ - entityKind: 'workflow', - entityId: workflowId, - ...(entityName ? { entityName } : {}), - entityDocument, - documentFormat: documentFormat ?? 'tg-mermaid-v1', - }), -})) - -vi.mock('@/lib/yjs/workflow-shared-session', () => ({ - acquireWritableWorkflowSessionLease: (...args: any[]) => - mockAcquireWritableWorkflowSessionLease(...args), -})) - -vi.mock('@/lib/yjs/workflow-session', () => ({ - setWorkflowState: (...args: any[]) => mockSetWorkflowState(...args), -})) - -vi.mock('@/stores/copilot/store-access', () => ({ - getCopilotStoreForToolCall: () => ({ - getState: () => ({ - toolCallsById: persistedToolCalls, - }), - }), -})) - -describe('EditWorkflowClientTool approval gating', () => { - beforeEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals?.() - persistedToolCalls = {} - mockGetReadableWorkflowState.mockReset() - mockResolveWorkflowTarget.mockReset() - mockSetWorkflowState.mockReset() - mockAcquireWritableWorkflowSessionLease.mockReset() - - mockResolveWorkflowTarget.mockResolvedValue({ - workflowId: 'wf-1', - workspaceId: 'workspace-1', - folderId: null, - }) - - mockGetReadableWorkflowState.mockResolvedValue({ - workflowId: 'wf-1', - entityName: 'Workflow 1', - workspaceId: 'workspace-1', - workflowState: { - blocks: { - 'block-1': { - id: 'block-1', - type: 'trigger', - name: 'Trigger', - position: { x: 0, y: 0 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - }, - }) - - mockAcquireWritableWorkflowSessionLease.mockImplementation(async ({ workflowId }) => ({ - session: { - workflowId, - doc: { id: workflowId === 'wf-target' ? 'doc-target' : 'doc-1' }, - }, - release: vi.fn(), - })) - }) - - it('stages workflow edits for review through the unified user-action handler', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - - if (url === '/api/copilot/execute-copilot-server-tool') { - const body = JSON.parse(String(init?.body)) - expect(body.payload).toMatchObject({ - entityId: 'wf-1', - entityDocument: editWorkflowDocument, - removedBlockIds: ['removed-1'], - }) - expect(body.payload).not.toHaveProperty('documentFormat') - return { - ok: true, - status: 200, - json: async () => ({ - success: true, - result: { - workflowState: { - blocks: { - 'block-1': { - id: 'block-1', - type: 'trigger', - name: 'Renamed Trigger', - position: { x: 0, y: 0 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - }, - entityDocument: editWorkflowDocument, - documentFormat: workflowGraphDocumentFormat, - }, - }), - } - } - - if (url === '/api/copilot/tools/mark-complete') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url}`) - }) - vi.stubGlobal('fetch', fetchMock) - - const tool = new EditWorkflowClientTool('tool-review') - tool.setExecutionContext({ - toolCallId: 'tool-review', - toolName: 'edit_workflow', - channelId: 'pair-1', - contextEntityKind: 'workflow', - contextEntityId: 'wf-1', - log: vi.fn(), - }) - - await tool.handleUserAction({ - entityId: 'wf-1', - entityDocument: editWorkflowDocument, - removedBlockIds: ['removed-1'], - }) - - expect(tool.getState()).toBe(ClientToolCallState.review) - expect(mockSetWorkflowState).not.toHaveBeenCalled() - expect(fetchMock).toHaveBeenCalledTimes(1) - - await tool.handleReject() - - expect(tool.getState()).toBe(ClientToolCallState.rejected) - expect(mockSetWorkflowState).not.toHaveBeenCalled() - expect(fetchMock).toHaveBeenCalledTimes(2) - const rejectRequest = fetchMock.mock.calls.find(([input]) => { - const url = typeof input === 'string' ? input : input.toString() - return url === '/api/copilot/tools/mark-complete' - }) - const rejectBody = JSON.parse(String(rejectRequest?.[1]?.body)) - expect(rejectBody.status).toBe(REJECTED_TOOL_COMPLETION_STATUS) - expect(rejectBody.data).toEqual({ rejected: true }) - }) - - it('stages workflow edits from a readable workflow snapshot when no live session is registered yet', async () => { - mockGetReadableWorkflowState.mockResolvedValueOnce({ - workflowId: 'wf-1', - workflowState: { - blocks: { - 'block-1': { - id: 'block-1', - type: 'trigger', - name: 'Persisted Trigger', - position: { x: 0, y: 0 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - }, - }) - - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - - if (url === '/api/copilot/execute-copilot-server-tool') { - expect(init?.body).toContain('"currentWorkflowState"') - expect(init?.body).toContain('Persisted Trigger') - return { - ok: true, - status: 200, - json: async () => ({ - success: true, - result: { - workflowState: { - blocks: { - 'block-1': { - id: 'block-1', - type: 'trigger', - name: 'Renamed Trigger', - position: { x: 0, y: 0 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - }, - entityDocument: editWorkflowDocument, - documentFormat: workflowGraphDocumentFormat, - }, - }), - } - } - - throw new Error(`Unexpected fetch URL: ${url}`) - }) - vi.stubGlobal('fetch', fetchMock) - - const tool = new EditWorkflowClientTool('tool-readable-state') - tool.setExecutionContext({ - toolCallId: 'tool-readable-state', - toolName: 'edit_workflow', - channelId: 'pair-1', - contextEntityKind: 'workflow', - contextEntityId: 'wf-1', - log: vi.fn(), - }) - - await tool.handleUserAction({ - entityId: 'wf-1', - entityDocument: editWorkflowDocument, - }) - - expect(tool.getState()).toBe(ClientToolCallState.review) - expect(mockSetWorkflowState).not.toHaveBeenCalled() - expect(fetchMock).toHaveBeenCalledTimes(1) - }) - - it('applies staged workflow edits through Yjs on accept', async () => { - const nextWorkflowState = { - blocks: { - 'block-1': { - id: 'block-1', - type: 'trigger', - name: 'Accepted Trigger', - position: { x: 0, y: 0 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - } - - const fetchMock = vi.fn(async (input: RequestInfo | URL) => { - const url = typeof input === 'string' ? input : input.toString() - - if (url === '/api/copilot/execute-copilot-server-tool') { - return { - ok: true, - status: 200, - json: async () => ({ - success: true, - result: { - workflowState: nextWorkflowState, - entityDocument: editWorkflowDocument, - documentFormat: workflowGraphDocumentFormat, - }, - }), - } - } - - if (url === '/api/copilot/tools/mark-complete') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url}`) - }) - vi.stubGlobal('fetch', fetchMock) - - const tool = new EditWorkflowClientTool('tool-accept') - tool.setExecutionContext({ - toolCallId: 'tool-accept', - toolName: 'edit_workflow', - channelId: 'pair-1', - contextEntityKind: 'workflow', - contextEntityId: 'wf-1', - log: vi.fn(), - }) - - await tool.execute({ - entityId: 'wf-1', - entityDocument: editWorkflowDocument, - }) - await tool.handleAccept() - - expect(tool.getState()).toBe(ClientToolCallState.success) - expect(mockSetWorkflowState).toHaveBeenCalledWith( - { id: 'doc-1' }, - nextWorkflowState, - YJS_ORIGINS.COPILOT_REVIEW_ACCEPT - ) - expect(fetchMock).toHaveBeenCalledTimes(2) - - const markCompleteRequest = fetchMock.mock.calls.find(([input]) => { - const url = typeof input === 'string' ? input : input.toString() - return url === '/api/copilot/tools/mark-complete' - }) - const markCompleteInit = markCompleteRequest - ? ((markCompleteRequest as unknown as Array)[1] as RequestInit | undefined) - : undefined - const markCompleteBody = JSON.parse(String(markCompleteInit?.body)) - expect(markCompleteBody.data).toMatchObject({ - entityKind: 'workflow', - entityId: 'wf-1', - entityDocument: editWorkflowDocument, - documentFormat: workflowGraphDocumentFormat, - }) - }) - - it('rejects edit execution without explicit entityId even when current workflow context exists', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - - if (url === '/api/copilot/tools/mark-complete') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url}`) - }) - vi.stubGlobal('fetch', fetchMock) - - const tool = new EditWorkflowClientTool('tool-missing-workflow-id') - tool.setExecutionContext({ - toolCallId: 'tool-missing-workflow-id', - toolName: 'edit_workflow', - channelId: 'pair-1', - contextEntityKind: 'workflow', - contextEntityId: 'wf-current', - log: vi.fn(), - }) - - await tool.execute({ - entityDocument: editWorkflowDocument, - }) - - expect(tool.getState()).toBe(ClientToolCallState.error) - expect(mockResolveWorkflowTarget).not.toHaveBeenCalled() - expect(mockSetWorkflowState).not.toHaveBeenCalled() - - const markCompleteRequest = fetchMock.mock.calls.find(([input]) => { - const url = typeof input === 'string' ? input : input.toString() - return url === '/api/copilot/tools/mark-complete' - }) - const markCompleteBody = JSON.parse(String(markCompleteRequest?.[1]?.body)) - expect(markCompleteBody.status).toBe(500) - expect(markCompleteBody.message).toContain('entityId is required') - }) - - it('accepts persisted staged workflow edits after reload using the persisted workflow target', async () => { - const stagedWorkflowState = { - blocks: { - 'block-1': { - id: 'block-1', - type: 'trigger', - name: 'Persisted Trigger', - position: { x: 0, y: 0 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - } - - persistedToolCalls = { - 'tool-persisted-review': { - id: 'tool-persisted-review', - name: 'edit_workflow', - state: ClientToolCallState.review, - params: { - entityId: 'wf-target', - entityDocument: editWorkflowDocument, - }, - result: { - entityId: 'wf-target', - workflowState: stagedWorkflowState, - }, - }, - } - - mockResolveWorkflowTarget.mockImplementation(async (_executionContext, options) => ({ - workflowId: options?.entityId ?? 'wf-current', - })) - mockAcquireWritableWorkflowSessionLease.mockImplementation(async ({ workflowId }) => ({ - session: { - workflowId, - doc: { id: workflowId === 'wf-target' ? 'doc-target' : 'doc-current' }, - }, - release: vi.fn(), - })) - - const fetchMock = vi.fn(async (input: RequestInfo | URL) => { - const url = typeof input === 'string' ? input : input.toString() - - if (url === '/api/copilot/tools/mark-complete') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url}`) - }) - vi.stubGlobal('fetch', fetchMock) - - const tool = new EditWorkflowClientTool('tool-persisted-review') - tool.setExecutionContext({ - toolCallId: 'tool-persisted-review', - toolName: 'edit_workflow', - channelId: 'pair-1', - contextEntityKind: 'workflow', - contextEntityId: 'wf-current', - log: vi.fn(), - }) - tool.hydratePersistedToolCall(persistedToolCalls['tool-persisted-review']) - - await tool.handleUserAction() - - expect(tool.getState()).toBe(ClientToolCallState.success) - expect(mockSetWorkflowState).toHaveBeenCalledWith( - { id: 'doc-target' }, - stagedWorkflowState, - YJS_ORIGINS.COPILOT_REVIEW_ACCEPT - ) - expect(mockSetWorkflowState).not.toHaveBeenCalledWith( - { id: 'doc-current' }, - stagedWorkflowState, - YJS_ORIGINS.COPILOT_REVIEW_ACCEPT - ) - expect(fetchMock).toHaveBeenCalledTimes(1) - }) -}) diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.ts deleted file mode 100644 index de6b9c0b3..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { Grid2x2, Grid2x2Check, Grid2x2X, Loader2, MinusCircle, XCircle } from 'lucide-react' -import { - type BaseClientToolMetadata, - ClientToolCallState, - StagedReviewClientTool, -} from '@/lib/copilot/tools/client/base-tool' -import { - executeCopilotServerTool, - getCopilotServerToolErrorStatus, -} from '@/lib/copilot/tools/client/server-tool-response' -import { - buildWorkflowDocumentToolResult, - getReadableWorkflowState, - resolveWorkflowTarget, -} from '@/lib/copilot/tools/client/workflow/workflow-review-tool-utils' -import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target' -import { createLogger } from '@/lib/logs/console/logger' -import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' -import { setWorkflowState } from '@/lib/yjs/workflow-session' -import { acquireWritableWorkflowSessionLease } from '@/lib/yjs/workflow-shared-session' -import { getCopilotStoreForToolCall } from '@/stores/copilot/store-access' - -interface EditWorkflowArgs { - entityDocument: string - removedBlockIds?: string[] - entityId?: string -} - -function readStoredToolArgs(toolCallId: string): TArgs | undefined { - try { - const { toolCallsById } = getCopilotStoreForToolCall(toolCallId).getState() - return toolCallsById[toolCallId]?.params as TArgs | undefined - } catch { - return undefined - } -} - -export class EditWorkflowClientTool extends StagedReviewClientTool> { - static readonly id: string = 'edit_workflow' - private hasExecuted = false - private hasAppliedState = false - - constructor( - toolCallId: string, - toolName = EditWorkflowClientTool.id, - metadata: BaseClientToolMetadata = EditWorkflowClientTool.metadata - ) { - super(toolCallId, toolName, metadata) - } - - static readonly metadata: BaseClientToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Editing your workflow', icon: Loader2 }, - [ClientToolCallState.executing]: { text: 'Editing your workflow', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Edited your workflow', icon: Grid2x2Check }, - [ClientToolCallState.error]: { text: 'Failed to edit your workflow', icon: XCircle }, - [ClientToolCallState.review]: { text: 'Review your workflow changes', icon: Grid2x2 }, - [ClientToolCallState.rejected]: { text: 'Rejected workflow changes', icon: Grid2x2X }, - [ClientToolCallState.aborted]: { text: 'Aborted editing your workflow', icon: MinusCircle }, - [ClientToolCallState.pending]: { text: 'Editing your workflow', icon: Loader2 }, - }, - interrupt: { - accept: { text: 'Accept changes', icon: Grid2x2Check }, - reject: { text: 'Reject changes', icon: Grid2x2X }, - }, - } - - async handleAccept(args?: EditWorkflowArgs): Promise { - const logger = createLogger('EditWorkflowClientTool') - try { - const stagedResult = this.getStagedReviewResult() - logger.info('handleAccept called', { - toolCallId: this.toolCallId, - state: this.getState(), - hasResult: stagedResult !== undefined, - }) - - if (!stagedResult?.workflowState) { - throw new Error('No staged workflow edits found to accept') - } - - const executionContext = this.requireExecutionContext() - const resolvedArgs = args || readStoredToolArgs(this.toolCallId) - const requestedEntityId = - resolvedArgs?.entityId?.trim() ?? - (typeof stagedResult?.entityId === 'string' ? stagedResult.entityId.trim() : undefined) - if (!requestedEntityId) { - throw new Error('entityId is required for edit_workflow') - } - const { workflowId } = await resolveWorkflowTarget(executionContext, { - entityId: requestedEntityId, - }) - const lease = await acquireWritableWorkflowSessionLease({ - workflowId, - workspaceId: - (typeof stagedResult.workspaceId === 'string' ? stagedResult.workspaceId : undefined) ?? - executionContext.workspaceId ?? - null, - }) - - try { - if (!this.hasAppliedState) { - setWorkflowState( - lease.session.doc, - stagedResult.workflowState, - YJS_ORIGINS.COPILOT_REVIEW_ACCEPT - ) - this.hasAppliedState = true - } - } finally { - lease.release() - } - - this.setState(ClientToolCallState.success) - const completed = await this.markToolComplete(200, 'Workflow edits accepted', stagedResult) - if (!completed) { - logger.warn('markToolComplete failed during handleAccept', { - toolCallId: this.toolCallId, - }) - } - } catch (error: any) { - const message = error instanceof Error ? error.message : String(error) - logger.error('handleAccept failed', { toolCallId: this.toolCallId, message }) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, message || 'Failed to apply workflow edits') - } - } - - protected getRejectCompletionMessage(): string { - return 'Workflow changes rejected' - } - - protected getServerToolName(): string { - return EditWorkflowClientTool.id - } - - protected buildServerPayload( - workflowId: string, - args: Record | undefined, - currentWorkflowState: string - ): Record { - const entityDocument = args?.entityDocument?.trim() - if (!entityDocument) { - throw new Error(`No entityDocument provided for ${this.getServerToolName()}`) - } - - return { - entityId: workflowId, - entityDocument, - ...(Array.isArray(args?.removedBlockIds) ? { removedBlockIds: args.removedBlockIds } : {}), - currentWorkflowState, - } - } - - protected hasStagedReviewResult(result: Record | undefined): boolean { - return !!result?.workflowState - } - - async execute(args?: EditWorkflowArgs): Promise { - const logger = createLogger('EditWorkflowClientTool') - try { - if (this.hasExecuted) { - logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId }) - return - } - this.hasExecuted = true - logger.info('execute called', { toolCallId: this.toolCallId, argsProvided: !!args }) - this.setState(ClientToolCallState.executing) - const executionContext = this.requireExecutionContext() - const requestedEntityId = requireCopilotEntityId(args, { toolName: 'edit_workflow' }) - - const { workflowId, workspaceId } = await resolveWorkflowTarget(executionContext, { - entityId: requestedEntityId, - }) - - const readableWorkflow = await getReadableWorkflowState(executionContext, workflowId) - - const result = (await executeCopilotServerTool({ - toolName: this.getServerToolName(), - payload: this.buildServerPayload( - workflowId, - args, - JSON.stringify(readableWorkflow.workflowState) - ), - signal: this.getAbortSignal(), - })) as any - if (!result.workflowState) { - throw new Error('No workflow state returned from server') - } - if (typeof result.entityDocument !== 'string') { - throw new Error('No workflow document returned from server') - } - - const stagedResult = { - ...result, - ...buildWorkflowDocumentToolResult({ - workflowId, - entityName: readableWorkflow.entityName, - workspaceId: readableWorkflow.workspaceId ?? workspaceId, - entityDocument: result.entityDocument, - documentFormat: result.documentFormat, - }), - } - this.hasAppliedState = false - logger.info('server result parsed', { - hasWorkflowState: !!result?.workflowState, - blocksCount: result?.workflowState - ? Object.keys(result.workflowState.blocks || {}).length - : 0, - }) - - this.stageReviewResult(stagedResult) - } catch (error: any) { - const message = error instanceof Error ? error.message : String(error) - logger.error('execute error', { message }) - await this.markToolComplete(getCopilotServerToolErrorStatus(error) ?? 500, message) - this.setState(ClientToolCallState.error) - } - } -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/list-workflows.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/list-workflows.ts deleted file mode 100644 index dc31eb959..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/list-workflows.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { ListChecks, Loader2, X, XCircle } from 'lucide-react' -import { CopilotTool } from '@/lib/copilot/registry' -import { - BaseClientTool, - type BaseClientToolMetadata, - ClientToolCallState, -} from '@/lib/copilot/tools/client/base-tool' -import { createLogger } from '@/lib/logs/console/logger' -import { listWorkflowsForExecutionContext } from './workflow-review-tool-utils' - -const logger = createLogger('ListWorkflowsClientTool') - -export class ListWorkflowsClientTool extends BaseClientTool { - static readonly id = CopilotTool.list_workflows - - constructor(toolCallId: string) { - super(toolCallId, ListWorkflowsClientTool.id, ListWorkflowsClientTool.metadata) - } - - static readonly metadata: BaseClientToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Listing your workflows', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Listing your workflows', icon: ListChecks }, - [ClientToolCallState.executing]: { text: 'Listing your workflows', icon: Loader2 }, - [ClientToolCallState.aborted]: { text: 'Aborted listing workflows', icon: XCircle }, - [ClientToolCallState.success]: { text: 'Listed your workflows', icon: ListChecks }, - [ClientToolCallState.error]: { text: 'Failed to list workflows', icon: X }, - [ClientToolCallState.rejected]: { text: 'Skipped listing workflows', icon: XCircle }, - }, - } - - async execute(): Promise { - try { - this.setState(ClientToolCallState.executing) - const executionContext = this.requireExecutionContext() - const workflows = await listWorkflowsForExecutionContext(executionContext) - const entities = workflows.map((workflow) => ({ - entityId: workflow.workflowId, - ...(workflow.entityName ? { entityName: workflow.entityName } : {}), - ...(workflow.workspaceId ? { workspaceId: workflow.workspaceId } : {}), - })) - - logger.info('Found workflows', { count: workflows.length }) - - await this.markToolComplete(200, `Found ${workflows.length} workflow(s)`, { - entityKind: 'workflow', - entities, - count: workflows.length, - }) - this.setState(ClientToolCallState.success) - } catch (error: any) { - const message = error instanceof Error ? error.message : String(error) - await this.markToolComplete(500, message || 'Failed to list workflows') - this.setState(ClientToolCallState.error) - } - } -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/read-block-outputs.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/read-block-outputs.ts deleted file mode 100644 index 051b93a40..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/read-block-outputs.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { Loader2, Tag, X, XCircle } from 'lucide-react' -import { CopilotTool } from '@/lib/copilot/registry' -import { - BaseClientTool, - type BaseClientToolMetadata, - ClientToolCallState, -} from '@/lib/copilot/tools/client/base-tool' -import { - computeBlockOutputReferences, - getSubflowInsideOutputReferences, - getSubflowOutsideOutputReferences, - readWorkflowSubBlockValues, - readWorkflowVariableOutputs, -} from '@/lib/copilot/tools/client/workflow/block-output-utils' -import { getReadableWorkflowState } from '@/lib/copilot/tools/client/workflow/workflow-review-tool-utils' -import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target' -import { - ReadBlockOutputsResult, - type ReadBlockOutputsResultType, -} from '@/lib/copilot/tools/shared/schemas' -import { createLogger } from '@/lib/logs/console/logger' - -const logger = createLogger('ReadBlockOutputsClientTool') - -interface ReadBlockOutputsArgs { - blockIds?: string[] - entityId: string -} - -export class ReadBlockOutputsClientTool extends BaseClientTool { - static readonly id = CopilotTool.read_block_outputs - - constructor(toolCallId: string) { - super(toolCallId, ReadBlockOutputsClientTool.id, ReadBlockOutputsClientTool.metadata) - } - - static readonly metadata: BaseClientToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Getting block outputs', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Getting block outputs', icon: Tag }, - [ClientToolCallState.executing]: { text: 'Getting block outputs', icon: Loader2 }, - [ClientToolCallState.aborted]: { text: 'Aborted getting outputs', icon: XCircle }, - [ClientToolCallState.success]: { text: 'Retrieved block outputs', icon: Tag }, - [ClientToolCallState.error]: { text: 'Failed to get outputs', icon: X }, - [ClientToolCallState.rejected]: { text: 'Skipped getting outputs', icon: XCircle }, - }, - getDynamicText: (params, state) => { - const blockIds = params?.blockIds - if (blockIds && Array.isArray(blockIds) && blockIds.length > 0) { - const count = blockIds.length - switch (state) { - case ClientToolCallState.success: - return `Retrieved outputs for ${count} block${count > 1 ? 's' : ''}` - case ClientToolCallState.executing: - case ClientToolCallState.generating: - case ClientToolCallState.pending: - return `Getting outputs for ${count} block${count > 1 ? 's' : ''}` - case ClientToolCallState.error: - return `Failed to get outputs for ${count} block${count > 1 ? 's' : ''}` - } - } - return undefined - }, - } - - async execute(args?: ReadBlockOutputsArgs): Promise { - try { - this.setState(ClientToolCallState.executing) - const executionContext = this.requireExecutionContext() - const entityId = requireCopilotEntityId(args) - - const { - workflowId: activeWorkflowId, - workflowState: snapshot, - variables, - } = await getReadableWorkflowState(executionContext, entityId) - const blocks = snapshot.blocks || {} - const loops = snapshot.loops || {} - const parallels = snapshot.parallels || {} - const subBlockValues = readWorkflowSubBlockValues(activeWorkflowId, snapshot) - const variableOutputs = readWorkflowVariableOutputs(variables) - - const ctx = { blocks, loops, parallels, subBlockValues } - const targetBlockIds = - args?.blockIds && args.blockIds.length > 0 ? args.blockIds : Object.keys(blocks) - - const blockOutputs: ReadBlockOutputsResultType['blocks'] = [] - - for (const blockId of targetBlockIds) { - const block = blocks[blockId] - if (!block?.type) continue - - const blockName = block.name || block.type - - const blockOutput: ReadBlockOutputsResultType['blocks'][0] = { - blockId, - blockName, - blockType: block.type, - outputs: [], - } - - if (block.type === 'loop' || block.type === 'parallel') { - blockOutput.insideSubflowOutputs = getSubflowInsideOutputReferences( - block.type, - blockId, - blockName, - loops, - parallels - ) - blockOutput.outsideSubflowOutputs = getSubflowOutsideOutputReferences(blockName) - } else { - blockOutput.outputs = computeBlockOutputReferences(block, ctx, variableOutputs) - } - - blockOutputs.push(blockOutput) - } - - const includeVariables = !args?.blockIds || args.blockIds.length === 0 - const resultData: { - blocks: typeof blockOutputs - variables?: ReturnType - } = { - blocks: blockOutputs, - } - if (includeVariables) { - resultData.variables = variableOutputs - } - - const result = ReadBlockOutputsResult.parse(resultData) - - logger.info('Retrieved block outputs', { - blockCount: blockOutputs.length, - variableCount: resultData.variables?.length ?? 0, - }) - - await this.markToolComplete(200, 'Retrieved block outputs', result) - this.setState(ClientToolCallState.success) - } catch (error: any) { - const message = error instanceof Error ? error.message : String(error) - logger.error('Error in tool execution', { toolCallId: this.toolCallId, error, message }) - await this.markToolComplete(500, message || 'Failed to get block outputs') - this.setState(ClientToolCallState.error) - } - } -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/read-block-upstream-references.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/read-block-upstream-references.ts deleted file mode 100644 index 0808b8210..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/read-block-upstream-references.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { GitBranch, Loader2, X, XCircle } from 'lucide-react' -import { BlockPathCalculator } from '@/lib/block-path-calculator' -import { CopilotTool } from '@/lib/copilot/registry' -import { - BaseClientTool, - type BaseClientToolMetadata, - ClientToolCallState, -} from '@/lib/copilot/tools/client/base-tool' -import { - computeBlockOutputReferences, - getSubflowInsideOutputReferences, - getSubflowOutsideOutputReferences, - readWorkflowSubBlockValues, - readWorkflowVariableOutputs, -} from '@/lib/copilot/tools/client/workflow/block-output-utils' -import { getReadableWorkflowState } from '@/lib/copilot/tools/client/workflow/workflow-review-tool-utils' -import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target' -import { - ReadBlockUpstreamReferencesResult, - type ReadBlockUpstreamReferencesResultType, -} from '@/lib/copilot/tools/shared/schemas' -import { createLogger } from '@/lib/logs/console/logger' -import type { Loop, Parallel } from '@/stores/workflows/workflow/types' - -const logger = createLogger('ReadBlockUpstreamReferencesClientTool') - -interface ReadBlockUpstreamReferencesArgs { - blockIds: string[] - entityId: string -} - -export class ReadBlockUpstreamReferencesClientTool extends BaseClientTool { - static readonly id = CopilotTool.read_block_upstream_references - - constructor(toolCallId: string) { - super( - toolCallId, - ReadBlockUpstreamReferencesClientTool.id, - ReadBlockUpstreamReferencesClientTool.metadata - ) - } - - static readonly metadata: BaseClientToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Getting upstream references', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Getting upstream references', icon: GitBranch }, - [ClientToolCallState.executing]: { text: 'Getting upstream references', icon: Loader2 }, - [ClientToolCallState.aborted]: { text: 'Aborted getting references', icon: XCircle }, - [ClientToolCallState.success]: { text: 'Retrieved upstream references', icon: GitBranch }, - [ClientToolCallState.error]: { text: 'Failed to get references', icon: X }, - [ClientToolCallState.rejected]: { text: 'Skipped getting references', icon: XCircle }, - }, - getDynamicText: (params, state) => { - const blockIds = params?.blockIds - if (blockIds && Array.isArray(blockIds) && blockIds.length > 0) { - const count = blockIds.length - switch (state) { - case ClientToolCallState.success: - return `Retrieved references for ${count} block${count > 1 ? 's' : ''}` - case ClientToolCallState.executing: - case ClientToolCallState.generating: - case ClientToolCallState.pending: - return `Getting references for ${count} block${count > 1 ? 's' : ''}` - case ClientToolCallState.error: - return `Failed to get references for ${count} block${count > 1 ? 's' : ''}` - } - } - return undefined - }, - } - - async execute(args?: ReadBlockUpstreamReferencesArgs): Promise { - try { - this.setState(ClientToolCallState.executing) - const executionContext = this.requireExecutionContext() - - if (!args?.blockIds || args.blockIds.length === 0) { - await this.markToolComplete(400, 'blockIds array is required') - this.setState(ClientToolCallState.error) - return - } - const entityId = requireCopilotEntityId(args) - - const { - workflowId: activeWorkflowId, - workflowState: snapshot, - variables, - } = await getReadableWorkflowState(executionContext, entityId) - const blocks = snapshot.blocks || {} - const edges = snapshot.edges || [] - const loops = snapshot.loops || {} - const parallels = snapshot.parallels || {} - const subBlockValues = readWorkflowSubBlockValues(activeWorkflowId, snapshot) - - const ctx = { blocks, loops, parallels, subBlockValues } - const variableOutputs = readWorkflowVariableOutputs(variables) - const graphEdges = edges.map((edge) => ({ source: edge.source, target: edge.target })) - - const results: ReadBlockUpstreamReferencesResultType['results'] = [] - - for (const blockId of args.blockIds) { - const targetBlock = blocks[blockId] - if (!targetBlock) { - logger.warn(`Block ${blockId} not found`) - continue - } - - const insideSubflows: { blockId: string; blockName: string; blockType: string }[] = [] - const containingLoopIds = new Set() - const containingParallelIds = new Set() - - Object.values(loops as Record).forEach((loop) => { - if (loop?.nodes?.includes(blockId)) { - containingLoopIds.add(loop.id) - const loopBlock = blocks[loop.id] - if (loopBlock) { - insideSubflows.push({ - blockId: loop.id, - blockName: loopBlock.name || loopBlock.type, - blockType: 'loop', - }) - } - } - }) - - Object.values(parallels as Record).forEach((parallel) => { - if (parallel?.nodes?.includes(blockId)) { - containingParallelIds.add(parallel.id) - const parallelBlock = blocks[parallel.id] - if (parallelBlock) { - insideSubflows.push({ - blockId: parallel.id, - blockName: parallelBlock.name || parallelBlock.type, - blockType: 'parallel', - }) - } - } - }) - - const ancestorIds = BlockPathCalculator.findAllPathNodes(graphEdges, blockId) - const accessibleIds = new Set(ancestorIds) - accessibleIds.add(blockId) - - containingLoopIds.forEach((loopId) => { - accessibleIds.add(loopId) - loops[loopId]?.nodes?.forEach((nodeId) => accessibleIds.add(nodeId)) - }) - - containingParallelIds.forEach((parallelId) => { - accessibleIds.add(parallelId) - parallels[parallelId]?.nodes?.forEach((nodeId) => accessibleIds.add(nodeId)) - }) - - const accessibleBlocks: ReadBlockUpstreamReferencesResultType['results'][0]['accessibleBlocks'] = - [] - - for (const accessibleBlockId of accessibleIds) { - const block = blocks[accessibleBlockId] - if (!block?.type) continue - - const canSelfReference = block.type === 'approval' || block.type === 'human_in_the_loop' - if (accessibleBlockId === blockId && !canSelfReference) continue - - const blockName = block.name || block.type - let accessContext: 'inside' | 'outside' | undefined - let outputs: ReadBlockUpstreamReferencesResultType['results'][0]['accessibleBlocks'][0]['outputs'] - - if (block.type === 'loop' || block.type === 'parallel') { - const isInside = - (block.type === 'loop' && containingLoopIds.has(accessibleBlockId)) || - (block.type === 'parallel' && containingParallelIds.has(accessibleBlockId)) - - accessContext = isInside ? 'inside' : 'outside' - outputs = isInside - ? getSubflowInsideOutputReferences( - block.type, - accessibleBlockId, - blockName, - loops, - parallels - ) - : getSubflowOutsideOutputReferences(blockName) - } else { - outputs = computeBlockOutputReferences(block, ctx, variableOutputs) - } - - const entry: ReadBlockUpstreamReferencesResultType['results'][0]['accessibleBlocks'][0] = - { - blockId: accessibleBlockId, - blockName, - blockType: block.type, - outputs, - } - - if (accessContext) entry.accessContext = accessContext - accessibleBlocks.push(entry) - } - - const resultEntry: ReadBlockUpstreamReferencesResultType['results'][0] = { - blockId, - blockName: targetBlock.name || targetBlock.type, - accessibleBlocks, - variables: variableOutputs, - } - - if (insideSubflows.length > 0) resultEntry.insideSubflows = insideSubflows - results.push(resultEntry) - } - - const result = ReadBlockUpstreamReferencesResult.parse({ results }) - - logger.info('Retrieved upstream references', { - blockIds: args.blockIds, - resultCount: results.length, - }) - - await this.markToolComplete(200, 'Retrieved upstream references', result) - this.setState(ClientToolCallState.success) - } catch (error: any) { - const message = error instanceof Error ? error.message : String(error) - logger.error('Error in tool execution', { toolCallId: this.toolCallId, error, message }) - await this.markToolComplete(500, message || 'Failed to get upstream references') - this.setState(ClientToolCallState.error) - } - } -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/read-workflow-variables.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/read-workflow-variables.ts deleted file mode 100644 index 1f6bbf5c1..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/read-workflow-variables.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { List, Loader2, X, XCircle } from 'lucide-react' -import { CopilotTool } from '@/lib/copilot/registry' -import { - BaseClientTool, - type BaseClientToolMetadata, - ClientToolCallState, -} from '@/lib/copilot/tools/client/base-tool' -import { getReadableWorkflowState } from '@/lib/copilot/tools/client/workflow/workflow-review-tool-utils' -import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target' -import { createLogger } from '@/lib/logs/console/logger' - -const logger = createLogger('ReadWorkflowVariablesClientTool') - -interface ReadWorkflowVariablesArgs { - entityId: string -} - -export class ReadWorkflowVariablesClientTool extends BaseClientTool { - static readonly id = CopilotTool.read_workflow_variables - - constructor(toolCallId: string) { - super(toolCallId, ReadWorkflowVariablesClientTool.id, ReadWorkflowVariablesClientTool.metadata) - } - - static readonly metadata: BaseClientToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Reading workflow variables', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Reading workflow variables', icon: List }, - [ClientToolCallState.executing]: { text: 'Reading workflow variables', icon: Loader2 }, - [ClientToolCallState.aborted]: { text: 'Aborted reading variables', icon: XCircle }, - [ClientToolCallState.success]: { text: 'Read workflow variables', icon: List }, - [ClientToolCallState.error]: { text: 'Failed to read variables', icon: X }, - [ClientToolCallState.rejected]: { text: 'Skipped reading variables', icon: XCircle }, - }, - } - - async execute(args?: ReadWorkflowVariablesArgs): Promise { - try { - this.setState(ClientToolCallState.executing) - const executionContext = this.requireExecutionContext() - const entityId = requireCopilotEntityId(args) - const { workflowId, variables: varsRecord } = await getReadableWorkflowState( - executionContext, - entityId - ) - const variables = Object.values(varsRecord).map((v: any) => ({ - name: String(v?.name || ''), - value: (v as any)?.value, - })) - - logger.info('Read workflow variables', { workflowId, count: variables.length }) - await this.markToolComplete(200, `Found ${variables.length} variable(s)`, { variables }) - this.setState(ClientToolCallState.success) - } catch (error: any) { - const message = error instanceof Error ? error.message : String(error) - await this.markToolComplete(500, message || 'Failed to read workflow variables') - this.setState(ClientToolCallState.error) - } - } -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/read-workflow.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/read-workflow.ts deleted file mode 100644 index 4d00e0dbb..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/read-workflow.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { Loader2, Workflow as WorkflowIcon, X, XCircle } from 'lucide-react' -import { CopilotTool } from '@/lib/copilot/registry' -import { - BaseClientTool, - type BaseClientToolMetadata, - ClientToolCallState, -} from '@/lib/copilot/tools/client/base-tool' -import { - buildWorkflowDocumentToolResult, - buildWorkflowSummary, - getReadableWorkflowState, -} from '@/lib/copilot/tools/client/workflow/workflow-review-tool-utils' -import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target' -import { createLogger } from '@/lib/logs/console/logger' -import { serializeWorkflowToTgMermaid } from '@/lib/workflows/studio-workflow-mermaid' - -interface ReadWorkflowArgs { - entityId: string -} - -const logger = createLogger('ReadWorkflowClientTool') - -export class ReadWorkflowClientTool extends BaseClientTool { - static readonly id = CopilotTool.read_workflow - - constructor(toolCallId: string) { - super(toolCallId, ReadWorkflowClientTool.id, ReadWorkflowClientTool.metadata) - } - - static readonly metadata: BaseClientToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Analyzing your workflow', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Analyzing your workflow', icon: WorkflowIcon }, - [ClientToolCallState.executing]: { text: 'Analyzing your workflow', icon: Loader2 }, - [ClientToolCallState.aborted]: { text: 'Aborted analyzing your workflow', icon: XCircle }, - [ClientToolCallState.success]: { text: 'Analyzed your workflow', icon: WorkflowIcon }, - [ClientToolCallState.error]: { text: 'Failed to analyze your workflow', icon: X }, - [ClientToolCallState.rejected]: { text: 'Skipped analyzing your workflow', icon: XCircle }, - }, - } - - async execute(args?: ReadWorkflowArgs): Promise { - try { - this.setState(ClientToolCallState.executing) - const executionContext = this.requireExecutionContext() - let requestedEntityId: string - try { - requestedEntityId = requireCopilotEntityId(args) - } catch { - await this.markToolComplete(400, 'entityId is required') - this.setState(ClientToolCallState.error) - return - } - - logger.info('Reading workflow from readable workflow snapshot', { - entityId: requestedEntityId, - }) - - const { workflowId, entityName, workflowState, workspaceId } = await getReadableWorkflowState( - executionContext, - requestedEntityId - ) - - let workflowDocument = '' - try { - workflowDocument = serializeWorkflowToTgMermaid(workflowState) - logger.info('Successfully serialized workflow document', { - workflowId, - documentLength: workflowDocument.length, - }) - } catch (stringifyError) { - await this.markToolComplete( - 500, - `Failed to convert workflow to Mermaid: ${ - stringifyError instanceof Error ? stringifyError.message : 'Unknown error' - }` - ) - this.setState(ClientToolCallState.error) - return - } - - // Mark complete with data; keep state success for store render - await this.markToolComplete(200, 'Workflow analyzed', { - ...buildWorkflowDocumentToolResult({ - workflowId, - entityName, - workspaceId, - entityDocument: workflowDocument, - }), - workflowSummary: buildWorkflowSummary(workflowState), - }) - this.setState(ClientToolCallState.success) - } catch (error: any) { - const message = error instanceof Error ? error.message : String(error) - logger.error('Error in tool execution', { - toolCallId: this.toolCallId, - error, - message, - }) - await this.markToolComplete(500, message || 'Failed to read workflow') - this.setState(ClientToolCallState.error) - } - } -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/rename-workflow.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/rename-workflow.ts deleted file mode 100644 index c19194442..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/rename-workflow.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Check, Grid2x2, Loader2, X, XCircle } from 'lucide-react' -import { - BaseClientTool, - type BaseClientToolMetadata, - ClientToolCallState, -} from '@/lib/copilot/tools/client/base-tool' -import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target' -import { createLogger } from '@/lib/logs/console/logger' -import { getCopilotStoreForToolCall } from '@/stores/copilot/store-access' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -type RenameWorkflowArgs = { - entityId: string - name: string -} - -function readStoredToolArgs(toolCallId: string): TArgs | undefined { - try { - const { toolCallsById } = getCopilotStoreForToolCall(toolCallId).getState() - return toolCallsById[toolCallId]?.params as TArgs | undefined - } catch { - return undefined - } -} - -export class RenameWorkflowClientTool extends BaseClientTool { - static readonly id = 'rename_workflow' - private currentArgs?: RenameWorkflowArgs - - static readonly metadata: BaseClientToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { text: 'Renaming workflow', icon: Loader2 }, - [ClientToolCallState.pending]: { text: 'Rename workflow?', icon: Grid2x2 }, - [ClientToolCallState.executing]: { text: 'Renaming workflow', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Renamed workflow', icon: Check }, - [ClientToolCallState.error]: { text: 'Failed to rename workflow', icon: X }, - [ClientToolCallState.aborted]: { text: 'Aborted renaming workflow', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped renaming workflow', icon: XCircle }, - }, - interrupt: { - accept: { text: 'Allow', icon: Check }, - reject: { text: 'Skip', icon: XCircle }, - }, - } - - constructor(toolCallId: string) { - super(toolCallId, RenameWorkflowClientTool.id, RenameWorkflowClientTool.metadata) - } - - async execute(args?: RenameWorkflowArgs): Promise { - this.currentArgs = args - } - - async handleAccept(args?: RenameWorkflowArgs): Promise { - const logger = createLogger('RenameWorkflowClientTool') - - try { - this.setState(ClientToolCallState.executing) - - const resolvedArgs = - args || this.currentArgs || readStoredToolArgs(this.toolCallId) - const workflowId = requireCopilotEntityId(resolvedArgs) - const nextName = resolvedArgs?.name?.trim() - - if (!nextName) { - throw new Error('name is required') - } - - const response = await fetch(`/api/workflows/${encodeURIComponent(workflowId)}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: nextName, - }), - }) - const payload = await response.json().catch(() => ({})) - - if (!response.ok) { - throw new Error(payload?.error || `Failed to rename workflow: ${response.status}`) - } - - const updatedWorkflow = - payload?.workflow && typeof payload.workflow === 'object' ? payload.workflow : null - - if (!updatedWorkflow) { - throw new Error('Invalid workflow rename response') - } - - useWorkflowRegistry.setState((state) => { - const existingWorkflow = state.workflows[workflowId] - if (!existingWorkflow) { - return state - } - - return { - workflows: { - ...state.workflows, - [workflowId]: { - ...existingWorkflow, - name: nextName, - lastModified: updatedWorkflow.updatedAt - ? new Date(updatedWorkflow.updatedAt) - : existingWorkflow.lastModified, - }, - }, - } - }) - - await this.markToolComplete(200, 'Workflow renamed', { - success: true, - entityKind: 'workflow', - entityId: workflowId, - entityName: nextName, - workspaceId: - typeof updatedWorkflow.workspaceId === 'string' - ? updatedWorkflow.workspaceId - : useWorkflowRegistry.getState().workflows[workflowId]?.workspaceId, - }) - this.setState(ClientToolCallState.success) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - logger.error('Failed to rename workflow', { toolCallId: this.toolCallId, message }) - await this.markToolComplete(500, message) - this.setState(ClientToolCallState.error) - } - } -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/set-workflow-variables.test.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/set-workflow-variables.test.ts deleted file mode 100644 index b92e41b6d..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/set-workflow-variables.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' -import { SetWorkflowVariablesClientTool } from '@/lib/copilot/tools/client/workflow/set-workflow-variables' -import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' - -const mockGetRegisteredWorkflowSession = vi.fn() -const mockGetVariablesForWorkflow = vi.fn() -const mockSetVariables = vi.fn() -const mockCopilotState = { - toolCallsById: {} as Record; state?: string }>, -} - -vi.mock('@/stores/copilot/store-access', () => ({ - getCopilotStoreForToolCall: () => ({ - getState: () => mockCopilotState, - }), -})) - -vi.mock('@/lib/yjs/workflow-session-registry', () => ({ - getRegisteredWorkflowSession: (...args: any[]) => mockGetRegisteredWorkflowSession(...args), - getVariablesForWorkflow: (...args: any[]) => mockGetVariablesForWorkflow(...args), -})) - -vi.mock('@/lib/yjs/workflow-session', () => ({ - getVariablesMap: vi.fn(), - setVariables: (...args: any[]) => mockSetVariables(...args), -})) - -describe('SetWorkflowVariablesClientTool', () => { - beforeEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals?.() - mockGetRegisteredWorkflowSession.mockReset() - mockGetVariablesForWorkflow.mockReset() - mockSetVariables.mockReset() - mockCopilotState.toolCallsById = {} - }) - - it('uses explicit entityId and writes variables only through the live Yjs session', async () => { - const doc = { kind: 'workflow-doc' } - mockGetRegisteredWorkflowSession.mockReturnValue({ doc }) - mockGetVariablesForWorkflow.mockReturnValue({ - 'var-1': { - id: 'var-1', - workflowId: 'wf-target', - name: 'existing', - type: 'plain', - value: 'value', - }, - }) - - vi.stubGlobal('crypto', { - randomUUID: vi.fn(() => 'var-2'), - }) - - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const method = init?.method || 'GET' - - if (url === '/api/copilot/tools/mark-complete' && method === 'POST') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url} (${method})`) - }) - vi.stubGlobal('fetch', fetchMock) - - const toolCallId = 'set-vars-tool-call' - const tool = new SetWorkflowVariablesClientTool(toolCallId) - tool.setExecutionContext({ - toolCallId, - toolName: 'set_workflow_variables', - channelId: 'pair-purple', - contextEntityKind: 'workflow', - contextEntityId: 'wf-context', - log: vi.fn(), - }) - - await tool.handleAccept({ - entityId: 'wf-target', - operations: [ - { operation: 'edit', name: 'existing', type: 'number', value: '42' }, - { operation: 'add', name: 'newVar', type: 'boolean', value: 'true' }, - ], - }) - - expect(mockGetRegisteredWorkflowSession).toHaveBeenCalledWith('wf-target') - expect(mockGetVariablesForWorkflow).toHaveBeenCalledWith('wf-target') - expect(mockSetVariables).toHaveBeenCalledTimes(1) - expect(mockSetVariables).toHaveBeenCalledWith( - doc, - { - 'var-1': { - id: 'var-1', - workflowId: 'wf-target', - name: 'existing', - type: 'number', - value: 42, - }, - 'var-2': { - id: 'var-2', - workflowId: 'wf-target', - name: 'newVar', - type: 'boolean', - value: true, - }, - }, - YJS_ORIGINS.COPILOT_TOOL - ) - expect(fetchMock).toHaveBeenCalledTimes(1) - expect(fetchMock).toHaveBeenCalledWith( - '/api/copilot/tools/mark-complete', - expect.objectContaining({ - method: 'POST', - }) - ) - expect(tool.getState()).toBe(ClientToolCallState.success) - }) -}) diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/set-workflow-variables.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/set-workflow-variables.ts deleted file mode 100644 index ecb677a4f..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/set-workflow-variables.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Loader2, Settings2, X, XCircle } from 'lucide-react' -import { CopilotTool } from '@/lib/copilot/registry' -import { - BaseClientTool, - type BaseClientToolMetadata, - ClientToolCallState, -} from '@/lib/copilot/tools/client/base-tool' -import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target' -import { createLogger } from '@/lib/logs/console/logger' -import { VariableManager } from '@/lib/variables/variable-manager' -import { isWorkflowVariableType, type WorkflowVariableType } from '@/lib/workflows/value-types' -import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' -import { setVariables } from '@/lib/yjs/workflow-session' -import { - getRegisteredWorkflowSession, - getVariablesForWorkflow, -} from '@/lib/yjs/workflow-session-registry' - -interface OperationItem { - operation: 'add' | 'edit' | 'delete' - name: string - type?: WorkflowVariableType - value?: string -} - -interface SetWorkflowVariablesArgs { - operations: OperationItem[] - entityId: string -} - -export class SetWorkflowVariablesClientTool extends BaseClientTool { - static readonly id = CopilotTool.set_workflow_variables - - constructor(toolCallId: string) { - super(toolCallId, SetWorkflowVariablesClientTool.id, SetWorkflowVariablesClientTool.metadata) - } - - static readonly metadata: BaseClientToolMetadata = { - displayNames: { - [ClientToolCallState.generating]: { - text: 'Preparing to set workflow variables', - icon: Loader2, - }, - [ClientToolCallState.pending]: { text: 'Set workflow variables?', icon: Settings2 }, - [ClientToolCallState.executing]: { text: 'Setting workflow variables', icon: Loader2 }, - [ClientToolCallState.success]: { text: 'Workflow variables updated', icon: Settings2 }, - [ClientToolCallState.error]: { text: 'Failed to set workflow variables', icon: X }, - [ClientToolCallState.aborted]: { text: 'Aborted setting variables', icon: XCircle }, - [ClientToolCallState.rejected]: { text: 'Skipped setting variables', icon: XCircle }, - }, - interrupt: { - accept: { text: 'Apply', icon: Settings2 }, - reject: { text: 'Skip', icon: XCircle }, - }, - } - - async handleAccept(args?: SetWorkflowVariablesArgs): Promise { - const logger = createLogger('SetWorkflowVariablesClientTool') - try { - this.setState(ClientToolCallState.executing) - const payload = { ...(args || { operations: [] }) } as Partial - const workflowId = requireCopilotEntityId(payload) - - const currentVarsRecord = getVariablesForWorkflow(workflowId) - if (!currentVarsRecord) { - throw new Error('No live Yjs session for this workflow') - } - - // Build mutable map by variable name - const byName: Record = {} - Object.values(currentVarsRecord).forEach((v: any) => { - if (v && typeof v === 'object' && v.id && v.name) byName[String(v.name)] = v - }) - - // Apply operations in order - for (const op of payload.operations || []) { - const key = String(op.name) - const nextType = op.type ?? byName[key]?.type ?? 'plain' - if (op.operation === 'delete') { - delete byName[key] - continue - } - if (!isWorkflowVariableType(nextType)) { - throw new Error(`Unsupported variable type: ${String(nextType)}`) - } - const typedValue = - op.value === undefined - ? undefined - : VariableManager.parseInputForStorage(op.value, nextType) - if (op.operation === 'add') { - byName[key] = { - id: crypto.randomUUID(), - workflowId, - name: key, - type: nextType, - value: typedValue, - } - continue - } - if (op.operation === 'edit') { - if (!byName[key]) { - byName[key] = { - id: crypto.randomUUID(), - workflowId, - name: key, - type: nextType, - value: typedValue, - } - } else { - byName[key] = { - ...byName[key], - type: nextType, - ...(op.value !== undefined ? { value: typedValue } : {}), - } - } - } - } - - const updatedRecord: Record = {} - for (const variable of Object.values(byName)) { - updatedRecord[variable.id] = variable - } - - const session = getRegisteredWorkflowSession(workflowId) - if (!session) { - throw new Error('No live Yjs session for this workflow') - } - setVariables(session.doc, updatedRecord, YJS_ORIGINS.COPILOT_TOOL) - - logger.info('Applied variable operations to Yjs doc', { - workflowId, - operationCount: payload.operations?.length ?? 0, - }) - - await this.markToolComplete(200, 'Workflow variables updated', { variables: byName }) - this.setState(ClientToolCallState.success) - } catch (e: any) { - const message = e instanceof Error ? e.message : String(e) - this.setState(ClientToolCallState.error) - await this.markToolComplete(500, message || 'Failed to set workflow variables') - } - } - - async execute(args?: SetWorkflowVariablesArgs): Promise { - await this.handleAccept(args) - } -} diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/workflow-metadata-tools.test.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/workflow-metadata-tools.test.ts deleted file mode 100644 index 0f569cb6c..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/workflow-metadata-tools.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' -import { CreateWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/create-workflow' -import { RenameWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/rename-workflow' - -const mockCopilotState = { - toolCallsById: {} as Record }>, -} - -const mockRegistryState = { - workflows: {} as Record, - createWorkflow: vi.fn(), -} - -const originalFetch = globalThis.fetch - -vi.mock('@/stores/copilot/store-access', () => ({ - getCopilotStoreForToolCall: () => ({ - getState: () => mockCopilotState, - }), -})) - -vi.mock('@/stores/workflows/registry/store', () => ({ - useWorkflowRegistry: { - getState: () => mockRegistryState, - setState: (updater: any) => { - const nextState = typeof updater === 'function' ? updater(mockRegistryState) : updater - - if (nextState && typeof nextState === 'object') { - Object.assign(mockRegistryState, nextState) - } - }, - }, -})) - -describe('workflow metadata tools', () => { - beforeEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals?.() - globalThis.fetch = originalFetch - mockCopilotState.toolCallsById = {} - mockRegistryState.workflows = {} - mockRegistryState.createWorkflow = vi.fn() - }) - - it('create_workflow creates a workflow in the current workspace and marks completion', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const method = init?.method || 'GET' - - if (url === '/api/copilot/tools/mark-complete' && method === 'POST') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url} (${method})`) - }) - vi.stubGlobal('fetch', fetchMock) - - mockRegistryState.createWorkflow = vi.fn(async (options: Record) => { - mockRegistryState.workflows['wf-2'] = { - id: 'wf-2', - name: String(options.name ?? 'Created Workflow'), - workspaceId: String(options.workspaceId), - } - return 'wf-2' - }) - - const toolCallId = 'create-workflow' - const tool = new CreateWorkflowClientTool(toolCallId) - tool.setExecutionContext({ - toolCallId, - toolName: 'create_workflow', - channelId: 'pair-blue', - workspaceId: 'ws-1', - log: vi.fn(), - }) - - await tool.execute({ - name: 'Created Workflow', - description: 'Created from copilot', - }) - await tool.handleAccept() - - expect(tool.getState()).toBe(ClientToolCallState.success) - expect(mockRegistryState.createWorkflow).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - name: 'Created Workflow', - description: 'Created from copilot', - }) - - const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => { - const url = typeof input === 'string' ? input : input.toString() - return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST' - }) - const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body)) - expect(markCompleteBody.name).toBe('create_workflow') - expect(markCompleteBody.data).toMatchObject({ - success: true, - entityKind: 'workflow', - entityId: 'wf-2', - entityName: 'Created Workflow', - workspaceId: 'ws-1', - }) - }) - - it('rename_workflow renames the target workflow and syncs the local registry', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input.toString() - const method = init?.method || 'GET' - - if (url === '/api/workflows/wf-1' && method === 'PUT') { - return { - ok: true, - status: 200, - json: async () => ({ - workflow: { - id: 'wf-1', - name: 'Renamed Workflow', - workspaceId: 'ws-1', - updatedAt: '2026-04-18T00:00:00.000Z', - }, - }), - } - } - - if (url === '/api/copilot/tools/mark-complete' && method === 'POST') { - return { - ok: true, - status: 200, - json: async () => ({ success: true }), - } - } - - throw new Error(`Unexpected fetch URL: ${url} (${method})`) - }) - vi.stubGlobal('fetch', fetchMock) - - mockRegistryState.workflows = { - 'wf-1': { - id: 'wf-1', - name: 'Old Workflow', - workspaceId: 'ws-1', - lastModified: new Date('2026-04-17T00:00:00.000Z'), - }, - } - - const toolCallId = 'rename-workflow' - const tool = new RenameWorkflowClientTool(toolCallId) - tool.setExecutionContext({ - toolCallId, - toolName: 'rename_workflow', - channelId: 'pair-blue', - log: vi.fn(), - }) - - await tool.execute({ - entityId: 'wf-1', - name: 'Renamed Workflow', - }) - await tool.handleAccept() - - expect(tool.getState()).toBe(ClientToolCallState.success) - expect(mockRegistryState.workflows['wf-1'].name).toBe('Renamed Workflow') - - const markCompleteCall = fetchMock.mock.calls.find(([input, init]) => { - const url = typeof input === 'string' ? input : input.toString() - return url === '/api/copilot/tools/mark-complete' && (init?.method || 'GET') === 'POST' - }) - const markCompleteBody = JSON.parse(String(markCompleteCall?.[1]?.body)) - expect(markCompleteBody.name).toBe('rename_workflow') - expect(markCompleteBody.data).toMatchObject({ - success: true, - entityKind: 'workflow', - entityId: 'wf-1', - entityName: 'Renamed Workflow', - workspaceId: 'ws-1', - }) - }) -}) diff --git a/apps/tradinggoose/lib/copilot/tools/server/agent/get-agent-accessory-catalog.test.ts b/apps/tradinggoose/lib/copilot/tools/server/agent/get-agent-accessory-catalog.test.ts index ed88be544..f657b834c 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/agent/get-agent-accessory-catalog.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/agent/get-agent-accessory-catalog.test.ts @@ -1,12 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const mockResolveServerWorkflowScope = vi.hoisted(() => vi.fn()) -const mockResolveServerWorkspaceId = vi.hoisted(() => vi.fn()) const mockListCustomTools = vi.hoisted(() => vi.fn()) +const mockCheckWorkspaceAccess = vi.hoisted(() => vi.fn()) -vi.mock('@/lib/copilot/tools/server/workflow/workflow-scope', () => ({ - resolveServerWorkflowScope: mockResolveServerWorkflowScope, - resolveServerWorkspaceId: mockResolveServerWorkspaceId, +vi.mock('@/lib/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, })) vi.mock('@/lib/copilot/tools/server/blocks/block-mermaid-catalog', () => ({ @@ -39,12 +37,12 @@ import { getAgentAccessoryCatalogServerTool } from './get-agent-accessory-catalo describe('getAgentAccessoryCatalogServerTool', () => { beforeEach(() => { - mockResolveServerWorkflowScope.mockResolvedValue({ + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, hasAccess: true, - workspaceId: 'workspace-1', - workflowId: 'workflow-1', + canWrite: true, + workspace: { id: 'workspace-1' }, }) - mockResolveServerWorkspaceId.mockReturnValue('workspace-1') mockListCustomTools.mockResolvedValue([ { id: 'custom-tool-1', @@ -62,7 +60,7 @@ describe('getAgentAccessoryCatalogServerTool', () => { it('emits executable custom tool IDs from canonical custom tool database IDs', async () => { const result = await getAgentAccessoryCatalogServerTool.execute( - { entityId: 'workflow-1' }, + { workspaceId: 'workspace-1' }, { userId: 'user-1' } ) diff --git a/apps/tradinggoose/lib/copilot/tools/server/agent/get-agent-accessory-catalog.ts b/apps/tradinggoose/lib/copilot/tools/server/agent/get-agent-accessory-catalog.ts index 7df58dd2b..959ccd113 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/agent/get-agent-accessory-catalog.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/agent/get-agent-accessory-catalog.ts @@ -1,10 +1,9 @@ import { CopilotTool } from '@/lib/copilot/registry' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { listWorkflowBlockCatalogItems } from '@/lib/copilot/tools/server/blocks/block-mermaid-catalog' import { - resolveServerWorkspaceId, - resolveServerWorkflowScope, -} from '@/lib/copilot/tools/server/workflow/workflow-scope' + type BaseServerTool, + withWorkspaceArgContext, +} from '@/lib/copilot/tools/server/base-tool' +import { listWorkflowBlockCatalogItems } from '@/lib/copilot/tools/server/blocks/block-mermaid-catalog' import type { GetAgentAccessoryCatalogInputType, GetAgentAccessoryCatalogResultType, @@ -13,6 +12,7 @@ import { listCustomTools } from '@/lib/custom-tools/operations' import { createCustomToolRuntimeId } from '@/lib/custom-tools/schema' import { mcpService } from '@/lib/mcp/service' import { createMcpToolId } from '@/lib/mcp/utils' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { listSkills } from '@/lib/skills/operations' import { registry as blockRegistry } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' @@ -76,21 +76,22 @@ export const getAgentAccessoryCatalogServerTool: BaseServerTool< > = { name: CopilotTool.get_agent_accessory_catalog, async execute(args, context) { - if (!context?.userId) throw new Error('User context is required') + const scopedContext = withWorkspaceArgContext(context, args) + if (!scopedContext?.userId) throw new Error('User context is required') - const scope = await resolveServerWorkflowScope(args, context) - if (scope && !scope.hasAccess) { - throw new Error('Workflow not found or access denied') - } - const workspaceId = resolveServerWorkspaceId(context, scope) + const workspaceId = scopedContext.workspaceId if (!workspaceId) { throw new Error('Workspace context is required') } + const workspaceAccess = await checkWorkspaceAccess(workspaceId, scopedContext.userId) + if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { + throw new Error('Access denied: You do not have permission to use this workspace') + } const [blockToolOptions, customToolRows, mcpToolRows, skillRows] = await Promise.all([ getBlockToolOptions(), listCustomTools({ workspaceId }), - mcpService.discoverTools(context.userId, workspaceId), + mcpService.discoverTools(scopedContext.userId, workspaceId), listSkills({ workspaceId }), ]) diff --git a/apps/tradinggoose/lib/copilot/tools/server/base-tool.ts b/apps/tradinggoose/lib/copilot/tools/server/base-tool.ts index e3208605c..e6d6e8d65 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/base-tool.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/base-tool.ts @@ -9,6 +9,36 @@ export interface ServerToolExecutionContext { signal?: AbortSignal } +export type WorkspaceArg = { + workspaceId?: unknown +} + +function normalizeWorkspaceArg(args?: WorkspaceArg): string | undefined { + return typeof args?.workspaceId === 'string' && args.workspaceId.trim().length > 0 + ? args.workspaceId.trim() + : undefined +} + +export function withWorkspaceArgContext( + context: ServerToolExecutionContext | undefined, + args: TArgs | undefined +): ServerToolExecutionContext | undefined { + const argWorkspaceId = normalizeWorkspaceArg(args) + if (!argWorkspaceId) { + return context + } + + const contextWorkspaceId = context?.workspaceId?.trim() + if (contextWorkspaceId && contextWorkspaceId !== argWorkspaceId) { + throw new Error('workspaceId does not match execution context') + } + + return { + ...(context ?? ({} as ServerToolExecutionContext)), + workspaceId: argWorkspaceId, + } +} + export function throwIfServerToolAborted(context?: ServerToolExecutionContext): void { if (!context?.signal?.aborted) { return diff --git a/apps/tradinggoose/lib/copilot/tools/server/blocks/block-mermaid-catalog.ts b/apps/tradinggoose/lib/copilot/tools/server/blocks/block-mermaid-catalog.ts index 8b6e3eee5..5267945b0 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/blocks/block-mermaid-catalog.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/blocks/block-mermaid-catalog.ts @@ -271,7 +271,7 @@ function buildInputReferenceGrammar( summary: 'Copy the exact workflow variable tag, such as `variable.riskLimit`, and wrap it once as ``.', examples: ['', ''], - sourceTools: ['read_workflow_variables'], + sourceTools: ['read_workflow'], }, environmentVariables: { syntax: '{{ENV_VAR_NAME}}', diff --git a/apps/tradinggoose/lib/copilot/tools/server/blocks/get-blocks-metadata.test.ts b/apps/tradinggoose/lib/copilot/tools/server/blocks/get-blocks-metadata.test.ts index 36665ade4..988f05bb8 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/blocks/get-blocks-metadata.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/blocks/get-blocks-metadata.test.ts @@ -209,7 +209,7 @@ describe('getBlocksMetadataServerTool', () => { workflowVariables: expect.objectContaining({ syntax: '', summary: expect.stringContaining('Copy the exact workflow variable tag'), - sourceTools: ['read_workflow_variables'], + sourceTools: ['read_workflow'], }), environmentVariables: expect.objectContaining({ syntax: '{{ENV_VAR_NAME}}', diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts new file mode 100644 index 000000000..fd8fb7bbf --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts @@ -0,0 +1,158 @@ +import { nanoid } from 'nanoid' +import { ENTITY_KIND_CUSTOM_TOOL } from '@/lib/copilot/review-sessions/types' +import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' +import { listCustomTools, upsertCustomTools } from '@/lib/custom-tools/operations' +import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' +import { savedEntityRowToFields } from '@/lib/yjs/entity-state' +import { + acceptEntityDocumentReview, + buildCreateEntityReviewResult, + buildDocumentEnvelope, + buildUpdateEntityReviewResult, + type EntityCreateResult, + type EntityListEntry, + type EntityServerTool, + readSavedEntityYjsFields, + requireEntityId, + verifySavedEntityContext, + verifyWorkspaceContext, +} from './shared' + +function readFunctionSchemaField(row: Awaited>[number]) { + const functionSchema = + row.schema && typeof row.schema === 'object' && 'function' in row.schema + ? row.schema.function + : null + + if (!functionSchema || typeof functionSchema !== 'object') { + return {} + } + + return { + functionName: + 'name' in functionSchema && typeof functionSchema.name === 'string' + ? functionSchema.name + : undefined, + functionDescription: + 'description' in functionSchema && typeof functionSchema.description === 'string' + ? functionSchema.description + : undefined, + } +} + +function toCustomToolListEntry( + row: Awaited>[number] +): EntityListEntry { + const { functionName, functionDescription } = readFunctionSchemaField(row) + + return { + entityId: row.id, + entityName: row.title ?? functionName ?? '', + workspaceId: row.workspaceId, + entityTitle: row.title ?? '', + entityFunctionName: functionName, + entityDescription: functionDescription, + } +} + +async function createCustomToolEntity( + fields: Record, + context: Parameters[0] +): Promise { + const { userId, workspaceId } = await verifyWorkspaceContext(context, 'write') + const entityId = nanoid() + const rows = await upsertCustomTools({ + userId, + workspaceId, + tools: [ + { + id: entityId, + title: String(fields.title ?? ''), + schema: parseCustomToolSchemaText(fields.schemaText), + code: String(fields.codeText ?? ''), + }, + ], + }) + const row = rows.find((candidate) => candidate.id === entityId) + if (!row) { + throw new Error('Created custom tool was not returned from canonical upsert') + } + + return { + entityId, + fields: savedEntityRowToFields(ENTITY_KIND_CUSTOM_TOOL, row), + } +} + +export const listCustomToolsServerTool: EntityServerTool> = { + name: 'list_custom_tools', + async execute(args, context) { + const { workspaceId } = await verifyWorkspaceContext( + withWorkspaceArgContext(context, args), + 'read' + ) + const rows = await listCustomTools({ workspaceId }) + const entities = rows.map(toCustomToolListEntry) + + return { + entityKind: ENTITY_KIND_CUSTOM_TOOL, + entities, + count: entities.length, + } + }, +} + +export const readCustomToolServerTool: EntityServerTool = { + name: 'read_custom_tool', + async execute(args, context) { + const entityId = requireEntityId(args, 'read_custom_tool') + const { workspaceId } = await verifySavedEntityContext( + context, + ENTITY_KIND_CUSTOM_TOOL, + entityId, + 'read' + ) + const fields = await readSavedEntityYjsFields(ENTITY_KIND_CUSTOM_TOOL, entityId, workspaceId) + return buildDocumentEnvelope(ENTITY_KIND_CUSTOM_TOOL, entityId, fields) + }, +} + +export const createCustomToolServerTool: EntityServerTool = { + name: 'create_custom_tool', + execute(args, context) { + return buildCreateEntityReviewResult(ENTITY_KIND_CUSTOM_TOOL, args, context) + }, +} + +export const editCustomToolServerTool: EntityServerTool = { + name: 'edit_custom_tool', + execute(args, context) { + return buildUpdateEntityReviewResult(ENTITY_KIND_CUSTOM_TOOL, 'edit_custom_tool', args, context) + }, +} + +export const renameCustomToolServerTool: EntityServerTool = { + name: 'rename_custom_tool', + execute(args, context) { + return buildUpdateEntityReviewResult( + ENTITY_KIND_CUSTOM_TOOL, + 'rename_custom_tool', + args, + context + ) + }, +} + +export function acceptCustomToolDocumentReview( + toolName: string, + result: unknown, + context: Parameters[0]['context'] +) { + return acceptEntityDocumentReview({ + kind: ENTITY_KIND_CUSTOM_TOOL, + toolName, + result, + context, + create: createCustomToolEntity, + }) +} diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/index.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/index.ts new file mode 100644 index 000000000..a49579359 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/index.ts @@ -0,0 +1,42 @@ +export { + acceptCustomToolDocumentReview, + createCustomToolServerTool, + editCustomToolServerTool, + listCustomToolsServerTool, + readCustomToolServerTool, + renameCustomToolServerTool, +} from './custom-tool' +export { + acceptIndicatorDocumentReview, + createIndicatorServerTool, + editIndicatorServerTool, + listIndicatorsServerTool, + readIndicatorServerTool, + renameIndicatorServerTool, +} from './indicator' +export { + acceptMcpServerDocumentReview, + createMcpServerServerTool, + editMcpServerServerTool, + listMcpServersServerTool, + readMcpServerServerTool, + renameMcpServerServerTool, +} from './mcp-server' +export { + acceptSkillDocumentReview, + createSkillServerTool, + editSkillServerTool, + listSkillsServerTool, + readSkillServerTool, + renameSkillServerTool, +} from './skill' +export { + acceptWorkflowDocumentReview, + createWorkflowServerTool, + editWorkflowVariableServerTool, + editWorkflowBlockServerTool, + editWorkflowServerTool, + listWorkflowsServerTool, + readWorkflowServerTool, + renameWorkflowServerTool, +} from './workflow' diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts new file mode 100644 index 000000000..9d26f9e67 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts @@ -0,0 +1,185 @@ +import { db } from '@tradinggoose/db' +import { pineIndicators } from '@tradinggoose/db/schema' +import { desc, eq } from 'drizzle-orm' +import { ENTITY_KIND_INDICATOR } from '@/lib/copilot/review-sessions/types' +import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' +import { upsertIndicators } from '@/lib/indicators/custom/operations' +import { + DEFAULT_INDICATOR_RUNTIME_ENTRIES, + resolveDefaultIndicatorRuntimeEntry, +} from '@/lib/indicators/default/runtime' +import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' +import { + applySavedEntityYjsStateToRows, + savedEntityRowToFields, +} from '@/lib/yjs/entity-state' +import { + acceptEntityDocumentReview, + buildCreateEntityReviewResult, + buildDocumentEnvelope, + buildUpdateEntityReviewResult, + type CopilotIndicatorListEntry, + type EntityCreateResult, + type EntityServerTool, + readSavedEntityYjsFields, + requireEntityId, + requireUserId, + verifySavedEntityContext, + verifyWorkspaceContext, +} from './shared' + +function toDefaultIndicatorListEntry(entry: (typeof DEFAULT_INDICATOR_RUNTIME_ENTRIES)[number]) { + const inputTitles = Object.keys(entry.inputMeta ?? {}) + + return { + name: entry.name, + source: 'default' as const, + editable: false, + callableInFunctionBlock: true, + ...(inputTitles.length > 0 ? { inputTitles } : {}), + runtimeId: entry.id, + } +} + +function toCustomIndicatorListEntry( + row: Awaited>>[number] +): CopilotIndicatorListEntry { + const inputMeta = normalizeInputMetaMap(row.inputMeta) + const inputTitles = Object.keys(inputMeta ?? {}) + + return { + name: row.name, + source: 'custom', + editable: true, + callableInFunctionBlock: false, + ...(inputTitles.length > 0 ? { inputTitles } : {}), + entityId: row.id, + } +} + +async function listCopilotIndicators(workspaceId: string): Promise { + const defaultOptions = DEFAULT_INDICATOR_RUNTIME_ENTRIES.map(toDefaultIndicatorListEntry) + const customRows = await db + .select() + .from(pineIndicators) + .where(eq(pineIndicators.workspaceId, workspaceId)) + .orderBy(desc(pineIndicators.createdAt)) + .then((rows) => applySavedEntityYjsStateToRows(ENTITY_KIND_INDICATOR, rows)) + const customOptions = customRows.map(toCustomIndicatorListEntry) + + return [...defaultOptions, ...customOptions].sort((a, b) => a.name.localeCompare(b.name)) +} + +async function createIndicatorEntity( + fields: Record, + context: Parameters[0] +): Promise { + const { userId, workspaceId } = await verifyWorkspaceContext(context, 'write') + const entityId = crypto.randomUUID() + const rows = await upsertIndicators({ + userId, + workspaceId, + indicators: [ + { + id: entityId, + name: String(fields.name ?? ''), + pineCode: String(fields.pineCode ?? ''), + inputMeta: + fields.inputMeta && typeof fields.inputMeta === 'object' && !Array.isArray(fields.inputMeta) + ? (fields.inputMeta as Record) + : undefined, + }, + ], + }) + const row = rows.find((candidate) => candidate.id === entityId) + if (!row) { + throw new Error('Created indicator was not returned from canonical upsert') + } + + return { + entityId, + fields: savedEntityRowToFields(ENTITY_KIND_INDICATOR, row), + } +} + +export const listIndicatorsServerTool: EntityServerTool> = { + name: 'list_indicators', + async execute(args, context) { + const { workspaceId } = await verifyWorkspaceContext( + withWorkspaceArgContext(context, args), + 'read' + ) + const indicators = await listCopilotIndicators(workspaceId) + + return { + entityKind: ENTITY_KIND_INDICATOR, + indicators, + count: indicators.length, + } + }, +} + +export const readIndicatorServerTool: EntityServerTool = { + name: 'read_indicator', + async execute(args, context) { + const runtimeId = args.runtimeId?.trim() + if (runtimeId) { + requireUserId(context) + const indicator = resolveDefaultIndicatorRuntimeEntry(runtimeId) + if (!indicator) { + throw new Error(`Built-in indicator ${runtimeId} was not found`) + } + + return buildDocumentEnvelope(ENTITY_KIND_INDICATOR, undefined, { + name: indicator.name, + pineCode: indicator.pineCode, + inputMeta: indicator.inputMeta ?? null, + }) + } + + const entityId = requireEntityId(args, 'read_indicator') + const { workspaceId } = await verifySavedEntityContext( + context, + ENTITY_KIND_INDICATOR, + entityId, + 'read' + ) + const fields = await readSavedEntityYjsFields(ENTITY_KIND_INDICATOR, entityId, workspaceId) + return buildDocumentEnvelope(ENTITY_KIND_INDICATOR, entityId, fields) + }, +} + +export const createIndicatorServerTool: EntityServerTool = { + name: 'create_indicator', + execute(args, context) { + return buildCreateEntityReviewResult(ENTITY_KIND_INDICATOR, args, context) + }, +} + +export const editIndicatorServerTool: EntityServerTool = { + name: 'edit_indicator', + execute(args, context) { + return buildUpdateEntityReviewResult(ENTITY_KIND_INDICATOR, 'edit_indicator', args, context) + }, +} + +export const renameIndicatorServerTool: EntityServerTool = { + name: 'rename_indicator', + execute(args, context) { + return buildUpdateEntityReviewResult(ENTITY_KIND_INDICATOR, 'rename_indicator', args, context) + }, +} + +export function acceptIndicatorDocumentReview( + toolName: string, + result: unknown, + context: Parameters[0]['context'] +) { + return acceptEntityDocumentReview({ + kind: ENTITY_KIND_INDICATOR, + toolName, + result, + context, + create: createIndicatorEntity, + }) +} diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts new file mode 100644 index 000000000..0d5328a61 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -0,0 +1,268 @@ +import { db } from '@tradinggoose/db' +import { mcpServers } from '@tradinggoose/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { ENTITY_KIND_MCP_SERVER } from '@/lib/copilot/review-sessions/types' +import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' +import { mcpService } from '@/lib/mcp/service' +import type { McpTransport } from '@/lib/mcp/types' +import { validateMcpServerUrl } from '@/lib/mcp/url-validator' +import { + applySavedEntityYjsStateToRows, + savedEntityRowToFields, +} from '@/lib/yjs/entity-state' +import { + acceptEntityDocumentReview, + applySavedEntityDocument, + buildDocumentDiff, + buildDocumentEnvelope, + type EntityCreateResult, + type EntityListEntry, + type EntityServerTool, + parseEntityMutationDocument, + readSavedEntityYjsFields, + requireEntityId, + verifySavedEntityContext, + verifyWorkspaceContext, +} from './shared' + +function isMcpTransport(value: unknown): value is McpTransport { + return value === 'http' || value === 'sse' || value === 'streamable-http' +} + +function normalizeStringRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [ + key, + typeof item === 'string' ? item : String(item ?? ''), + ]) + ) +} + +function normalizeMcpServerFields(fields: Record): Record { + const transport = isMcpTransport(fields.transport) ? fields.transport : 'http' + const rawUrl = typeof fields.url === 'string' ? fields.url.trim() : '' + let normalizedUrl = rawUrl + + if (rawUrl) { + const validation = validateMcpServerUrl(rawUrl) + if (!validation.isValid) { + throw new Error(`Invalid MCP server URL: ${validation.error}`) + } + normalizedUrl = validation.normalizedUrl ?? rawUrl + } + + return { + name: typeof fields.name === 'string' ? fields.name : '', + description: typeof fields.description === 'string' ? fields.description : '', + transport, + url: normalizedUrl, + headers: normalizeStringRecord(fields.headers), + command: typeof fields.command === 'string' ? fields.command : '', + args: Array.isArray(fields.args) ? fields.args.map(String) : [], + env: normalizeStringRecord(fields.env), + timeout: typeof fields.timeout === 'number' ? fields.timeout : 30000, + retries: typeof fields.retries === 'number' ? fields.retries : 3, + enabled: typeof fields.enabled === 'boolean' ? fields.enabled : true, + } +} + +function toMcpServerListEntry(row: typeof mcpServers.$inferSelect): EntityListEntry { + return { + entityId: row.id, + entityName: row.name, + workspaceId: row.workspaceId, + entityTransport: typeof row.transport === 'string' ? row.transport : undefined, + entityUrl: typeof row.url === 'string' ? row.url : undefined, + entityEnabled: typeof row.enabled === 'boolean' ? row.enabled : undefined, + } +} + +async function createMcpServerEntity( + fields: Record, + context: Parameters[0] +): Promise { + const { userId, workspaceId } = await verifyWorkspaceContext(context, 'write') + const entityId = crypto.randomUUID() + const normalized = normalizeMcpServerFields(fields) + + const [row] = await db + .insert(mcpServers) + .values({ + id: entityId, + workspaceId, + createdBy: userId, + name: String(normalized.name ?? ''), + description: String(normalized.description ?? '') || null, + transport: normalized.transport as McpTransport, + url: String(normalized.url ?? '') || null, + headers: normalizeStringRecord(normalized.headers), + command: String(normalized.command ?? '') || null, + args: Array.isArray(normalized.args) ? normalized.args.map(String) : [], + env: normalizeStringRecord(normalized.env), + timeout: Number(normalized.timeout ?? 30000), + retries: Number(normalized.retries ?? 3), + enabled: normalized.enabled !== false, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning() + + if (!row) { + throw new Error('Created MCP server was not returned from canonical insert') + } + + const savedFields = savedEntityRowToFields(ENTITY_KIND_MCP_SERVER, row) + await applySavedEntityDocument(ENTITY_KIND_MCP_SERVER, row.id, savedFields) + mcpService.clearCache(workspaceId) + + return { + entityId, + fields: savedFields, + } +} + +async function applyMcpServerDocument(input: { + entityId: string + fields: Record + workspaceId: string +}) { + await applySavedEntityDocument( + ENTITY_KIND_MCP_SERVER, + input.entityId, + normalizeMcpServerFields(input.fields) + ) + mcpService.clearCache(input.workspaceId) +} + +async function buildCreateMcpServerReviewResult(args: any, context: any) { + if (args.entityId?.trim()) { + throw new Error('create_mcp_server does not accept entityId') + } + + const { workspaceId } = await verifyWorkspaceContext( + withWorkspaceArgContext(context, args), + 'write' + ) + const fields = normalizeMcpServerFields(parseEntityMutationDocument(ENTITY_KIND_MCP_SERVER, args)) + + return { + requiresReview: true, + success: true, + workspaceId, + ...buildDocumentEnvelope(ENTITY_KIND_MCP_SERVER, undefined, fields), + preview: { + documentDiff: { + before: '', + after: buildDocumentEnvelope(ENTITY_KIND_MCP_SERVER, undefined, fields).entityDocument, + }, + }, + } +} + +async function buildUpdateMcpServerReviewResult( + toolName: string, + args: any, + context: any +) { + const fields = normalizeMcpServerFields( + parseEntityMutationDocument(ENTITY_KIND_MCP_SERVER, args) + ) + const entityId = requireEntityId(args, toolName) + const { workspaceId } = await verifySavedEntityContext( + context, + ENTITY_KIND_MCP_SERVER, + entityId, + 'write' + ) + const currentFields = await readSavedEntityYjsFields( + ENTITY_KIND_MCP_SERVER, + entityId, + workspaceId + ) + + return { + requiresReview: true, + success: true, + ...buildDocumentEnvelope(ENTITY_KIND_MCP_SERVER, entityId, fields), + preview: { + documentDiff: buildDocumentDiff(ENTITY_KIND_MCP_SERVER, currentFields, fields), + }, + } +} + +export const listMcpServersServerTool: EntityServerTool> = { + name: 'list_mcp_servers', + async execute(args, context) { + const { workspaceId } = await verifyWorkspaceContext( + withWorkspaceArgContext(context, args), + 'read' + ) + const rows = await db + .select() + .from(mcpServers) + .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) + .then((serverRows) => applySavedEntityYjsStateToRows(ENTITY_KIND_MCP_SERVER, serverRows)) + const entities = rows.map(toMcpServerListEntry) + + return { + entityKind: ENTITY_KIND_MCP_SERVER, + entities, + count: entities.length, + } + }, +} + +export const readMcpServerServerTool: EntityServerTool = { + name: 'read_mcp_server', + async execute(args, context) { + const entityId = requireEntityId(args, 'read_mcp_server') + const { workspaceId } = await verifySavedEntityContext( + context, + ENTITY_KIND_MCP_SERVER, + entityId, + 'read' + ) + const fields = await readSavedEntityYjsFields(ENTITY_KIND_MCP_SERVER, entityId, workspaceId) + return buildDocumentEnvelope(ENTITY_KIND_MCP_SERVER, entityId, fields) + }, +} + +export const createMcpServerServerTool: EntityServerTool = { + name: 'create_mcp_server', + execute(args, context) { + return buildCreateMcpServerReviewResult(args, context) + }, +} + +export const editMcpServerServerTool: EntityServerTool = { + name: 'edit_mcp_server', + execute(args, context) { + return buildUpdateMcpServerReviewResult('edit_mcp_server', args, context) + }, +} + +export const renameMcpServerServerTool: EntityServerTool = { + name: 'rename_mcp_server', + execute(args, context) { + return buildUpdateMcpServerReviewResult('rename_mcp_server', args, context) + }, +} + +export function acceptMcpServerDocumentReview( + toolName: string, + result: unknown, + context: Parameters[0]['context'] +) { + return acceptEntityDocumentReview({ + kind: ENTITY_KIND_MCP_SERVER, + toolName, + result, + context, + create: createMcpServerEntity, + apply: applyMcpServerDocument, + }) +} diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts new file mode 100644 index 000000000..270bf422e --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -0,0 +1,319 @@ +import * as Y from 'yjs' +import { + type EntityDocumentKind, + getEntityDocumentFormat, + getEntityDocumentName, + parseEntityDocument, + serializeEntityDocument, +} from '@/lib/copilot/entity-documents' +import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' +import type { + BaseServerTool, + ServerToolExecutionContext, +} from '@/lib/copilot/tools/server/base-tool' +import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' +import { getEntityFields } from '@/lib/yjs/entity-session' +import type { SavedEntityKind } from '@/lib/yjs/entity-state' +import { buildSavedEntityYjsDescriptor } from '@/lib/yjs/entity-state' +import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' + +export type SavedEntityDocumentKind = EntityDocumentKind +export type EntityDocumentArgs = { + entityId?: string + runtimeId?: string + workspaceId?: string + entityDocument?: string + documentFormat?: string +} + +export type EntityListEntry = { + entityId: string + entityName?: string + workspaceId?: string + entityDescription?: string + entityTitle?: string + entityFunctionName?: string + entityTransport?: string + entityUrl?: string + entityEnabled?: boolean + entityConnectionStatus?: string +} + +export type CopilotIndicatorListEntry = { + name: string + source: 'default' | 'custom' + editable: boolean + callableInFunctionBlock: boolean + inputTitles?: string[] + entityId?: string + runtimeId?: string +} + +export type EntityCreateResult = { + entityId: string + fields: Record +} + +export type CreateEntityFromDocument = ( + fields: Record, + context: ServerToolExecutionContext | undefined +) => Promise + +export type ApplyEntityDocument = (input: { + entityId: string + fields: Record + workspaceId: string +}) => Promise + +export const ENTITY_KIND_LABELS: Record = { + skill: 'skill', + custom_tool: 'custom tool', + indicator: 'indicator', + knowledge_base: 'knowledge base', + mcp_server: 'MCP server', +} + +export function requireUserId(context?: ServerToolExecutionContext): string { + const userId = context?.userId?.trim() + if (!userId) { + throw new Error('Authenticated user is required to execute copilot entity tools') + } + return userId +} + +export function requireWorkspaceId(context?: ServerToolExecutionContext): string { + const workspaceId = context?.workspaceId?.trim() + if (!workspaceId) { + throw new Error( + 'No active workspace found in execution context. Ensure workspaceId is included in tool provenance.' + ) + } + return workspaceId +} + +export async function verifyWorkspaceContext( + context: ServerToolExecutionContext | undefined, + accessMode: 'read' | 'write' +): Promise<{ userId: string; workspaceId: string }> { + const userId = requireUserId(context) + const workspaceId = requireWorkspaceId(context) + const access = await checkWorkspaceAccess(workspaceId, userId) + + if (!access.exists || !access.hasAccess || (accessMode === 'write' && !access.canWrite)) { + throw new Error('Access denied: You do not have permission to use this workspace') + } + + return { userId, workspaceId } +} + +export async function verifySavedEntityContext( + context: ServerToolExecutionContext | undefined, + entityKind: SavedEntityDocumentKind, + entityId: string, + accessMode: 'read' | 'write' +): Promise<{ userId: string; workspaceId: string }> { + const userId = requireUserId(context) + const access = await verifyReviewTargetAccess( + userId, + { + workspaceId: null, + entityKind, + entityId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: entityId, + }, + accessMode + ) + + if (!access.hasAccess || !access.workspaceId) { + throw new Error( + `Access denied: You do not have permission to ${accessMode === 'write' ? 'edit' : 'read'} this ${ENTITY_KIND_LABELS[entityKind]}` + ) + } + + return { userId, workspaceId: access.workspaceId } +} + +export function requireEntityId(args: EntityDocumentArgs, toolName: string): string { + const entityId = args.entityId?.trim() + if (!entityId) { + throw new Error(`entityId is required for ${toolName}`) + } + return entityId +} + +export function parseEntityMutationDocument( + kind: SavedEntityDocumentKind, + args: EntityDocumentArgs +): Record { + const entityDocument = args.entityDocument?.trim() + if (!entityDocument) { + throw new Error('entityDocument is required') + } + + const expectedFormat = getEntityDocumentFormat(kind) + if (args.documentFormat && args.documentFormat !== expectedFormat) { + throw new Error(`Unsupported documentFormat "${args.documentFormat}". Expected ${expectedFormat}`) + } + + return parseEntityDocument(kind, entityDocument) +} + +export function buildDocumentEnvelope( + kind: SavedEntityDocumentKind, + entityId: string | undefined, + fields: Record +) { + return { + entityKind: kind, + ...(entityId ? { entityId } : {}), + entityName: getEntityDocumentName(kind, fields), + documentFormat: getEntityDocumentFormat(kind), + entityDocument: serializeEntityDocument(kind, fields), + } +} + +export function buildDocumentDiff( + kind: SavedEntityDocumentKind, + before: Record, + after: Record +) { + return { + before: serializeEntityDocument(kind, before), + after: serializeEntityDocument(kind, after), + } +} + +export async function readSavedEntityYjsFields( + kind: SavedEntityDocumentKind, + entityId: string, + workspaceId: string +): Promise> { + const descriptor = buildSavedEntityYjsDescriptor(kind as SavedEntityKind, entityId, workspaceId) + const snapshot = await readBootstrappedReviewTargetSnapshot(descriptor) + + if (!snapshot.snapshotBase64) { + throw new Error(`Current Yjs ${ENTITY_KIND_LABELS[kind]} state is required for ${entityId}`) + } + + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) + return getEntityFields(doc, kind as SavedEntityKind) + } finally { + doc.destroy() + } +} + +export async function applySavedEntityDocument( + kind: SavedEntityDocumentKind, + entityId: string, + fields: Record +): Promise { + await applySavedEntityState(kind as SavedEntityKind, entityId, fields) +} + +export async function buildCreateEntityReviewResult( + kind: SavedEntityDocumentKind, + args: EntityDocumentArgs, + context: ServerToolExecutionContext | undefined +) { + if (args.entityId?.trim()) { + throw new Error(`create_${kind} does not accept entityId`) + } + + const scopedContext = withWorkspaceArgContext(context, args) + const { workspaceId } = await verifyWorkspaceContext(scopedContext, 'write') + const fields = parseEntityMutationDocument(kind, args) + + return { + requiresReview: true, + success: true, + workspaceId, + ...buildDocumentEnvelope(kind, undefined, fields), + preview: { + documentDiff: { + before: '', + after: serializeEntityDocument(kind, fields), + }, + }, + } +} + +export async function buildUpdateEntityReviewResult( + kind: SavedEntityDocumentKind, + toolName: string, + args: EntityDocumentArgs, + context: ServerToolExecutionContext | undefined +) { + const fields = parseEntityMutationDocument(kind, args) + const entityId = requireEntityId(args, toolName) + const { workspaceId } = await verifySavedEntityContext(context, kind, entityId, 'write') + const currentFields = await readSavedEntityYjsFields(kind, entityId, workspaceId) + + return { + requiresReview: true, + success: true, + ...buildDocumentEnvelope(kind, entityId, fields), + preview: { + documentDiff: buildDocumentDiff(kind, currentFields, fields), + }, + } +} + +export async function acceptEntityDocumentReview(input: { + kind: SavedEntityDocumentKind + toolName: string + result: unknown + context: ServerToolExecutionContext | undefined + create: CreateEntityFromDocument + apply?: ApplyEntityDocument +}) { + const { kind, toolName, result, context, create, apply } = input + if (!result || typeof result !== 'object') { + throw new Error(`Missing review result for ${toolName}`) + } + + const reviewResult = result as EntityDocumentArgs & { + entityKind?: string + entityName?: string + preview?: unknown + success?: boolean + } + + if (reviewResult.entityKind !== kind) { + throw new Error(`Review result entityKind must be ${kind}`) + } + + const fields = parseEntityMutationDocument(kind, reviewResult) + + if (toolName.startsWith('create_')) { + const created = await create(fields, withWorkspaceArgContext(context, reviewResult)) + return { + ...reviewResult, + requiresReview: true, + success: true, + ...buildDocumentEnvelope(kind, created.entityId, created.fields), + } + } + + const entityId = requireEntityId(reviewResult, toolName) + const { workspaceId } = await verifySavedEntityContext(context, kind, entityId, 'write') + if (apply) { + await apply({ entityId, fields, workspaceId }) + } else { + await applySavedEntityDocument(kind, entityId, fields) + } + + return { + ...reviewResult, + requiresReview: true, + success: true, + ...buildDocumentEnvelope(kind, entityId, fields), + } +} + +export type EntityServerTool = BaseServerTool diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts new file mode 100644 index 000000000..ddd27adc8 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts @@ -0,0 +1,125 @@ +import { nanoid } from 'nanoid' +import { ENTITY_KIND_SKILL } from '@/lib/copilot/review-sessions/types' +import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' +import { listSkills, upsertSkills } from '@/lib/skills/operations' +import { savedEntityRowToFields } from '@/lib/yjs/entity-state' +import { + acceptEntityDocumentReview, + buildCreateEntityReviewResult, + buildDocumentEnvelope, + buildUpdateEntityReviewResult, + type EntityCreateResult, + type EntityDocumentArgs, + type EntityListEntry, + type EntityServerTool, + readSavedEntityYjsFields, + requireEntityId, + verifySavedEntityContext, + verifyWorkspaceContext, +} from './shared' + +function toSkillListEntry(row: Awaited>[number]): EntityListEntry { + return { + entityId: row.id, + entityName: row.name, + workspaceId: row.workspaceId, + entityDescription: row.description ?? '', + } +} + +async function createSkillEntity( + fields: Record, + context: Parameters[0] +): Promise { + const { userId, workspaceId } = await verifyWorkspaceContext(context, 'write') + const entityId = nanoid() + const rows = await upsertSkills({ + userId, + workspaceId, + skills: [ + { + id: entityId, + name: String(fields.name ?? ''), + description: String(fields.description ?? ''), + content: String(fields.content ?? ''), + }, + ], + }) + const row = rows.find((candidate) => candidate.id === entityId) + if (!row) { + throw new Error('Created skill was not returned from canonical upsert') + } + + return { + entityId, + fields: savedEntityRowToFields(ENTITY_KIND_SKILL, row), + } +} + +export const listSkillsServerTool: EntityServerTool> = { + name: 'list_skills', + async execute(args, context) { + const { workspaceId } = await verifyWorkspaceContext( + withWorkspaceArgContext(context, args), + 'read' + ) + const rows = await listSkills({ workspaceId }) + const entities = rows.map(toSkillListEntry) + + return { + entityKind: ENTITY_KIND_SKILL, + entities, + count: entities.length, + } + }, +} + +export const readSkillServerTool: EntityServerTool = { + name: 'read_skill', + async execute(args, context) { + const entityId = requireEntityId(args, 'read_skill') + const { workspaceId } = await verifySavedEntityContext( + context, + ENTITY_KIND_SKILL, + entityId, + 'read' + ) + const fields = await readSavedEntityYjsFields(ENTITY_KIND_SKILL, entityId, workspaceId) + return buildDocumentEnvelope(ENTITY_KIND_SKILL, entityId, fields) + }, +} + +export const createSkillServerTool: EntityServerTool = { + name: 'create_skill', + execute(args, context) { + return buildCreateEntityReviewResult(ENTITY_KIND_SKILL, args, context) + }, +} + +export const editSkillServerTool: EntityServerTool = { + name: 'edit_skill', + execute(args, context) { + return buildUpdateEntityReviewResult(ENTITY_KIND_SKILL, 'edit_skill', args, context) + }, +} + +export const renameSkillServerTool: EntityServerTool = { + name: 'rename_skill', + execute(args, context) { + return buildUpdateEntityReviewResult(ENTITY_KIND_SKILL, 'rename_skill', args, context) + }, +} + +export function acceptSkillDocumentReview( + toolName: string, + result: unknown, + context: Parameters[0]['context'] +) { + return acceptEntityDocumentReview({ + kind: ENTITY_KIND_SKILL, + toolName, + result, + context, + create: createSkillEntity, + }) +} diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts new file mode 100644 index 000000000..680fe5adb --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' +import { WORKFLOW_VARIABLE_DOCUMENT_FORMAT } from '@/lib/copilot/entity-documents' +import { + acceptWorkflowDocumentReview, + editWorkflowVariableServerTool, + readWorkflowServerTool, +} from '@/lib/copilot/tools/server/entities/workflow' +import { + createWorkflowSnapshot, + setVariables, + setWorkflowState, +} from '@/lib/yjs/workflow-session' + +const mockDbLimit = vi.hoisted(() => vi.fn()) +const mockReadBootstrappedReviewTargetSnapshot = vi.hoisted(() => vi.fn()) +const mockVerifyWorkflowAccess = vi.hoisted(() => vi.fn()) +const mockApplyWorkflowStateInSocketServer = vi.hoisted(() => vi.fn()) + +vi.mock('@tradinggoose/db', () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: mockDbLimit, + })), + })), + })), + }, +})) + +vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ + verifyWorkflowAccess: (...args: any[]) => mockVerifyWorkflowAccess(...args), +})) + +vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ + readBootstrappedReviewTargetSnapshot: (...args: any[]) => + mockReadBootstrappedReviewTargetSnapshot(...args), +})) + +vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ + applyWorkflowStateInSocketServer: (...args: any[]) => + mockApplyWorkflowStateInSocketServer(...args), +})) + +function workflowSnapshotBase64(variables: Record): string { + const doc = new Y.Doc() + setWorkflowState(doc, createWorkflowSnapshot(), 'test') + setVariables(doc, variables, 'test') + const encoded = Buffer.from(Y.encodeStateAsUpdate(doc)).toString('base64') + doc.destroy() + return encoded +} + +describe('workflow variable server tools', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() + mockDbLimit.mockReset() + mockReadBootstrappedReviewTargetSnapshot.mockReset() + mockVerifyWorkflowAccess.mockReset() + mockApplyWorkflowStateInSocketServer.mockReset() + mockDbLimit.mockResolvedValue([ + { + id: 'wf-1', + name: 'Strategy Workflow', + workspaceId: 'workspace-1', + }, + ]) + mockVerifyWorkflowAccess.mockResolvedValue({ + hasAccess: true, + workspaceId: 'workspace-1', + }) + mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue({ + snapshotBase64: workflowSnapshotBase64({ + 'var-1': { + id: 'var-1', + workflowId: 'wf-1', + name: 'riskLimit', + type: 'number', + value: 10, + }, + }), + }) + vi.stubGlobal('crypto', { + randomUUID: vi.fn(() => 'var-2'), + }) + }) + + it('returns workflow variables through read_workflow', async () => { + const result = await readWorkflowServerTool.execute( + { entityId: 'wf-1' }, + { userId: 'user-1' } + ) + + expect(result.workflowVariableDocumentFormat).toBe(WORKFLOW_VARIABLE_DOCUMENT_FORMAT) + expect(JSON.parse(result.workflowVariableDocument)).toEqual({ + variables: [{ name: 'riskLimit', type: 'number', value: 10 }], + }) + }) + + it('prepares a document-diff review while preserving existing variable ids by name', async () => { + const result = await editWorkflowVariableServerTool.execute( + { + entityId: 'wf-1', + documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, + entityDocument: JSON.stringify({ + variables: [ + { name: 'riskLimit', type: 'number', value: 25 }, + { name: 'enabled', type: 'boolean', value: true }, + ], + }), + }, + { userId: 'user-1' } + ) + + expect(result).toMatchObject({ + requiresReview: true, + success: true, + entityKind: 'workflow', + entityId: 'wf-1', + workspaceId: 'workspace-1', + documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, + variables: { + 'var-1': { + id: 'var-1', + workflowId: 'wf-1', + name: 'riskLimit', + type: 'number', + value: 25, + }, + 'var-2': { + id: 'var-2', + workflowId: 'wf-1', + name: 'enabled', + type: 'boolean', + value: true, + }, + }, + }) + expect(result.preview.documentDiff.before).toContain('riskLimit') + expect(result.preview.documentDiff.after).toContain('enabled') + }) + + it('applies accepted workflow variable reviews through the workflow Yjs bridge', async () => { + const result = await editWorkflowVariableServerTool.execute( + { + entityId: 'wf-1', + documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, + entityDocument: JSON.stringify({ + variables: [{ name: 'riskLimit', type: 'number', value: 25 }], + }), + }, + { userId: 'user-1' } + ) + + await acceptWorkflowDocumentReview('edit_workflow_variable', result, { userId: 'user-1' }) + + expect(mockApplyWorkflowStateInSocketServer).toHaveBeenCalledWith( + 'wf-1', + expect.objectContaining({ + blocks: {}, + edges: [], + }), + result.variables + ) + }) +}) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts new file mode 100644 index 000000000..7daac105d --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -0,0 +1,617 @@ +import { db } from '@tradinggoose/db' +import { workflow } from '@tradinggoose/db/schema' +import { eq } from 'drizzle-orm' +import * as Y from 'yjs' +import { z } from 'zod' +import { getStableVibrantColor } from '@/lib/colors' +import { WORKFLOW_VARIABLE_DOCUMENT_FORMAT } from '@/lib/copilot/entity-documents' +import { verifyWorkflowAccess } from '@/lib/copilot/review-sessions/permissions' +import { + ENTITY_KIND_WORKFLOW, + type ReviewAccessMode, +} from '@/lib/copilot/review-sessions/types' +import type { + BaseServerTool, + ServerToolExecutionContext, +} from '@/lib/copilot/tools/server/base-tool' +import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' +import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target' +import { generateCreativeWorkflowName } from '@/lib/naming' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' +import { VariableManager } from '@/lib/variables/variable-manager' +import { + TG_MERMAID_DOCUMENT_FORMAT, + WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT, +} from '@/lib/workflows/document-format' +import { + readWorkflowContainerBoundaryEdgeViolation, + readWorkflowEdgeScope, + serializeWorkflowToTgMermaid, +} from '@/lib/workflows/studio-workflow-mermaid' +import { applyWorkflowStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' +import { + getVariablesSnapshot, + createWorkflowSnapshot, + readWorkflowSnapshot, + type WorkflowSnapshot, +} from '@/lib/yjs/workflow-session' +import { + isWorkflowVariableType, + type WorkflowVariableType, +} from '@/lib/workflows/value-types' +import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow' +import { editWorkflowBlockServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow-block' + +type WorkflowSummary = { + blocks: Array<{ + blockId: string + blockType: string + blockName: string + enabled?: boolean + parentId?: string + subBlockIds: string[] + connections: { + externalIn: number + externalOut: number + internalIn: number + internalOut: number + } + }> + edges: Array<{ + source: string + target: string + sourceHandle?: string + targetHandle?: string + scope: 'external' | 'internal' + }> + connectionIssues: Array<{ + edgeIndex: number + source: string + target: string + sourceHandle?: string + targetHandle?: string + message: string + }> +} + +type WorkflowVariableDocumentEntry = { + name: string + type: WorkflowVariableType + value?: unknown +} + +const WorkflowVariableDocumentSchema = z + .object({ + variables: z.array( + z.object({ + name: z.string().trim().min(1), + type: z.string().trim().min(1), + value: z.unknown().optional(), + }) + ), + }) + .strict() + +function requireUserId(context?: ServerToolExecutionContext): string { + const userId = context?.userId?.trim() + if (!userId) { + throw new Error('Authenticated user is required to execute copilot workflow tools') + } + return userId +} + +function requireWorkspaceId(context?: ServerToolExecutionContext): string { + const workspaceId = context?.workspaceId?.trim() + if (!workspaceId) { + throw new Error( + 'No active workspace found in execution context. Ensure workspaceId is included in tool provenance.' + ) + } + return workspaceId +} + +function normalizeWorkflowName(value?: string | null): string | undefined { + const normalized = value?.trim() + return normalized ? normalized : undefined +} + +function buildWorkflowDocumentEnvelope(input: { + workflowId: string + entityName?: string | null + workspaceId?: string | null + entityDocument: string + documentFormat?: string +}) { + const entityName = normalizeWorkflowName(input.entityName) + + return { + entityKind: ENTITY_KIND_WORKFLOW, + entityId: input.workflowId, + ...(entityName ? { entityName } : {}), + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), + entityDocument: input.entityDocument, + documentFormat: input.documentFormat ?? TG_MERMAID_DOCUMENT_FORMAT, + } +} + +function buildWorkflowSummary(workflowState: WorkflowSnapshot): WorkflowSummary { + const edges: WorkflowSummary['edges'] = (workflowState.edges ?? []).map((edge) => ({ + source: edge.source, + target: edge.target, + ...(typeof edge.sourceHandle === 'string' ? { sourceHandle: edge.sourceHandle } : {}), + ...(typeof edge.targetHandle === 'string' ? { targetHandle: edge.targetHandle } : {}), + scope: readWorkflowEdgeScope(edge, workflowState.blocks ?? {}), + })) + const blockIds = Object.keys(workflowState.blocks ?? {}).sort() + const connectionsByBlock = Object.fromEntries( + blockIds.map((blockId) => [ + blockId, + { externalIn: 0, externalOut: 0, internalIn: 0, internalOut: 0 }, + ]) + ) + + edges.forEach((edge) => { + const prefix = edge.scope === 'internal' ? 'internal' : 'external' + if (connectionsByBlock[edge.source]) { + connectionsByBlock[edge.source][`${prefix}Out`] += 1 + } + if (connectionsByBlock[edge.target]) { + connectionsByBlock[edge.target][`${prefix}In`] += 1 + } + }) + + return { + blocks: blockIds.map((blockId) => { + const block = workflowState.blocks[blockId] + + return { + blockId, + blockType: block.type, + blockName: normalizeWorkflowName(typeof block.name === 'string' ? block.name : undefined) ?? blockId, + ...(typeof block.enabled === 'boolean' ? { enabled: block.enabled } : {}), + ...(typeof block.data?.parentId === 'string' ? { parentId: block.data.parentId } : {}), + subBlockIds: Object.keys(block.subBlocks ?? {}).sort(), + connections: connectionsByBlock[blockId], + } + }), + edges, + connectionIssues: edges.flatMap((edge, edgeIndex) => { + const message = readWorkflowContainerBoundaryEdgeViolation(edge, workflowState.blocks ?? {}) + const { scope: _scope, ...edgeWithoutScope } = edge + return message ? [{ edgeIndex, ...edgeWithoutScope, message }] : [] + }), + } +} + +async function verifyWorkspaceContext( + context: ServerToolExecutionContext | undefined, + accessMode: 'read' | 'write' +): Promise<{ userId: string; workspaceId: string }> { + const userId = requireUserId(context) + const workspaceId = requireWorkspaceId(context) + const access = await checkWorkspaceAccess(workspaceId, userId) + + if (!access.exists || !access.hasAccess || (accessMode === 'write' && !access.canWrite)) { + throw new Error('Access denied: You do not have permission to use this workspace') + } + + return { userId, workspaceId } +} + +async function verifyWorkflowContext( + workflowId: string, + context: ServerToolExecutionContext | undefined, + accessMode: ReviewAccessMode +) { + const userId = requireUserId(context) + const access = await verifyWorkflowAccess(userId, workflowId, accessMode) + if (!access.hasAccess) { + throw new Error( + `Access denied: You do not have permission to ${accessMode === 'write' ? 'edit' : 'read'} this workflow` + ) + } + + return { userId, workspaceId: access.workspaceId } +} + +export async function loadWorkflowSnapshotForCopilot( + workflowId: string, + context: ServerToolExecutionContext | undefined, + accessMode: ReviewAccessMode +): Promise<{ + workflowId: string + entityName?: string + workspaceId: string | null + workflowState: WorkflowSnapshot + variables: Record +}> { + const { workspaceId } = await verifyWorkflowContext(workflowId, context, accessMode) + const [workflowRow] = await db + .select({ + id: workflow.id, + name: workflow.name, + workspaceId: workflow.workspaceId, + }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (!workflowRow) { + throw new Error('Workflow not found') + } + + const snapshot = await readBootstrappedReviewTargetSnapshot({ + workspaceId: workspaceId ?? workflowRow.workspaceId, + entityKind: ENTITY_KIND_WORKFLOW, + entityId: workflowId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: workflowId, + }) + + if (!snapshot.snapshotBase64) { + throw new Error(`Current Yjs workflow state is required for ${workflowId}`) + } + + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) + return { + workflowId, + entityName: workflowRow.name ?? undefined, + workspaceId: workflowRow.workspaceId ?? null, + workflowState: readWorkflowSnapshot(doc), + variables: getVariablesSnapshot(doc), + } + } finally { + doc.destroy() + } +} + +function buildVariablesByName(variables: Record): Record { + const byName: Record = {} + Object.values(variables).forEach((variable: any) => { + if ( + variable && + typeof variable === 'object' && + typeof variable.id === 'string' && + typeof variable.name === 'string' + ) { + byName[variable.name] = variable + } + }) + return byName +} + +function serializeWorkflowVariableDocument(variables: Record): string { + const entries = Object.values(variables) + .filter((variable: any) => variable && typeof variable === 'object') + .map((variable: any) => ({ + name: String(variable.name ?? ''), + type: isWorkflowVariableType(variable.type) ? variable.type : 'plain', + value: variable.value ?? '', + })) + .filter((variable) => variable.name.trim().length > 0) + .sort((left, right) => left.name.localeCompare(right.name)) + + return JSON.stringify({ variables: entries }, null, 2) +} + +function parseWorkflowVariableDocument(entityDocument: string): WorkflowVariableDocumentEntry[] { + const parsed = WorkflowVariableDocumentSchema.parse(JSON.parse(entityDocument)) + const seenNames = new Set() + + return parsed.variables.map((variable) => { + const name = variable.name.trim() + if (seenNames.has(name)) { + throw new Error(`Duplicate workflow variable name: ${name}`) + } + seenNames.add(name) + + if (!isWorkflowVariableType(variable.type)) { + throw new Error(`Unsupported workflow variable type: ${variable.type}`) + } + + return { + name, + type: variable.type, + value: variable.value, + } + }) +} + +function normalizeWorkflowVariableValue(value: unknown, type: WorkflowVariableType): unknown { + if (value === undefined) { + return '' + } + if (typeof value === 'string') { + return VariableManager.parseInputForStorage(value, type) + } + if (type === 'plain') { + return String(value ?? '') + } + return VariableManager.parseInputForStorage(JSON.stringify(value), type) +} + +function buildWorkflowVariablesFromDocument(input: { + workflowId: string + currentVariables: Record + entityDocument: string +}): Record { + const existingByName = buildVariablesByName(input.currentVariables) + const entries = parseWorkflowVariableDocument(input.entityDocument) + + return Object.fromEntries( + entries.map((entry) => { + const existing = existingByName[entry.name] + const id = typeof existing?.id === 'string' ? existing.id : crypto.randomUUID() + return [ + id, + { + id, + workflowId: input.workflowId, + name: entry.name, + type: entry.type, + value: normalizeWorkflowVariableValue(entry.value, entry.type), + }, + ] + }) + ) +} + +export const listWorkflowsServerTool: BaseServerTool<{ workspaceId?: string }, any> = { + name: 'list_workflows', + async execute(args, context) { + const { workspaceId } = await verifyWorkspaceContext( + withWorkspaceArgContext(context, args), + 'read' + ) + const rows = await db + .select({ + id: workflow.id, + name: workflow.name, + workspaceId: workflow.workspaceId, + description: workflow.description, + }) + .from(workflow) + .where(eq(workflow.workspaceId, workspaceId)) + + return { + entityKind: ENTITY_KIND_WORKFLOW, + entities: rows.map((row) => ({ + entityId: row.id, + ...(row.name ? { entityName: row.name } : {}), + ...(row.workspaceId ? { workspaceId: row.workspaceId } : {}), + ...(row.description ? { entityDescription: row.description } : {}), + })), + count: rows.length, + } + }, +} + +export const readWorkflowServerTool: BaseServerTool<{ entityId: string }, any> = { + name: 'read_workflow', + async execute(args, context) { + const workflowId = requireCopilotEntityId(args, { toolName: 'read_workflow' }) + const { entityName, workspaceId, workflowState, variables } = await loadWorkflowSnapshotForCopilot( + workflowId, + context, + 'read' + ) + const entityDocument = serializeWorkflowToTgMermaid(workflowState) + + return { + ...buildWorkflowDocumentEnvelope({ + workflowId, + entityName, + workspaceId, + entityDocument, + }), + workflowSummary: buildWorkflowSummary(workflowState), + workflowVariableDocumentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, + workflowVariableDocument: serializeWorkflowVariableDocument(variables), + } + }, +} + +export const editWorkflowVariableServerTool: BaseServerTool< + { entityId: string; entityDocument: string; documentFormat?: string }, + any +> = { + name: 'edit_workflow_variable', + async execute(args, context) { + if (args.documentFormat && args.documentFormat !== WORKFLOW_VARIABLE_DOCUMENT_FORMAT) { + throw new Error( + `Unsupported documentFormat "${args.documentFormat}". Expected ${WORKFLOW_VARIABLE_DOCUMENT_FORMAT}` + ) + } + const workflowId = requireCopilotEntityId(args, { toolName: 'edit_workflow_variable' }) + const { workspaceId, variables } = await loadWorkflowSnapshotForCopilot( + workflowId, + context, + 'write' + ) + const nextVariables = buildWorkflowVariablesFromDocument({ + workflowId, + currentVariables: variables, + entityDocument: args.entityDocument, + }) + const currentDocument = serializeWorkflowVariableDocument(variables) + const nextDocument = serializeWorkflowVariableDocument(nextVariables) + + return { + requiresReview: true, + success: true, + entityKind: ENTITY_KIND_WORKFLOW, + entityId: workflowId, + ...(workspaceId ? { workspaceId } : {}), + documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, + entityDocument: nextDocument, + variables: nextVariables, + preview: { + documentDiff: { + before: currentDocument, + after: nextDocument, + }, + }, + } + }, +} + +export const createWorkflowServerTool: BaseServerTool< + { name?: string; description?: string; folderId?: string | null; workspaceId?: string }, + any +> = { + name: 'create_workflow', + async execute(args, context) { + const explicitWorkspaceId = args.workspaceId?.trim() + const contextWorkspaceId = context?.workspaceId?.trim() + const authenticatedUserId = requireUserId(context) + const { userId, workspaceId } = await verifyWorkspaceContext( + { + ...context, + userId: authenticatedUserId, + workspaceId: explicitWorkspaceId || contextWorkspaceId, + }, + 'write' + ) + const workflowId = crypto.randomUUID() + const now = new Date() + const name = args.name?.trim() || generateCreativeWorkflowName() + const description = typeof args.description === 'string' ? args.description : 'New workflow' + const color = getStableVibrantColor(workflowId) + const workflowState = createWorkflowSnapshot() + + await db.insert(workflow).values({ + id: workflowId, + userId, + workspaceId, + folderId: args.folderId || null, + name, + description, + color, + lastSynced: now, + createdAt: now, + updatedAt: now, + isDeployed: false, + collaborators: [], + runCount: 0, + variables: {}, + isPublished: false, + marketplaceData: null, + }) + + await applyWorkflowStateInSocketServer(workflowId, workflowState, {}, name) + + return { + success: true, + entityKind: ENTITY_KIND_WORKFLOW, + entityId: workflowId, + entityName: name, + workspaceId, + } + }, +} + +export const renameWorkflowServerTool: BaseServerTool<{ entityId: string; name: string }, any> = { + name: 'rename_workflow', + async execute(args, context) { + const workflowId = requireCopilotEntityId(args, { toolName: 'rename_workflow' }) + const nextName = args.name?.trim() + if (!nextName) { + throw new Error('name is required') + } + + await verifyWorkflowContext(workflowId, context, 'write') + const [updatedWorkflow] = await db + .update(workflow) + .set({ name: nextName, updatedAt: new Date() }) + .where(eq(workflow.id, workflowId)) + .returning() + + if (!updatedWorkflow) { + throw new Error('Workflow not found') + } + + return { + success: true, + entityKind: ENTITY_KIND_WORKFLOW, + entityId: workflowId, + entityName: nextName, + workspaceId: updatedWorkflow.workspaceId ?? undefined, + } + }, +} + +export { editWorkflowServerTool, editWorkflowBlockServerTool } + +export async function acceptWorkflowDocumentReview( + toolName: string, + result: unknown, + context: ServerToolExecutionContext | undefined +) { + if ( + toolName !== 'edit_workflow' && + toolName !== 'edit_workflow_block' && + toolName !== 'edit_workflow_variable' + ) { + throw new Error(`Unsupported workflow review tool: ${toolName}`) + } + if (!result || typeof result !== 'object') { + throw new Error(`Missing review result for ${toolName}`) + } + + const reviewResult = result as { + entityKind?: string + entityId?: string + workflowState?: unknown + variables?: unknown + entityDocument?: string + documentFormat?: string + } + if (reviewResult.entityKind !== ENTITY_KIND_WORKFLOW) { + throw new Error('Review result entityKind must be workflow') + } + const workflowId = reviewResult.entityId?.trim() + if (!workflowId) { + throw new Error(`entityId is required for ${toolName}`) + } + if (toolName === 'edit_workflow_variable') { + if (!reviewResult.variables || typeof reviewResult.variables !== 'object') { + throw new Error(`variables are required for ${toolName} review acceptance`) + } + const { workflowState } = await loadWorkflowSnapshotForCopilot(workflowId, context, 'write') + await applyWorkflowStateInSocketServer( + workflowId, + workflowState, + reviewResult.variables as Record + ) + + return { + ...reviewResult, + requiresReview: true, + success: true, + } + } + if (!reviewResult.workflowState || typeof reviewResult.workflowState !== 'object') { + throw new Error(`workflowState is required for ${toolName} review acceptance`) + } + + await verifyWorkflowContext(workflowId, context, 'write') + await applyWorkflowStateInSocketServer( + workflowId, + createWorkflowSnapshot(reviewResult.workflowState as Partial) + ) + + return { + ...reviewResult, + requiresReview: true, + success: true, + documentFormat: + reviewResult.documentFormat ?? + (toolName === 'edit_workflow' + ? WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT + : TG_MERMAID_DOCUMENT_FORMAT), + } +} diff --git a/apps/tradinggoose/lib/copilot/tools/server/gdrive/list-files.ts b/apps/tradinggoose/lib/copilot/tools/server/gdrive/list-files.ts index 1e611a6f6..ea054e0dd 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/gdrive/list-files.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/gdrive/list-files.ts @@ -2,18 +2,15 @@ import { type BaseServerTool, type ServerToolExecutionContext, throwIfServerToolAborted, + withWorkspaceArgContext, } from '@/lib/copilot/tools/server/base-tool' -import { - createWorkflowPermissionError, - resolveServerWorkspaceId, - resolveServerWorkflowScope, -} from '@/lib/copilot/tools/server/workflow/workflow-scope' import { getOAuthAccessTokenForUserCredential } from '@/lib/credentials/oauth' import { createLogger } from '@/lib/logs/console/logger' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { executeTool } from '@/tools' interface ListGDriveFilesParams { - entityId?: string + workspaceId?: string credentialId?: string search_query?: string num_results?: number @@ -23,18 +20,22 @@ export const listGDriveFilesServerTool: BaseServerTool { const logger = createLogger('ListGDriveFilesServerTool') + const scopedContext = withWorkspaceArgContext(context, params) const { credentialId, search_query, num_results } = params || {} - const uid = context?.userId + const uid = scopedContext?.userId if (!uid || typeof uid !== 'string' || uid.trim().length === 0 || !credentialId) { throw new Error('Authentication and credentialId are required') } - const workflowScope = await resolveServerWorkflowScope(params, context) - if (workflowScope && !workflowScope.hasAccess) { - throw new Error(createWorkflowPermissionError('access Google Drive files in')) + const workspaceId = scopedContext?.workspaceId + if (!workspaceId) { + throw new Error('workspaceId is required') + } + const workspaceAccess = await checkWorkspaceAccess(workspaceId, uid) + if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { + throw new Error('Access denied: You do not have permission to use this workspace') } - throwIfServerToolAborted(context) - const workspaceId = resolveServerWorkspaceId(context, workflowScope) + throwIfServerToolAborted(scopedContext) const query = search_query const pageSize = num_results @@ -60,9 +61,9 @@ export const listGDriveFilesServerTool: BaseServerTool name: 'read_gdrive_file', async execute(params: ReadGDriveFileParams, context?: ServerToolExecutionContext): Promise { const logger = createLogger('ReadGDriveFileServerTool') + const scopedContext = withWorkspaceArgContext(context, params) - const userId = context?.userId + const userId = scopedContext?.userId const credentialId = params?.credentialId const fileId = params?.fileId const type = params?.type - const workflowScope = await resolveServerWorkflowScope(params, context) logger.info('read_gdrive_file input', { hasUserId: !!userId, - workflowId: workflowScope?.workflowId, + workspaceId: scopedContext?.workspaceId, hasCredentialId: !!credentialId, hasFileId: !!fileId, type, @@ -43,11 +40,15 @@ export const readGDriveFileServerTool: BaseServerTool if (!userId || !credentialId || !fileId || !type) { throw new Error('Authentication, credentialId, fileId and type are required') } - if (workflowScope && !workflowScope.hasAccess) { - throw new Error(createWorkflowPermissionError('access Google Drive files in')) + const workspaceId = scopedContext?.workspaceId + if (!workspaceId) { + throw new Error('workspaceId is required') + } + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { + throw new Error('Access denied: You do not have permission to use this workspace') } - throwIfServerToolAborted(context) - const workspaceId = resolveServerWorkspaceId(context, workflowScope) + throwIfServerToolAborted(scopedContext) const accessToken = await getOAuthAccessTokenForUserCredential({ credentialId, @@ -67,9 +68,9 @@ export const readGDriveFileServerTool: BaseServerTool { accessToken, fileId }, false, undefined, - { signal: context?.signal } + { signal: scopedContext?.signal } ) - throwIfServerToolAborted(context) + throwIfServerToolAborted(scopedContext) if (!result.success) throw new Error(result.error || 'Failed to read Google Drive document') const output = (result as any).output || result const content = output?.output?.content ?? output?.content @@ -87,9 +88,9 @@ export const readGDriveFileServerTool: BaseServerTool }, false, undefined, - { signal: context?.signal } + { signal: scopedContext?.signal } ) - throwIfServerToolAborted(context) + throwIfServerToolAborted(scopedContext) if (!result.success) throw new Error(result.error || 'Failed to read Google Sheets data') const output = (result as any).output || result const rows: string[][] = output?.output?.data?.values || output?.data?.values || [] diff --git a/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts index 1ee18306e..4f7a7d811 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -1,250 +1,225 @@ +import { ENTITY_KIND_KNOWLEDGE_BASE } from '@/lib/copilot/review-sessions/types' import { type BaseServerTool, type ServerToolExecutionContext, - throwIfServerToolAborted, + withWorkspaceArgContext, } from '@/lib/copilot/tools/server/base-tool' -import type { KnowledgeBaseArgs, KnowledgeBaseResult } from '@/lib/copilot/tools/shared/schemas' import { generateSearchEmbedding } from '@/lib/embeddings/utils' import { createKnowledgeBase, getKnowledgeBaseById, getKnowledgeBases, } from '@/lib/knowledge/service' +import type { ChunkingConfig, KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { createLogger } from '@/lib/logs/console/logger' +import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { getQueryStrategy, handleVectorOnlySearch } from '@/app/api/knowledge/search/utils' +import { + acceptEntityDocumentReview, + buildCreateEntityReviewResult, + buildDocumentEnvelope, + buildUpdateEntityReviewResult, + type EntityCreateResult, + type EntityDocumentArgs, + type EntityServerTool, + readSavedEntityYjsFields, + requireEntityId, + verifySavedEntityContext, + verifyWorkspaceContext, +} from '../entities/shared' + +const logger = createLogger('KnowledgeBaseServerTools') + +function normalizeOptionalString(value: string | null | undefined): string | undefined { + const normalized = value?.trim() + return normalized ? normalized : undefined +} + +function toIsoString(value: Date | string | null | undefined): string | undefined { + if (!value) return undefined + if (typeof value === 'string') return value + return value.toISOString() +} + +function buildKnowledgeBaseDocumentEnvelope( + kb: KnowledgeBaseWithCounts, + fields: Record = savedEntityRowToFields(ENTITY_KIND_KNOWLEDGE_BASE, kb) +) { + return { + ...buildDocumentEnvelope(ENTITY_KIND_KNOWLEDGE_BASE, kb.id, fields), + workspaceId: kb.workspaceId, + docCount: kb.docCount, + tokenCount: kb.tokenCount, + embeddingModel: kb.embeddingModel, + embeddingDimension: kb.embeddingDimension, + createdAt: toIsoString(kb.createdAt), + updatedAt: toIsoString(kb.updatedAt), + } +} + +async function createKnowledgeBaseEntity( + fields: Record, + context: ServerToolExecutionContext | undefined +): Promise { + const { userId, workspaceId } = await verifyWorkspaceContext(context, 'write') + const created = await createKnowledgeBase( + { + name: String(fields.name ?? ''), + description: String(fields.description ?? ''), + workspaceId, + userId, + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, + chunkingConfig: fields.chunkingConfig as ChunkingConfig, + }, + crypto.randomUUID().slice(0, 8) + ) + + return { + entityId: created.id, + fields: savedEntityRowToFields(ENTITY_KIND_KNOWLEDGE_BASE, created), + } +} + +async function readAccessibleKnowledgeBase(entityId: string, context?: ServerToolExecutionContext) { + await verifySavedEntityContext(context, ENTITY_KIND_KNOWLEDGE_BASE, entityId, 'read') + const kb = await getKnowledgeBaseById(entityId) + if (!kb) { + throw new Error('Knowledge base not found') + } + return kb +} + +export const listKnowledgeBasesServerTool: BaseServerTool<{ workspaceId: string }> = { + name: 'list_knowledge_bases', + async execute(args, context) { + const scopedContext = withWorkspaceArgContext(context, args) + const { userId, workspaceId } = await verifyWorkspaceContext(scopedContext, 'read') + const knowledgeBases = await getKnowledgeBases(userId, workspaceId) + + return { + entityKind: ENTITY_KIND_KNOWLEDGE_BASE, + entities: knowledgeBases.map((kb) => ({ + entityId: kb.id, + entityName: kb.name, + workspaceId: kb.workspaceId, + ...(normalizeOptionalString(kb.description) ? { entityDescription: kb.description! } : {}), + docCount: kb.docCount, + tokenCount: kb.tokenCount, + embeddingModel: kb.embeddingModel, + createdAt: toIsoString(kb.createdAt), + updatedAt: toIsoString(kb.updatedAt), + })), + count: knowledgeBases.length, + } + }, +} -const logger = createLogger('KnowledgeBaseServerTool') - -/** - * Knowledge base tool for copilot to create, list, and get knowledge bases - */ -export const knowledgeBaseServerTool: BaseServerTool = { - name: 'knowledge_base', - async execute( - params: KnowledgeBaseArgs, - context?: ServerToolExecutionContext - ): Promise { - if (!context?.userId) { - logger.error('Unauthorized attempt to access knowledge base - no authenticated user context') - throw new Error('Authentication required') +export const readKnowledgeBaseServerTool: EntityServerTool = { + name: 'read_knowledge_base', + async execute(args, context) { + const entityId = requireEntityId(args, 'read_knowledge_base') + const { workspaceId } = await verifySavedEntityContext( + context, + ENTITY_KIND_KNOWLEDGE_BASE, + entityId, + 'read' + ) + const [kb, fields] = await Promise.all([ + getKnowledgeBaseById(entityId), + readSavedEntityYjsFields(ENTITY_KIND_KNOWLEDGE_BASE, entityId, workspaceId), + ]) + if (!kb) { + throw new Error('Knowledge base not found') } - const { operation, args = {} } = params - const workspaceId = args.workspaceId ?? context.workspaceId - throwIfServerToolAborted(context) - - try { - switch (operation) { - case 'create': { - if (!args.name) { - return { - success: false, - message: 'Name is required for creating a knowledge base', - } - } - if (!workspaceId) { - return { - success: false, - message: 'Workspace ID is required for creating a knowledge base', - } - } - - const requestId = crypto.randomUUID().slice(0, 8) - throwIfServerToolAborted(context) - const newKnowledgeBase = await createKnowledgeBase( - { - name: args.name, - description: args.description, - workspaceId, - userId: context.userId, - embeddingModel: 'text-embedding-3-small', - embeddingDimension: 1536, - chunkingConfig: args.chunkingConfig || { - maxSize: 1024, - minSize: 1, - overlap: 200, - }, - }, - requestId - ) - - logger.info('Knowledge base created via copilot', { - knowledgeBaseId: newKnowledgeBase.id, - name: newKnowledgeBase.name, - userId: context.userId, - }) - - return { - success: true, - message: `Knowledge base "${newKnowledgeBase.name}" created successfully`, - data: { - id: newKnowledgeBase.id, - name: newKnowledgeBase.name, - description: newKnowledgeBase.description, - workspaceId: newKnowledgeBase.workspaceId, - docCount: newKnowledgeBase.docCount, - createdAt: newKnowledgeBase.createdAt, - }, - } - } - - case 'list': { - if (!workspaceId) { - return { - success: false, - message: 'Workspace ID is required for listing knowledge bases', - } - } - - const knowledgeBases = await getKnowledgeBases(context.userId, workspaceId) - - logger.info('Knowledge bases listed via copilot', { - count: knowledgeBases.length, - userId: context.userId, - workspaceId, - }) - - return { - success: true, - message: `Found ${knowledgeBases.length} knowledge base(s)`, - data: knowledgeBases.map((kb) => ({ - id: kb.id, - name: kb.name, - description: kb.description, - workspaceId: kb.workspaceId, - docCount: kb.docCount, - tokenCount: kb.tokenCount, - createdAt: kb.createdAt, - updatedAt: kb.updatedAt, - })), - } - } - - case 'get': { - if (!args.knowledgeBaseId) { - return { - success: false, - message: 'Knowledge base ID is required for get operation', - } - } - - const knowledgeBase = await getKnowledgeBaseById(args.knowledgeBaseId) - if (!knowledgeBase) { - return { - success: false, - message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, - } - } - - logger.info('Knowledge base metadata retrieved via copilot', { - knowledgeBaseId: knowledgeBase.id, - userId: context.userId, - }) - - return { - success: true, - message: `Retrieved knowledge base "${knowledgeBase.name}"`, - data: { - id: knowledgeBase.id, - name: knowledgeBase.name, - description: knowledgeBase.description, - workspaceId: knowledgeBase.workspaceId, - docCount: knowledgeBase.docCount, - tokenCount: knowledgeBase.tokenCount, - embeddingModel: knowledgeBase.embeddingModel, - chunkingConfig: knowledgeBase.chunkingConfig, - createdAt: knowledgeBase.createdAt, - updatedAt: knowledgeBase.updatedAt, - }, - } - } - - case 'query': { - if (!args.knowledgeBaseId) { - return { - success: false, - message: 'Knowledge base ID is required for query operation', - } - } - - if (!args.query) { - return { - success: false, - message: 'Query text is required for query operation', - } - } - - // Verify knowledge base exists - const kb = await getKnowledgeBaseById(args.knowledgeBaseId) - if (!kb) { - return { - success: false, - message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, - } - } - - const topK = args.topK || 5 - - throwIfServerToolAborted(context) - const queryEmbedding = await generateSearchEmbedding(args.query) - throwIfServerToolAborted(context) - const queryVector = JSON.stringify(queryEmbedding) - - // Get search strategy - const strategy = getQueryStrategy(1, topK) - - // Perform vector search - const results = await handleVectorOnlySearch({ - knowledgeBaseIds: [args.knowledgeBaseId], - topK, - queryVector, - distanceThreshold: strategy.distanceThreshold, - }) - - logger.info('Knowledge base queried via copilot', { - knowledgeBaseId: args.knowledgeBaseId, - query: args.query.substring(0, 100), - resultCount: results.length, - userId: context.userId, - }) - - return { - success: true, - message: `Found ${results.length} result(s) for query "${args.query.substring(0, 50)}${args.query.length > 50 ? '...' : ''}"`, - data: { - knowledgeBaseId: args.knowledgeBaseId, - knowledgeBaseName: kb.name, - query: args.query, - topK, - totalResults: results.length, - results: results.map((result) => ({ - documentId: result.documentId, - content: result.content, - chunkIndex: result.chunkIndex, - similarity: 1 - result.distance, - })), - }, - } - } - - default: - return { - success: false, - message: `Unknown operation: ${operation}. Supported operations: create, list, get, query`, - } - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' - logger.error('Error in knowledge_base tool', { - operation, - error: errorMessage, - userId: context.userId, - }) - - return { - success: false, - message: `Failed to ${operation} knowledge base: ${errorMessage}`, - } + return buildKnowledgeBaseDocumentEnvelope(kb, fields) + }, +} + +export const createKnowledgeBaseServerTool: EntityServerTool = { + name: 'create_knowledge_base', + execute(args, context) { + return buildCreateEntityReviewResult(ENTITY_KIND_KNOWLEDGE_BASE, args, context) + }, +} + +export const editKnowledgeBaseServerTool: EntityServerTool = { + name: 'edit_knowledge_base', + execute(args, context) { + return buildUpdateEntityReviewResult( + ENTITY_KIND_KNOWLEDGE_BASE, + 'edit_knowledge_base', + args, + context + ) + }, +} + +export const renameKnowledgeBaseServerTool: EntityServerTool = { + name: 'rename_knowledge_base', + execute(args, context) { + return buildUpdateEntityReviewResult( + ENTITY_KIND_KNOWLEDGE_BASE, + 'rename_knowledge_base', + args, + context + ) + }, +} + +export const queryKnowledgeBaseServerTool: BaseServerTool<{ + entityId: string + query: string + topK?: number +}> = { + name: 'query_knowledge_base', + async execute(args, context) { + const kb = await readAccessibleKnowledgeBase(args.entityId, context) + const topK = args.topK || 5 + + const queryEmbedding = await generateSearchEmbedding(args.query, kb.embeddingModel) + const queryVector = JSON.stringify(queryEmbedding) + const strategy = getQueryStrategy(1, topK) + const results = await handleVectorOnlySearch({ + knowledgeBaseIds: [args.entityId], + topK, + queryVector, + distanceThreshold: strategy.distanceThreshold, + }) + + logger.info('Knowledge base queried via copilot', { + knowledgeBaseId: args.entityId, + resultCount: results.length, + }) + + return { + entityKind: ENTITY_KIND_KNOWLEDGE_BASE, + entityId: args.entityId, + entityName: kb.name, + query: args.query, + topK, + totalResults: results.length, + results: results.map((result) => ({ + documentId: result.documentId, + content: result.content, + chunkIndex: result.chunkIndex, + similarity: 1 - result.distance, + })), } }, } + +export function acceptKnowledgeBaseDocumentReview( + toolName: string, + result: unknown, + context: Parameters[0]['context'] +) { + return acceptEntityDocumentReview({ + kind: ENTITY_KIND_KNOWLEDGE_BASE, + toolName, + result, + context, + create: createKnowledgeBaseEntity, + }) +} diff --git a/apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts b/apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts new file mode 100644 index 000000000..e15fd68f9 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts @@ -0,0 +1,57 @@ +import { getMonitorRowById } from '@/app/api/monitors/shared' +import { updateMonitorForUser } from '@/app/api/monitors/update-service' +import { + MONITOR_DOCUMENT_FORMAT, + parseMonitorDocument, +} from '@/lib/copilot/monitor/monitor-documents' +import { + buildMonitorDocumentEnvelope, + type MonitorRecord, +} from '@/lib/copilot/tools/server/monitor/shared' +import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('EditMonitorServerTool') + +type EditMonitorArgs = { + monitorId: string + monitorDocument: string + documentFormat?: string +} + +export const editMonitorServerTool: BaseServerTool = { + name: 'edit_monitor', + async execute(args, context) { + const userId = context?.userId?.trim() + if (!userId) { + throw new Error('Authenticated user is required to edit monitors') + } + if (args.documentFormat && args.documentFormat !== MONITOR_DOCUMENT_FORMAT) { + throw new Error( + `Unsupported documentFormat "${args.documentFormat}". Expected ${MONITOR_DOCUMENT_FORMAT}` + ) + } + + const row = await getMonitorRowById(args.monitorId) + if (!row) { + throw new Error('Monitor not found') + } + if (!row.workflow.workspaceId) { + throw new Error('Monitor workspace is missing') + } + + const nextFields = parseMonitorDocument(args.monitorDocument) + const updatedMonitor = (await updateMonitorForUser({ + monitorId: args.monitorId, + userId, + body: { + ...nextFields, + workspaceId: row.workflow.workspaceId, + }, + requestId: crypto.randomUUID(), + logger, + })) as MonitorRecord + + return buildMonitorDocumentEnvelope(updatedMonitor, true) + }, +} diff --git a/apps/tradinggoose/lib/copilot/tools/server/monitor/list-monitors.ts b/apps/tradinggoose/lib/copilot/tools/server/monitor/list-monitors.ts new file mode 100644 index 000000000..6aa22354c --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/monitor/list-monitors.ts @@ -0,0 +1,42 @@ +import { listMonitorRows, toMonitorRecord } from '@/app/api/monitors/shared' +import { buildMonitorListEntry, type MonitorRecord } from '@/lib/copilot/tools/server/monitor/shared' +import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' + +type ListMonitorsArgs = { + workspaceId: string + entityId?: string + blockId?: string +} + +export const listMonitorsServerTool: BaseServerTool = { + name: 'list_monitors', + async execute(args, context) { + const executionContext = withWorkspaceArgContext(context, args) + const userId = executionContext?.userId?.trim() + const workspaceId = executionContext?.workspaceId?.trim() + if (!userId || !workspaceId) { + throw new Error('Authenticated user and workspaceId are required to list monitors') + } + + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.exists || !access.hasAccess) { + throw new Error('Access denied: You do not have permission to use this workspace') + } + + const rows = await listMonitorRows({ + workspaceId, + workflowId: args.entityId, + blockId: args.blockId, + }) + const monitors = (await Promise.all(rows.map((row) => toMonitorRecord(row.webhook)))) as MonitorRecord[] + const monitorEntries = monitors.map(buildMonitorListEntry) + + return { + surfaceKind: 'monitor' as const, + monitors: monitorEntries, + count: monitorEntries.length, + } + }, +} diff --git a/apps/tradinggoose/lib/copilot/tools/server/monitor/read-monitor.ts b/apps/tradinggoose/lib/copilot/tools/server/monitor/read-monitor.ts new file mode 100644 index 000000000..628a7d7bb --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/monitor/read-monitor.ts @@ -0,0 +1,38 @@ +import { getMonitorRowById, toMonitorRecord } from '@/app/api/monitors/shared' +import { + buildMonitorDocumentEnvelope, + type MonitorRecord, +} from '@/lib/copilot/tools/server/monitor/shared' +import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' + +type ReadMonitorArgs = { + monitorId: string +} + +export const readMonitorServerTool: BaseServerTool = { + name: 'read_monitor', + async execute(args, context) { + const userId = context?.userId?.trim() + if (!userId) { + throw new Error('Authenticated user is required to read monitors') + } + + const row = await getMonitorRowById(args.monitorId) + if (!row) { + throw new Error('Monitor not found') + } + const workspaceId = row.workflow.workspaceId + if (!workspaceId) { + throw new Error('Monitor workspace is missing') + } + + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.exists || !access.hasAccess) { + throw new Error('Access denied: You do not have permission to read this monitor') + } + + const monitor = (await toMonitorRecord(row.webhook)) as MonitorRecord + return buildMonitorDocumentEnvelope(monitor) + }, +} diff --git a/apps/tradinggoose/lib/copilot/tools/server/monitor/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/monitor/shared.ts new file mode 100644 index 000000000..65ecba1be --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/monitor/shared.ts @@ -0,0 +1,135 @@ +import { + MONITOR_DOCUMENT_FORMAT, + readMonitorDocumentName, + serializeMonitorDocument, +} from '@/lib/copilot/monitor/monitor-documents' +import { + INDICATOR_MONITOR_PROVIDER, + type MonitorWebhookProvider, + PORTFOLIO_MONITOR_PROVIDER, +} from '@/lib/monitors/sources' + +export type MonitorRecord = { + monitorId: string + source: MonitorWebhookProvider + workflowId: string + blockId: string + isActive: boolean + providerConfig: { + monitor: { + providerId: string + interval?: string + listing?: Record + indicatorId?: string + serviceId?: string + credentialId?: string + accountId?: string + condition?: unknown + fireMode?: 'edge' | 'while_true' + cooldownSeconds?: number + pollIntervalSeconds?: number + auth?: { + hasEncryptedSecrets?: boolean + encryptedSecretFieldIds?: string[] + } + providerParams?: Record + } + } + createdAt: string + updatedAt: string +} + +function getListingLabel(listing: Record | null | undefined): string { + if (!listing || typeof listing !== 'object') { + return 'listing' + } + + const name = typeof listing.name === 'string' ? listing.name.trim() : '' + if (name) return name + + const listingType = typeof listing.listing_type === 'string' ? listing.listing_type : '' + if (listingType === 'default') { + const listingId = typeof listing.listing_id === 'string' ? listing.listing_id.trim() : '' + return listingId || 'listing' + } + + const baseId = typeof listing.base_id === 'string' ? listing.base_id.trim() : '' + const quoteId = typeof listing.quote_id === 'string' ? listing.quote_id.trim() : '' + return baseId && quoteId ? `${baseId}/${quoteId}` : baseId || quoteId || 'listing' +} + +export function buildMonitorName(record: MonitorRecord): string { + if (record.source === PORTFOLIO_MONITOR_PROVIDER) { + return `Portfolio state (${record.providerConfig.monitor.accountId || 'account'})` + } + + const indicatorId = record.providerConfig.monitor.indicatorId || 'indicator' + const interval = record.providerConfig.monitor.interval || 'interval' + const listingLabel = getListingLabel(record.providerConfig.monitor.listing) + return `${indicatorId} on ${listingLabel} (${interval})` +} + +export function toMonitorDocumentFields(record: MonitorRecord) { + const monitor = record.providerConfig.monitor + if (record.source === PORTFOLIO_MONITOR_PROVIDER) { + return { + source: PORTFOLIO_MONITOR_PROVIDER, + workflowId: record.workflowId, + blockId: record.blockId, + providerId: monitor.providerId, + serviceId: monitor.serviceId, + credentialId: monitor.credentialId, + accountId: monitor.accountId, + condition: monitor.condition, + fireMode: monitor.fireMode, + cooldownSeconds: monitor.cooldownSeconds, + pollIntervalSeconds: monitor.pollIntervalSeconds, + isActive: record.isActive, + } + } + + return { + source: INDICATOR_MONITOR_PROVIDER, + workflowId: record.workflowId, + blockId: record.blockId, + providerId: monitor.providerId, + interval: monitor.interval, + indicatorId: monitor.indicatorId, + listing: monitor.listing, + isActive: record.isActive, + ...(monitor.providerParams ? { providerParams: monitor.providerParams } : {}), + } +} + +export function buildMonitorDocumentEnvelope(record: MonitorRecord, success?: boolean) { + const fields = toMonitorDocumentFields(record) + return { + ...(success === undefined ? {} : { success }), + surfaceKind: 'monitor' as const, + monitorId: record.monitorId, + monitorName: readMonitorDocumentName(fields), + documentFormat: MONITOR_DOCUMENT_FORMAT, + monitorDocument: serializeMonitorDocument(fields), + } +} + +export function buildMonitorListEntry(record: MonitorRecord) { + const monitor = record.providerConfig.monitor + return { + monitorId: record.monitorId, + monitorName: buildMonitorName(record), + monitorDescription: `Workflow ${record.workflowId}, block ${record.blockId}`, + workflowId: record.workflowId, + blockId: record.blockId, + source: record.source, + providerId: monitor.providerId, + ...(monitor.indicatorId ? { indicatorId: monitor.indicatorId } : {}), + ...(monitor.interval ? { interval: monitor.interval } : {}), + ...(monitor.serviceId ? { serviceId: monitor.serviceId } : {}), + ...(monitor.credentialId ? { credentialId: monitor.credentialId } : {}), + ...(monitor.accountId ? { accountId: monitor.accountId } : {}), + isActive: record.isActive, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } +} diff --git a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts new file mode 100644 index 000000000..69398e961 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts @@ -0,0 +1,50 @@ +import { isToolId, type ToolId, ToolResultSchemas } from '@/lib/copilot/registry' +import type { ServerToolExecutionContext } from '@/lib/copilot/tools/server/base-tool' +import { + acceptCustomToolDocumentReview, + acceptIndicatorDocumentReview, + acceptMcpServerDocumentReview, + acceptSkillDocumentReview, + acceptWorkflowDocumentReview, +} from '@/lib/copilot/tools/server/entities' +import { acceptKnowledgeBaseDocumentReview } from '@/lib/copilot/tools/server/knowledge/knowledge-base' + +export async function acceptServerManagedToolReview( + toolName: string, + reviewResult: unknown, + context?: ServerToolExecutionContext +) { + if (!isToolId(toolName)) { + throw new Error(`Unknown server tool review: ${toolName}`) + } + + const parsedResult = ToolResultSchemas[toolName].parse(reviewResult) + switch (toolName as ToolId) { + case 'edit_workflow': + case 'edit_workflow_block': + case 'edit_workflow_variable': + return acceptWorkflowDocumentReview(toolName, parsedResult, context) + case 'create_skill': + case 'edit_skill': + case 'rename_skill': + return acceptSkillDocumentReview(toolName, parsedResult, context) + case 'create_custom_tool': + case 'edit_custom_tool': + case 'rename_custom_tool': + return acceptCustomToolDocumentReview(toolName, parsedResult, context) + case 'create_indicator': + case 'edit_indicator': + case 'rename_indicator': + return acceptIndicatorDocumentReview(toolName, parsedResult, context) + case 'create_knowledge_base': + case 'edit_knowledge_base': + case 'rename_knowledge_base': + return acceptKnowledgeBaseDocumentReview(toolName, parsedResult, context) + case 'create_mcp_server': + case 'edit_mcp_server': + case 'rename_mcp_server': + return acceptMcpServerDocumentReview(toolName, parsedResult, context) + default: + throw new Error(`Server tool ${toolName} does not support review acceptance`) + } +} diff --git a/apps/tradinggoose/lib/copilot/tools/server/router.test.ts b/apps/tradinggoose/lib/copilot/tools/server/router.test.ts index 43ea96079..be390d221 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/router.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/router.test.ts @@ -1,4 +1,8 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + KNOWLEDGE_BASE_DOCUMENT_FORMAT, + WORKFLOW_VARIABLE_DOCUMENT_FORMAT, +} from '@/lib/copilot/entity-documents' import { TG_MERMAID_DOCUMENT_FORMAT, WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT, @@ -79,9 +83,29 @@ vi.mock('@/lib/copilot/tools/server/gdrive/read-file', () => ({ readGDriveFileServerTool: { name: 'read_gdrive_file', execute: readGDriveFileExecute }, })) vi.mock('@/lib/copilot/tools/server/knowledge/knowledge-base', () => ({ - knowledgeBaseServerTool: { - name: 'knowledge_base', - execute: vi.fn(async () => ({ results: [] })), + listKnowledgeBasesServerTool: { + name: 'list_knowledge_bases', + execute: vi.fn(async () => ({ entityKind: 'knowledge_base', entities: [], count: 0 })), + }, + readKnowledgeBaseServerTool: { + name: 'read_knowledge_base', + execute: vi.fn(), + }, + createKnowledgeBaseServerTool: { + name: 'create_knowledge_base', + execute: vi.fn(), + }, + editKnowledgeBaseServerTool: { + name: 'edit_knowledge_base', + execute: vi.fn(), + }, + renameKnowledgeBaseServerTool: { + name: 'rename_knowledge_base', + execute: vi.fn(), + }, + queryKnowledgeBaseServerTool: { + name: 'query_knowledge_base', + execute: vi.fn(), }, })) vi.mock('@/lib/copilot/tools/server/other/make-api-request', () => ({ @@ -173,12 +197,12 @@ describe('copilot contract registry', () => { it('exposes the agent accessory catalog contract', () => { const contract = getToolContract('get_agent_accessory_catalog') - expect(contract?.args.parse({})).toEqual({}) - expect(contract?.args.parse({ entityId: 'workflow-123' })).toEqual({ - entityId: 'workflow-123', + expect(contract?.args.parse({ workspaceId: 'workspace-123' })).toEqual({ + workspaceId: 'workspace-123', }) expect(contract?.result.parse(agentAccessoryCatalogResult)).toEqual(agentAccessoryCatalogResult) - expect(() => contract?.args.parse({ workspaceId: 'workspace-123' })).toThrow() + expect(() => contract?.args.parse({})).toThrow() + expect(() => contract?.args.parse({ entityId: 'workflow-123' })).toThrow() }) it('enforces workflow identity in workflow read/list results', () => { @@ -187,6 +211,8 @@ describe('copilot contract registry', () => { entityId: 'workflow-123', entityDocument: 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', documentFormat: TG_MERMAID_DOCUMENT_FORMAT, + workflowVariableDocumentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, + workflowVariableDocument: '{"variables":[]}', workflowSummary: { blocks: [], edges: [], @@ -211,6 +237,8 @@ describe('copilot contract registry', () => { entityId: 'workflow-123', entityDocument: 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', documentFormat: TG_MERMAID_DOCUMENT_FORMAT, + workflowVariableDocumentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, + workflowVariableDocument: '{"variables":[]}', workflowSummary: { blocks: [ { @@ -253,14 +281,87 @@ describe('copilot contract registry', () => { triggerBlockId: 'trigger-1', }) expect( - getToolContract('set_workflow_variables')?.args.parse({ + getToolContract('edit_workflow_variable')?.args.parse({ entityId: 'workflow-123', - operations: [], + entityDocument: '{"variables":[]}', + documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, }) ).toEqual({ entityId: 'workflow-123', - operations: [], + entityDocument: '{"variables":[]}', + documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, + }) + }) + + it('exposes knowledge base document contracts without the legacy operation wrapper', () => { + const entityDocument = + '{"name":"Research","description":"","chunkingConfig":{"maxSize":1024,"minSize":1,"overlap":200}}' + const mutationArgs = { + entityId: 'kb-123', + entityDocument, + documentFormat: KNOWLEDGE_BASE_DOCUMENT_FORMAT, + } + const envelope = { + entityKind: 'knowledge_base', + entityId: 'kb-123', + entityName: 'Research', + workspaceId: 'workspace-123', + documentFormat: KNOWLEDGE_BASE_DOCUMENT_FORMAT, + entityDocument, + docCount: 0, + tokenCount: 0, + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, + } + + expect( + getToolContract('list_knowledge_bases')?.args.parse({ workspaceId: 'workspace-123' }) + ).toEqual({ workspaceId: 'workspace-123' }) + expect( + getToolContract('create_knowledge_base')?.args.parse({ + workspaceId: 'workspace-123', + entityDocument, + documentFormat: KNOWLEDGE_BASE_DOCUMENT_FORMAT, + }) + ).toEqual({ + workspaceId: 'workspace-123', + entityDocument, + documentFormat: KNOWLEDGE_BASE_DOCUMENT_FORMAT, }) + expect(getToolContract('rename_knowledge_base')?.args.parse(mutationArgs)).toEqual(mutationArgs) + expect(() => + getToolContract('rename_knowledge_base')?.args.parse({ entityId: 'kb-123', name: 'Research' }) + ).toThrow() + expect(getToolContract('read_knowledge_base')?.result.parse(envelope)).toEqual(envelope) + expect( + getToolContract('edit_knowledge_base')?.result.parse({ + ...envelope, + requiresReview: true, + success: true, + preview: { + documentDiff: { + before: entityDocument, + after: entityDocument, + }, + }, + }) + ).toMatchObject({ + entityKind: 'knowledge_base', + entityId: 'kb-123', + requiresReview: true, + success: true, + }) + expect( + getToolContract('query_knowledge_base')?.result.parse({ + entityKind: 'knowledge_base', + entityId: 'kb-123', + entityName: 'Research', + query: 'risk', + topK: 5, + totalResults: 1, + results: [{ documentId: 'doc-1', content: 'risk note', chunkIndex: 0, similarity: 0.9 }], + }) + ).toMatchObject({ entityId: 'kb-123', totalResults: 1 }) }) }) @@ -311,8 +412,7 @@ describe('routeExecution', () => { it('routes agent accessory catalog requests through the central contract', async () => { const context = { userId: 'user-1', - contextEntityKind: 'workflow' as const, - contextEntityId: 'workflow-current', + workspaceId: 'workspace-1', } await expect(routeExecution('get_agent_accessory_catalog', {}, context)).resolves.toMatchObject( @@ -322,7 +422,10 @@ describe('routeExecution', () => { } ) - expect(getAgentAccessoryCatalogExecute).toHaveBeenCalledWith({}, context) + expect(getAgentAccessoryCatalogExecute).toHaveBeenCalledWith( + { workspaceId: 'workspace-1' }, + context + ) }) it('routes indicator metadata requests through the central contract', async () => { @@ -343,7 +446,6 @@ describe('routeExecution', () => { const payload = { entityDocument: 'flowchart TD\n n1["Input
id: input1
type: input_trigger"]', entityId: 'workflow-123', - currentWorkflowState: '{"blocks":{}}', } await expect(routeExecution('edit_workflow', payload)).resolves.toMatchObject({ @@ -370,11 +472,10 @@ describe('routeExecution', () => { expect(readWorkflowLogsExecute).toHaveBeenCalledWith(payload, undefined) }) - it('forwards ambient workflow context separately from raw tool args', async () => { + it('injects hosted workspace context for workspace-targeted tools', async () => { const context = { userId: 'user-1', - contextEntityKind: 'workflow' as const, - contextEntityId: 'workflow-current', + workspaceId: 'workspace-1', } await expect(routeExecution('read_environment_variables', {}, context)).resolves.toMatchObject({ @@ -382,36 +483,38 @@ describe('routeExecution', () => { count: expect.any(Number), }) - expect(readEnvironmentVariablesExecute).toHaveBeenCalledWith({}, context) + expect(readEnvironmentVariablesExecute).toHaveBeenCalledWith( + { workspaceId: 'workspace-1' }, + context + ) }) it.each([ { toolName: 'read_environment_variables', - payload: { entityId: 'workflow-123' }, + payload: { workspaceId: 'workspace-123' }, execute: readEnvironmentVariablesExecute, }, { toolName: 'set_environment_variables', - payload: { entityId: 'workflow-123', variables: { API_KEY: 'secret' } }, + payload: { variables: { API_KEY: 'secret' } }, execute: setEnvironmentVariablesExecute, }, { toolName: 'read_credentials', - payload: { entityId: 'workflow-123' }, + payload: { workspaceId: 'workspace-123' }, execute: readCredentialsExecute, }, { toolName: 'list_gdrive_files', payload: { - entityId: 'workflow-123', + workspaceId: 'workspace-123', credentialId: 'credential-1', - userId: 'spoofed-user', search_query: 'report', num_results: 3, }, expectedArgs: { - entityId: 'workflow-123', + workspaceId: 'workspace-123', credentialId: 'credential-1', search_query: 'report', num_results: 3, @@ -421,7 +524,7 @@ describe('routeExecution', () => { { toolName: 'read_gdrive_file', payload: { - entityId: 'workflow-123', + workspaceId: 'workspace-123', credentialId: 'credential-1', fileId: 'file-1', type: 'doc', @@ -430,15 +533,24 @@ describe('routeExecution', () => { }, { toolName: 'read_oauth_credentials', - payload: { entityId: 'workflow-123' }, + payload: { workspaceId: 'workspace-123' }, execute: readOAuthCredentialsExecute, }, ])( - 'preserves entityId when routing $toolName', + 'preserves explicit args when routing $toolName', async ({ toolName, payload, expectedArgs, execute }) => { - await expect(routeExecution(toolName, payload)).resolves.toBeDefined() + const workspaceId = + typeof (payload as { workspaceId?: unknown }).workspaceId === 'string' + ? (payload as { workspaceId: string }).workspaceId + : undefined + const context = workspaceId ? { userId: 'user-1' } : undefined - expect(execute).toHaveBeenCalledWith(expectedArgs ?? payload, undefined) + await expect(routeExecution(toolName, payload, context)).resolves.toBeDefined() + + expect(execute).toHaveBeenCalledWith( + expectedArgs ?? payload, + workspaceId ? { userId: 'user-1', workspaceId } : undefined + ) } ) }) diff --git a/apps/tradinggoose/lib/copilot/tools/server/router.ts b/apps/tradinggoose/lib/copilot/tools/server/router.ts index 5bf2f5890..7a45f20b1 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/router.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/router.ts @@ -9,19 +9,60 @@ import { type BaseServerTool, type ServerToolExecutionContext, throwIfServerToolAborted, + withWorkspaceArgContext, } from '@/lib/copilot/tools/server/base-tool' import { searchDocumentationServerTool } from '@/lib/copilot/tools/server/docs/search-documentation' +import { + createCustomToolServerTool, + createIndicatorServerTool, + createMcpServerServerTool, + createSkillServerTool, + createWorkflowServerTool, + editCustomToolServerTool, + editIndicatorServerTool, + editMcpServerServerTool, + editSkillServerTool, + editWorkflowBlockServerTool, + editWorkflowServerTool, + editWorkflowVariableServerTool, + listCustomToolsServerTool, + listIndicatorsServerTool, + listMcpServersServerTool, + listSkillsServerTool, + listWorkflowsServerTool, + readCustomToolServerTool, + readIndicatorServerTool, + readMcpServerServerTool, + readSkillServerTool, + readWorkflowServerTool, + renameCustomToolServerTool, + renameIndicatorServerTool, + renameMcpServerServerTool, + renameSkillServerTool, + renameWorkflowServerTool, +} from '@/lib/copilot/tools/server/entities' import { listGDriveFilesServerTool } from '@/lib/copilot/tools/server/gdrive/list-files' import { readGDriveFileServerTool } from '@/lib/copilot/tools/server/gdrive/read-file' -import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' +import { + createKnowledgeBaseServerTool, + editKnowledgeBaseServerTool, + listKnowledgeBasesServerTool, + queryKnowledgeBaseServerTool, + readKnowledgeBaseServerTool, + renameKnowledgeBaseServerTool, +} from '@/lib/copilot/tools/server/knowledge/knowledge-base' +import { editMonitorServerTool } from '@/lib/copilot/tools/server/monitor/edit-monitor' +import { listMonitorsServerTool } from '@/lib/copilot/tools/server/monitor/list-monitors' +import { readMonitorServerTool } from '@/lib/copilot/tools/server/monitor/read-monitor' import { makeApiRequestServerTool } from '@/lib/copilot/tools/server/other/make-api-request' import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online' import { readCredentialsServerTool } from '@/lib/copilot/tools/server/user/read-credentials' import { readEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/read-environment-variables' import { readOAuthCredentialsServerTool } from '@/lib/copilot/tools/server/user/read-oauth-credentials' import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables' -import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow' -import { editWorkflowBlockServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow-block' +import { checkDeploymentStatusServerTool } from '@/lib/copilot/tools/server/workflow/check-deployment-status' +import { readBlockOutputsServerTool } from '@/lib/copilot/tools/server/workflow/read-block-outputs' +import { readBlockUpstreamReferencesServerTool } from '@/lib/copilot/tools/server/workflow/read-block-upstream-references' import { readWorkflowLogsServerTool } from '@/lib/copilot/tools/server/workflow/read-workflow-logs' import { createLogger } from '@/lib/logs/console/logger' @@ -30,6 +71,11 @@ const logger = createLogger('ServerToolRouter') const serverToolRegistry: Partial>> = { [editWorkflowServerTool.name]: editWorkflowServerTool, [editWorkflowBlockServerTool.name]: editWorkflowBlockServerTool, + [editWorkflowVariableServerTool.name]: editWorkflowVariableServerTool, + [createWorkflowServerTool.name]: createWorkflowServerTool, + [renameWorkflowServerTool.name]: renameWorkflowServerTool, + [readWorkflowServerTool.name]: readWorkflowServerTool, + [listWorkflowsServerTool.name]: listWorkflowsServerTool, [readWorkflowLogsServerTool.name]: readWorkflowLogsServerTool, [searchDocumentationServerTool.name]: searchDocumentationServerTool, [searchOnlineServerTool.name]: searchOnlineServerTool, @@ -40,45 +86,83 @@ const serverToolRegistry: Partial>> = { [readOAuthCredentialsServerTool.name]: readOAuthCredentialsServerTool, [readCredentialsServerTool.name]: readCredentialsServerTool, [makeApiRequestServerTool.name]: makeApiRequestServerTool, - [knowledgeBaseServerTool.name]: knowledgeBaseServerTool, + [listKnowledgeBasesServerTool.name]: listKnowledgeBasesServerTool, + [readKnowledgeBaseServerTool.name]: readKnowledgeBaseServerTool, + [createKnowledgeBaseServerTool.name]: createKnowledgeBaseServerTool, + [editKnowledgeBaseServerTool.name]: editKnowledgeBaseServerTool, + [renameKnowledgeBaseServerTool.name]: renameKnowledgeBaseServerTool, + [queryKnowledgeBaseServerTool.name]: queryKnowledgeBaseServerTool, + [listMonitorsServerTool.name]: listMonitorsServerTool, + [readMonitorServerTool.name]: readMonitorServerTool, + [editMonitorServerTool.name]: editMonitorServerTool, + [checkDeploymentStatusServerTool.name]: checkDeploymentStatusServerTool, + [readBlockOutputsServerTool.name]: readBlockOutputsServerTool, + [readBlockUpstreamReferencesServerTool.name]: readBlockUpstreamReferencesServerTool, + [listSkillsServerTool.name]: listSkillsServerTool, + [readSkillServerTool.name]: readSkillServerTool, + [createSkillServerTool.name]: createSkillServerTool, + [editSkillServerTool.name]: editSkillServerTool, + [renameSkillServerTool.name]: renameSkillServerTool, + [listCustomToolsServerTool.name]: listCustomToolsServerTool, + [readCustomToolServerTool.name]: readCustomToolServerTool, + [createCustomToolServerTool.name]: createCustomToolServerTool, + [editCustomToolServerTool.name]: editCustomToolServerTool, + [renameCustomToolServerTool.name]: renameCustomToolServerTool, + [listIndicatorsServerTool.name]: listIndicatorsServerTool, + [readIndicatorServerTool.name]: readIndicatorServerTool, + [createIndicatorServerTool.name]: createIndicatorServerTool, + [editIndicatorServerTool.name]: editIndicatorServerTool, + [renameIndicatorServerTool.name]: renameIndicatorServerTool, + [listMcpServersServerTool.name]: listMcpServersServerTool, + [readMcpServerServerTool.name]: readMcpServerServerTool, + [createMcpServerServerTool.name]: createMcpServerServerTool, + [editMcpServerServerTool.name]: editMcpServerServerTool, + [renameMcpServerServerTool.name]: renameMcpServerServerTool, } -async function resolveServerTool(toolName: ToolId): Promise | null> { - if (toolName === CopilotTool.get_available_blocks) { +const lazyServerToolLoaders: Partial Promise>>> = { + [CopilotTool.get_available_blocks]: async () => { const { getAvailableBlocksServerTool } = await import( '@/lib/copilot/tools/server/blocks/get-available-blocks' ) return getAvailableBlocksServerTool - } - - if (toolName === CopilotTool.get_blocks_metadata) { + }, + [CopilotTool.get_blocks_metadata]: async () => { const { getBlocksMetadataServerTool } = await import( '@/lib/copilot/tools/server/blocks/get-blocks-metadata' ) return getBlocksMetadataServerTool - } - - if (toolName === CopilotTool.get_agent_accessory_catalog) { + }, + [CopilotTool.get_agent_accessory_catalog]: async () => { const { getAgentAccessoryCatalogServerTool } = await import( '@/lib/copilot/tools/server/agent/get-agent-accessory-catalog' ) return getAgentAccessoryCatalogServerTool - } - - if (toolName === CopilotTool.get_indicator_catalog) { + }, + [CopilotTool.get_indicator_catalog]: async () => { const { getIndicatorCatalogServerTool } = await import( '@/lib/copilot/tools/server/indicators/get-indicator-catalog' ) return getIndicatorCatalogServerTool - } - - if (toolName === CopilotTool.get_indicator_metadata) { + }, + [CopilotTool.get_indicator_metadata]: async () => { const { getIndicatorMetadataServerTool } = await import( '@/lib/copilot/tools/server/indicators/get-indicator-metadata' ) return getIndicatorMetadataServerTool - } + }, +} + +export function getServerToolIds(): ToolId[] { + return [ + ...(Object.keys(serverToolRegistry) as ToolId[]), + ...(Object.keys(lazyServerToolLoaders) as ToolId[]), + ] +} +async function resolveServerTool(toolName: ToolId): Promise | null> { + const lazyTool = lazyServerToolLoaders[toolName] + if (lazyTool) return lazyTool() return serverToolRegistry[toolName] ?? null } @@ -112,11 +196,32 @@ export async function routeExecution( })(), }) - const args = ServerToolArgSchemas[toolName].parse(payload ?? {}) - throwIfServerToolAborted(context) + let args: any + try { + args = ServerToolArgSchemas[toolName].parse(payload ?? {}) + } catch (error) { + if ( + context?.workspaceId && + (!payload || (typeof payload === 'object' && !Array.isArray(payload))) + ) { + const payloadWithContextWorkspace = { + ...((payload as Record | null | undefined) ?? {}), + workspaceId: context.workspaceId, + } + try { + args = ServerToolArgSchemas[toolName].parse(payloadWithContextWorkspace) + } catch { + throw error + } + } else { + throw error + } + } + const executionContext = withWorkspaceArgContext(context, args) + throwIfServerToolAborted(executionContext) - const result = await tool.execute(args, context) - throwIfServerToolAborted(context) + const result = await tool.execute(args, executionContext) + throwIfServerToolAborted(executionContext) return contract.result.parse(result) } diff --git a/apps/tradinggoose/lib/copilot/tools/server/user/read-credentials.ts b/apps/tradinggoose/lib/copilot/tools/server/user/read-credentials.ts index 46b2dce7f..f1380441b 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/read-credentials.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/read-credentials.ts @@ -3,18 +3,15 @@ import type { BaseServerTool, ServerToolExecutionContext, } from '@/lib/copilot/tools/server/base-tool' -import { - createWorkflowPermissionError, - resolveServerWorkspaceId, - resolveServerWorkflowScope, -} from '@/lib/copilot/tools/server/workflow/workflow-scope' +import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' import { listOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { createLogger } from '@/lib/logs/console/logger' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' interface ReadCredentialsParams { - entityId?: string + workspaceId?: string } export const readCredentialsServerTool: BaseServerTool = { @@ -22,29 +19,26 @@ export const readCredentialsServerTool: BaseServerTool { const logger = createLogger('ReadCredentialsServerTool') - if (!context?.userId) { + const scopedContext = withWorkspaceArgContext(context, params) + + if (!scopedContext?.userId) { logger.error('Unauthorized attempt to access credentials - no authenticated user context') throw new Error('Authentication required') } - const authenticatedUserId = context.userId - - const workflowScope = await resolveServerWorkflowScope(params, context) - if (workflowScope && !workflowScope.hasAccess) { - const errorMessage = createWorkflowPermissionError('access credentials in') - logger.error('Unauthorized attempt to access credentials', { - workflowId: workflowScope.workflowId, - authenticatedUserId, - }) - throw new Error(errorMessage) + const userId = scopedContext.userId + const workspaceId = scopedContext.workspaceId + if (!workspaceId) { + throw new Error('workspaceId is required') } - const workspaceId = resolveServerWorkspaceId(context, workflowScope) - const userId = authenticatedUserId + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { + throw new Error('Access denied: You do not have permission to use this workspace') + } logger.info('Fetching credentials for authenticated user', { userId, - workflowId: workflowScope?.workflowId, workspaceId, }) diff --git a/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.test.ts b/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.test.ts index b5bea606e..b3804bc6e 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.test.ts @@ -2,20 +2,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { readEnvironmentVariablesServerTool } from './read-environment-variables' const mocks = vi.hoisted(() => ({ - getEnvironmentVariableKeys: vi.fn(), getPersonalAndWorkspaceEnv: vi.fn(), - verifyWorkflowAccess: vi.fn(), -})) - -vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ - verifyWorkflowAccess: mocks.verifyWorkflowAccess, + checkWorkspaceAccess: vi.fn(), })) vi.mock('@/lib/environment/utils', () => ({ - getEnvironmentVariableKeys: mocks.getEnvironmentVariableKeys, getPersonalAndWorkspaceEnv: mocks.getPersonalAndWorkspaceEnv, })) +vi.mock('@/lib/permissions/utils', () => ({ + checkWorkspaceAccess: mocks.checkWorkspaceAccess, +})) + vi.mock('@/lib/logs/console/logger', () => ({ createLogger: () => ({ debug: vi.fn(), @@ -30,10 +28,12 @@ describe('readEnvironmentVariablesServerTool', () => { vi.clearAllMocks() }) - it('uses ambient current-workflow context to include workspace variables', async () => { - mocks.verifyWorkflowAccess.mockResolvedValue({ + it('uses explicit workspace context to include workspace variables', async () => { + mocks.checkWorkspaceAccess.mockResolvedValue({ + exists: true, hasAccess: true, - workspaceId: 'workspace-1', + canWrite: true, + workspace: { id: 'workspace-1' }, }) mocks.getPersonalAndWorkspaceEnv.mockResolvedValue({ personalEncrypted: { PERSONAL_KEY: 'encrypted-1' }, @@ -43,11 +43,9 @@ describe('readEnvironmentVariablesServerTool', () => { await expect( readEnvironmentVariablesServerTool.execute( - {}, + { workspaceId: 'workspace-1' }, { userId: 'auth-user', - contextEntityKind: 'workflow', - contextEntityId: 'workflow-1', } ) ).resolves.toEqual({ @@ -55,8 +53,7 @@ describe('readEnvironmentVariablesServerTool', () => { count: 2, }) - expect(mocks.verifyWorkflowAccess).toHaveBeenCalledWith('auth-user', 'workflow-1', 'read') + expect(mocks.checkWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'auth-user') expect(mocks.getPersonalAndWorkspaceEnv).toHaveBeenCalledWith('auth-user', 'workspace-1') - expect(mocks.getEnvironmentVariableKeys).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.ts b/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.ts index 36267b0fc..4cf791cfb 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.ts @@ -3,16 +3,13 @@ import type { BaseServerTool, ServerToolExecutionContext, } from '@/lib/copilot/tools/server/base-tool' -import { - createWorkflowPermissionError, - resolveServerWorkspaceId, - resolveServerWorkflowScope, -} from '@/lib/copilot/tools/server/workflow/workflow-scope' -import { getEnvironmentVariableKeys, getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' +import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' +import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { createLogger } from '@/lib/logs/console/logger' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' interface ReadEnvironmentVariablesParams { - entityId?: string + workspaceId?: string } export const readEnvironmentVariablesServerTool: BaseServerTool< @@ -26,58 +23,45 @@ export const readEnvironmentVariablesServerTool: BaseServerTool< ): Promise { const logger = createLogger('ReadEnvironmentVariablesServerTool') - if (!context?.userId) { + const scopedContext = withWorkspaceArgContext(context, params) + + if (!scopedContext?.userId) { logger.error( 'Unauthorized attempt to access environment variables - no authenticated user context' ) throw new Error('Authentication required') } - const authenticatedUserId = context.userId - - const workflowScope = await resolveServerWorkflowScope(params, context) - if (workflowScope && !workflowScope.hasAccess) { - const errorMessage = createWorkflowPermissionError('access environment variables in') - logger.error('Unauthorized attempt to access environment variables', { - workflowId: workflowScope.workflowId, - authenticatedUserId, - }) - throw new Error(errorMessage) + const userId = scopedContext.userId + const workspaceId = scopedContext.workspaceId + if (!workspaceId) { + throw new Error('workspaceId is required') } - const userId = authenticatedUserId - const workspaceId = resolveServerWorkspaceId(context, workflowScope) + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { + throw new Error('Access denied: You do not have permission to use this workspace') + } logger.info('Reading environment variables for authenticated user', { userId, - workflowId: workflowScope?.workflowId, workspaceId, }) - if (workspaceId) { - const envResult = await getPersonalAndWorkspaceEnv(userId, workspaceId) - const variableNames = [ - ...new Set([ - ...Object.keys(envResult.personalEncrypted), - ...Object.keys(envResult.workspaceEncrypted), - ]), - ] - logger.info('Environment variable keys retrieved', { - userId, - workflowId: workflowScope?.workflowId, - variableCount: variableNames.length, - }) - return { - variableNames, - count: variableNames.length, - } - } - - const result = await getEnvironmentVariableKeys(userId) - logger.info('Environment variable keys retrieved', { userId, variableCount: result.count }) + const envResult = await getPersonalAndWorkspaceEnv(userId, workspaceId) + const variableNames = [ + ...new Set([ + ...Object.keys(envResult.personalEncrypted), + ...Object.keys(envResult.workspaceEncrypted), + ]), + ] + logger.info('Environment variable keys retrieved', { + userId, + variableCount: variableNames.length, + }) return { - variableNames: result.variableNames, - count: result.count, + variableNames, + count: variableNames.length, } }, } diff --git a/apps/tradinggoose/lib/copilot/tools/server/user/read-oauth-credentials.ts b/apps/tradinggoose/lib/copilot/tools/server/user/read-oauth-credentials.ts index 832dd2cba..5cef0ca30 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/read-oauth-credentials.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/read-oauth-credentials.ts @@ -3,16 +3,13 @@ import type { BaseServerTool, ServerToolExecutionContext, } from '@/lib/copilot/tools/server/base-tool' -import { - createWorkflowPermissionError, - resolveServerWorkspaceId, - resolveServerWorkflowScope, -} from '@/lib/copilot/tools/server/workflow/workflow-scope' +import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' import { listOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { createLogger } from '@/lib/logs/console/logger' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' interface ReadOAuthCredentialsParams { - entityId?: string + workspaceId?: string } export const readOAuthCredentialsServerTool: BaseServerTool = { @@ -23,31 +20,27 @@ export const readOAuthCredentialsServerTool: BaseServerTool { const logger = createLogger('ReadOAuthCredentialsServerTool') - if (!context?.userId) { + const scopedContext = withWorkspaceArgContext(context, params) + + if (!scopedContext?.userId) { logger.error( 'Unauthorized attempt to access OAuth credentials - no authenticated user context' ) throw new Error('Authentication required') } - const authenticatedUserId = context.userId - - const workflowScope = await resolveServerWorkflowScope(params, context) - if (workflowScope && !workflowScope.hasAccess) { - const errorMessage = createWorkflowPermissionError('access credentials in') - logger.error('Unauthorized attempt to access OAuth credentials', { - workflowId: workflowScope.workflowId, - authenticatedUserId, - }) - throw new Error(errorMessage) + const userId = scopedContext.userId + const workspaceId = scopedContext.workspaceId + if (!workspaceId) { + throw new Error('workspaceId is required') + } + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { + throw new Error('Access denied: You do not have permission to use this workspace') } - - const userId = authenticatedUserId - const workspaceId = resolveServerWorkspaceId(context, workflowScope) logger.info('Reading OAuth credentials for authenticated user', { userId, - workflowId: workflowScope?.workflowId, workspaceId, }) const credentials = await listOAuthCredentialsForUser({ diff --git a/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts b/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts index 982cc30cd..057b2f293 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts @@ -7,16 +7,11 @@ import { type ServerToolExecutionContext, throwIfServerToolAborted, } from '@/lib/copilot/tools/server/base-tool' -import { - createWorkflowPermissionError, - resolveServerWorkflowScope, -} from '@/lib/copilot/tools/server/workflow/workflow-scope' import { createLogger } from '@/lib/logs/console/logger' import { encryptSecret } from '@/lib/utils-server' interface SetEnvironmentVariablesParams { variables: Record | Array<{ name: string; value: string }> - entityId?: string } const EnvVarSchema = z.object({ variables: z.record(z.string()) }) @@ -56,21 +51,9 @@ export const setEnvironmentVariablesServerTool: BaseServerTool ({ + loadWorkflowSnapshotForCopilot: (...args: unknown[]) => + mockLoadWorkflowSnapshotForCopilot(...args), +})) + +vi.mock('@/blocks', () => ({ + getBlock: (blockType: string) => { + const registry: Record = { + agent: { + outputs: { + content: { type: 'string', description: 'Agent content' }, + meta: { + sentiment: { type: 'string', description: 'Sentiment label' }, + }, + }, + }, + function: { + outputs: { + result: { type: 'json', description: 'Return value' }, + stdout: { type: 'string', description: 'Console output' }, + }, + }, + loop: { + outputs: {}, + }, + } + + return registry[blockType] + }, +})) + +describe('server workflow output tools', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('read_block_outputs returns structured output entries with paths and types', async () => { + mockLoadWorkflowSnapshotForCopilot.mockResolvedValue({ + workflowId: 'wf-1', + workflowState: { + blocks: { + 'agent-1': { id: 'agent-1', type: 'agent', name: 'agent', subBlocks: {} }, + 'loop-1': { id: 'loop-1', type: 'loop', name: 'loop', subBlocks: {} }, + }, + edges: [], + loops: { + 'loop-1': { id: 'loop-1', nodes: [], loopType: 'forEach' }, + }, + parallels: {}, + }, + workspaceId: 'ws-1', + variables: { + 'var-1': { id: 'var-1', name: 'riskLimit', type: 'number' }, + }, + }) + + const result = await readBlockOutputsServerTool.execute( + { entityId: 'wf-1', blockIds: ['agent-1', 'loop-1'] }, + { userId: 'user-1' } + ) + + expect(result.blocks).toEqual([ + { + blockId: 'agent-1', + blockName: 'agent', + blockType: 'agent', + outputs: [ + { path: 'agent.content', type: 'string' }, + { path: 'agent.meta.sentiment', type: 'string' }, + ], + }, + { + blockId: 'loop-1', + blockName: 'loop', + blockType: 'loop', + outputs: [], + insideSubflowOutputs: [ + { path: 'loop.index', type: 'number' }, + { path: 'loop.currentItem', type: 'any' }, + { path: 'loop.items', type: 'json' }, + ], + outsideSubflowOutputs: [{ path: 'loop.results', type: 'json' }], + }, + ]) + expect(ToolResultSchemas.read_block_outputs.parse(result)).toBeDefined() + expect(mockLoadWorkflowSnapshotForCopilot).toHaveBeenCalledWith( + 'wf-1', + { userId: 'user-1' }, + 'read' + ) + }) + + it('read_block_upstream_references returns accessible output entries with paths and types', async () => { + mockLoadWorkflowSnapshotForCopilot.mockResolvedValue({ + workflowId: 'wf-1', + workflowState: { + blocks: { + 'agent-1': { id: 'agent-1', type: 'agent', name: 'agent', subBlocks: {} }, + 'fn-1': { id: 'fn-1', type: 'function', name: 'function', subBlocks: {} }, + }, + edges: [{ source: 'agent-1', target: 'fn-1' }], + loops: {}, + parallels: {}, + }, + workspaceId: 'ws-1', + variables: { + 'var-1': { id: 'var-1', name: 'riskLimit', type: 'number' }, + }, + }) + + const result = await readBlockUpstreamReferencesServerTool.execute( + { entityId: 'wf-1', blockIds: ['fn-1'] }, + { userId: 'user-1' } + ) + + expect(result.results).toEqual([ + { + blockId: 'fn-1', + blockName: 'function', + accessibleBlocks: [ + { + blockId: 'agent-1', + blockName: 'agent', + blockType: 'agent', + outputs: [ + { path: 'agent.content', type: 'string' }, + { path: 'agent.meta.sentiment', type: 'string' }, + ], + }, + ], + variables: [ + { + id: 'var-1', + name: 'riskLimit', + type: 'number', + tag: 'variable.risklimit', + }, + ], + }, + ]) + expect(ToolResultSchemas.read_block_upstream_references.parse(result)).toBeDefined() + }) +}) diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/check-deployment-status.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/check-deployment-status.ts new file mode 100644 index 000000000..0ba5be7ee --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/check-deployment-status.ts @@ -0,0 +1,88 @@ +import { chat, db, workflow, workflowDeploymentVersion } from '@tradinggoose/db' +import { and, desc, eq } from 'drizzle-orm' +import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { verifyWorkflowAccess } from '@/lib/copilot/review-sessions/permissions' + +type CheckDeploymentStatusArgs = { + entityId: string +} + +type CheckDeploymentStatusResult = { + isDeployed: boolean + deploymentTypes: string[] + apiDeployed: boolean + chatDeployed: boolean + deployedAt: string | null +} + +export const checkDeploymentStatusServerTool: BaseServerTool< + CheckDeploymentStatusArgs, + CheckDeploymentStatusResult +> = { + name: 'check_deployment_status', + async execute(args, context) { + const userId = context?.userId?.trim() + if (!userId) { + throw new Error('Authenticated user is required to check workflow deployment status') + } + + const access = await verifyWorkflowAccess(userId, args.entityId, 'read') + if (!access.hasAccess) { + throw new Error('Access denied: You do not have permission to read this workflow') + } + + const [workflowRow] = await db + .select({ + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt, + }) + .from(workflow) + .where(eq(workflow.id, args.entityId)) + .limit(1) + + if (!workflowRow) { + throw new Error('Workflow not found') + } + + const [activeDeployment] = await db + .select({ id: workflowDeploymentVersion.id }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, args.entityId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .orderBy(desc(workflowDeploymentVersion.createdAt)) + .limit(1) + + const chatRows = activeDeployment + ? await db + .select({ id: chat.id }) + .from(chat) + .where( + and( + eq(chat.workflowId, args.entityId), + eq(chat.deploymentVersionId, activeDeployment.id), + eq(chat.isActive, true) + ) + ) + .limit(1) + : [] + + const apiDeployed = workflowRow.isDeployed || false + const chatDeployed = chatRows.length > 0 + const deploymentTypes = [ + ...(apiDeployed ? ['api'] : []), + ...(chatDeployed ? ['chat'] : []), + ] + + return { + isDeployed: apiDeployed || chatDeployed, + deploymentTypes, + apiDeployed, + chatDeployed, + deployedAt: workflowRow.deployedAt?.toISOString?.() ?? null, + } + }, +} diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.test.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.test.ts index 5bdd6fb8f..25fd6b17b 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.test.ts @@ -1,6 +1,16 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { editWorkflowBlockServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow-block' +const mockLoadBaseWorkflowState = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/copilot/tools/server/workflow/workflow-mutation-utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...(actual as object), + loadBaseWorkflowState: (...args: any[]) => mockLoadBaseWorkflowState(...args), + } +}) + vi.mock('@/lib/workflows/validation', () => ({ validateWorkflowState: (state: any) => ({ valid: true, @@ -21,7 +31,7 @@ vi.mock('@/blocks', () => ({ : undefined, })) -const CURRENT_WORKFLOW_STATE = JSON.stringify({ +const CURRENT_WORKFLOW_STATE = { direction: 'TD', blocks: { fn1: { @@ -43,9 +53,14 @@ const CURRENT_WORKFLOW_STATE = JSON.stringify({ edges: [], loops: {}, parallels: {}, -}) +} describe('editWorkflowBlockServerTool', () => { + beforeEach(() => { + mockLoadBaseWorkflowState.mockReset() + mockLoadBaseWorkflowState.mockResolvedValue(CURRENT_WORKFLOW_STATE) + }) + it('patches only the selected block config and preserves the workflow document envelope', async () => { const result = await editWorkflowBlockServerTool.execute( { @@ -56,7 +71,6 @@ describe('editWorkflowBlockServerTool', () => { subBlocks: { code: 'return { rsi: 50 }', }, - currentWorkflowState: CURRENT_WORKFLOW_STATE, }, { userId: 'user-1' } ) @@ -77,7 +91,6 @@ describe('editWorkflowBlockServerTool', () => { subBlocks: { madeUpField: 'bad', }, - currentWorkflowState: CURRENT_WORKFLOW_STATE, }, { userId: 'user-1' } ) diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.ts index 30d27c38d..9f2ad8368 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.ts @@ -1,6 +1,9 @@ import { StructuredServerToolError } from '@/lib/copilot/server-tool-errors' import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import type { + BaseServerTool, + ServerToolExecutionContext, +} from '@/lib/copilot/tools/server/base-tool' import { createLogger } from '@/lib/logs/console/logger' import { getAllowedSubBlockIds } from '@/lib/workflows/block-config-canonicalization' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' @@ -14,7 +17,6 @@ interface EditWorkflowBlockParams { name?: string enabled?: boolean subBlocks?: Record - currentWorkflowState: string } function normalizeOptionalString(value?: string): string | undefined { @@ -41,9 +43,12 @@ function throwInvalidBlockEdit(input: { export const editWorkflowBlockServerTool: BaseServerTool = { name: 'edit_workflow_block', - async execute(params: EditWorkflowBlockParams): Promise { + async execute( + params: EditWorkflowBlockParams, + context?: ServerToolExecutionContext + ): Promise { const logger = createLogger('EditWorkflowBlockServerTool') - const { blockId, blockType, name, enabled, subBlocks, currentWorkflowState } = params + const { blockId, blockType, name, enabled, subBlocks } = params const workflowId = requireCopilotEntityId(params, { toolName: 'edit_workflow_block' }) if (!blockId?.trim()) { @@ -79,7 +84,7 @@ export const editWorkflowBlockServerTool: BaseServerTool vi.fn()) + +vi.mock('@/lib/copilot/tools/server/workflow/workflow-mutation-utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...(actual as object), + loadBaseWorkflowState: (...args: any[]) => mockLoadBaseWorkflowState(...args), + } +}) + vi.mock('@/lib/workflows/validation', () => ({ validateWorkflowState: (state: any) => ({ valid: true, @@ -55,6 +65,11 @@ function graph(lines: string[]): string { } describe('editWorkflowServerTool', () => { + beforeEach(() => { + mockLoadBaseWorkflowState.mockReset() + mockLoadBaseWorkflowState.mockResolvedValue(BASE_WORKFLOW_STATE) + }) + it('connects existing blocks without rewriting block internals', async () => { const result = await editWorkflowServerTool.execute( { @@ -65,7 +80,6 @@ describe('editWorkflowServerTool', () => { ' n2["Compute Indicators
id: fn1
type: function"]', ' n1 --> n2', ]), - currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, { userId: 'user-1' } ) @@ -95,7 +109,6 @@ describe('editWorkflowServerTool', () => { ' n2["Compute
id: fn1
type: function"]', ' n1 --> n2', ]), - currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, { userId: 'user-1' } ) @@ -111,7 +124,6 @@ describe('editWorkflowServerTool', () => { ' fn1["Compute"]', ' input1 --> fn1', ]), - currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, { userId: 'user-1' } ) @@ -129,7 +141,6 @@ describe('editWorkflowServerTool', () => { ' n2["Compute
id: fn1
type: agent"]', ' n1 --> n2', ]), - currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, { userId: 'user-1' } ) @@ -139,6 +150,11 @@ describe('editWorkflowServerTool', () => { }) it('adds new blocks with canonical block defaults from metadata-only labels', async () => { + mockLoadBaseWorkflowState.mockResolvedValueOnce({ + ...BASE_WORKFLOW_STATE, + blocks: { input1: BASE_WORKFLOW_STATE.blocks.input1 }, + }) + const result = await editWorkflowServerTool.execute( { entityId: 'wf-1', @@ -148,10 +164,6 @@ describe('editWorkflowServerTool', () => { ' n2["id: fn2
type: function"]', ' n1 --> n2', ]), - currentWorkflowState: JSON.stringify({ - ...BASE_WORKFLOW_STATE, - blocks: { input1: BASE_WORKFLOW_STATE.blocks.input1 }, - }), }, { userId: 'user-1' } ) @@ -183,7 +195,6 @@ describe('editWorkflowServerTool', () => { ' n1["Input Form
id: input1
type: input_trigger"]', ' n3["Compute Indicators
id: fn1
type: function"]', ]), - currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, { userId: 'user-1' } ) @@ -192,6 +203,25 @@ describe('editWorkflowServerTool', () => { }) it('preserves existing block absolute position when moving into a container', async () => { + mockLoadBaseWorkflowState.mockResolvedValueOnce({ + ...BASE_WORKFLOW_STATE, + blocks: { + fn1: { + ...BASE_WORKFLOW_STATE.blocks.fn1, + position: { x: 420, y: 260 }, + }, + loop1: { + id: 'loop1', + type: 'loop', + name: 'Loop', + position: { x: 100, y: 100 }, + enabled: true, + subBlocks: {}, + outputs: {}, + }, + }, + }) + const result = await editWorkflowServerTool.execute( { entityId: 'wf-1', @@ -201,24 +231,6 @@ describe('editWorkflowServerTool', () => { ' n1["Compute Indicators
id: fn1
type: function"]', ' end', ]), - currentWorkflowState: JSON.stringify({ - ...BASE_WORKFLOW_STATE, - blocks: { - fn1: { - ...BASE_WORKFLOW_STATE.blocks.fn1, - position: { x: 420, y: 260 }, - }, - loop1: { - id: 'loop1', - type: 'loop', - name: 'Loop', - position: { x: 100, y: 100 }, - enabled: true, - subBlocks: {}, - outputs: {}, - }, - }, - }), }, { userId: 'user-1' } ) @@ -239,7 +251,6 @@ describe('editWorkflowServerTool', () => { 'flowchart TD', ' n1["Input Form
id: input1
type: input_trigger
enabled: false
outputs: {}
data.foo: bar
subBlocks.code: return 1"]', ]), - currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, { userId: 'user-1' } ) @@ -257,7 +268,6 @@ describe('editWorkflowServerTool', () => { 'flowchart TD', ' n1["Input Form
id: input1
type: input_trigger"]', ]), - currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, { userId: 'user-1' } ) @@ -267,30 +277,31 @@ describe('editWorkflowServerTool', () => { }) it('removes omitted blocks only when removedBlockIds declares intent', async () => { + mockLoadBaseWorkflowState.mockResolvedValueOnce({ + ...BASE_WORKFLOW_STATE, + blocks: { + input1: BASE_WORKFLOW_STATE.blocks.input1, + loop1: { + id: 'loop1', + type: 'loop', + name: 'Loop', + position: { x: 100, y: 100 }, + enabled: true, + subBlocks: {}, + outputs: {}, + }, + fn1: { + ...BASE_WORKFLOW_STATE.blocks.fn1, + data: { parentId: 'loop1', extent: 'parent' }, + }, + }, + }) + const result = await editWorkflowServerTool.execute( { entityId: 'wf-1', entityDocument: graph(['flowchart TD', 'input1["Input Form"]']), removedBlockIds: ['loop1'], - currentWorkflowState: JSON.stringify({ - ...BASE_WORKFLOW_STATE, - blocks: { - input1: BASE_WORKFLOW_STATE.blocks.input1, - loop1: { - id: 'loop1', - type: 'loop', - name: 'Loop', - position: { x: 100, y: 100 }, - enabled: true, - subBlocks: {}, - outputs: {}, - }, - fn1: { - ...BASE_WORKFLOW_STATE.blocks.fn1, - data: { parentId: 'loop1', extent: 'parent' }, - }, - }, - }), }, { userId: 'user-1' } ) @@ -312,7 +323,6 @@ describe('editWorkflowServerTool', () => { ' n2["Compute
id: fn1
type: function"]', ]), removedBlockIds: ['fn1'], - currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, { userId: 'user-1' } ) @@ -329,7 +339,6 @@ describe('editWorkflowServerTool', () => { '%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', '%% TG_BLOCK {"id":"input1","type":"input_trigger","name":"Input Form","position":{"x":0,"y":0},"subBlocks":{},"outputs":{},"enabled":true}', ]), - currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, { userId: 'user-1' } ) diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.ts index ef63d8d94..c01bf05ed 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.ts @@ -1,5 +1,8 @@ import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import type { + BaseServerTool, + ServerToolExecutionContext, +} from '@/lib/copilot/tools/server/base-tool' import { createLogger } from '@/lib/logs/console/logger' import { resolveBlockRuntimeState } from '@/lib/workflows/block-outputs' import { WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' @@ -16,7 +19,6 @@ interface EditWorkflowParams { entityId: string entityDocument: string removedBlockIds?: string[] - currentWorkflowState: string } function buildStableEdgeId(edge: { @@ -238,9 +240,9 @@ function applyGraphMermaidToWorkflow( export const editWorkflowServerTool: BaseServerTool = { name: 'edit_workflow', - async execute(params: EditWorkflowParams): Promise { + async execute(params: EditWorkflowParams, context?: ServerToolExecutionContext): Promise { const logger = createLogger('EditWorkflowServerTool') - const { entityDocument, removedBlockIds, currentWorkflowState } = params + const { entityDocument, removedBlockIds } = params const workflowId = requireCopilotEntityId(params, { toolName: 'edit_workflow' }) if (!entityDocument || entityDocument.trim().length === 0) { @@ -252,7 +254,7 @@ export const editWorkflowServerTool: BaseServerTool = { documentLength: entityDocument.length, }) - const baseWorkflowState = await loadBaseWorkflowState(workflowId, currentWorkflowState) + const baseWorkflowState = await loadBaseWorkflowState(workflowId, context) const nextWorkflowState = applyGraphMermaidToWorkflow( baseWorkflowState, entityDocument, @@ -266,7 +268,7 @@ export const editWorkflowServerTool: BaseServerTool = { documentFormat: WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT, }) - logger.info('edit_workflow successfully applied workflow graph', { + logger.info('edit_workflow prepared workflow graph review', { workflowId, blocksCount: Object.keys(result.workflowState.blocks).length, edgesCount: result.workflowState.edges.length, diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/read-block-outputs.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/read-block-outputs.ts new file mode 100644 index 000000000..aab947eef --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/read-block-outputs.ts @@ -0,0 +1,85 @@ +import { CopilotTool } from '@/lib/copilot/registry' +import { + computeBlockOutputReferences, + extractSubBlockValuesFromBlocks, + getSubflowInsideOutputReferences, + getSubflowOutsideOutputReferences, + readWorkflowVariableOutputs, +} from '@/lib/copilot/workflow/block-output-utils' +import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import type { + ReadBlockOutputsResultType, +} from '@/lib/copilot/tools/shared/schemas' +import { loadWorkflowSnapshotForCopilot } from '@/lib/copilot/tools/server/entities/workflow' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('ReadBlockOutputsServerTool') + +type ReadBlockOutputsArgs = { + entityId: string + blockIds?: string[] +} + +export const readBlockOutputsServerTool: BaseServerTool< + ReadBlockOutputsArgs, + ReadBlockOutputsResultType +> = { + name: CopilotTool.read_block_outputs, + async execute(args, context) { + const { + workflowId, + workflowState: snapshot, + variables, + } = await loadWorkflowSnapshotForCopilot(args.entityId, context, 'read') + const blocks = snapshot.blocks || {} + const loops = snapshot.loops || {} + const parallels = snapshot.parallels || {} + const subBlockValues = extractSubBlockValuesFromBlocks(blocks) + const variableOutputs = readWorkflowVariableOutputs(variables) + const ctx = { blocks, loops, parallels, subBlockValues } + const targetBlockIds = + args.blockIds && args.blockIds.length > 0 ? args.blockIds : Object.keys(blocks) + + const blockOutputs: ReadBlockOutputsResultType['blocks'] = [] + + for (const blockId of targetBlockIds) { + const block = blocks[blockId] + if (!block?.type) continue + + const blockName = block.name || block.type + const blockOutput: ReadBlockOutputsResultType['blocks'][0] = { + blockId, + blockName, + blockType: block.type, + outputs: [], + } + + if (block.type === 'loop' || block.type === 'parallel') { + blockOutput.insideSubflowOutputs = getSubflowInsideOutputReferences( + block.type, + blockId, + blockName, + loops, + parallels + ) + blockOutput.outsideSubflowOutputs = getSubflowOutsideOutputReferences(blockName) + } else { + blockOutput.outputs = computeBlockOutputReferences(block, ctx, variableOutputs) + } + + blockOutputs.push(blockOutput) + } + + const includeVariables = !args.blockIds || args.blockIds.length === 0 + logger.info('Retrieved workflow block outputs', { + workflowId, + blockCount: blockOutputs.length, + variableCount: includeVariables ? variableOutputs.length : 0, + }) + + return { + blocks: blockOutputs, + ...(includeVariables ? { variables: variableOutputs } : {}), + } + }, +} diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/read-block-upstream-references.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/read-block-upstream-references.ts new file mode 100644 index 000000000..f29cf2785 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/read-block-upstream-references.ts @@ -0,0 +1,152 @@ +import { BlockPathCalculator } from '@/lib/block-path-calculator' +import { CopilotTool } from '@/lib/copilot/registry' +import { + computeBlockOutputReferences, + extractSubBlockValuesFromBlocks, + getSubflowInsideOutputReferences, + getSubflowOutsideOutputReferences, + readWorkflowVariableOutputs, +} from '@/lib/copilot/workflow/block-output-utils' +import { loadWorkflowSnapshotForCopilot } from '@/lib/copilot/tools/server/entities/workflow' +import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import type { ReadBlockUpstreamReferencesResultType } from '@/lib/copilot/tools/shared/schemas' +import { createLogger } from '@/lib/logs/console/logger' +import type { Loop, Parallel } from '@/stores/workflows/workflow/types' + +const logger = createLogger('ReadBlockUpstreamReferencesServerTool') + +type ReadBlockUpstreamReferencesArgs = { + entityId: string + blockIds: string[] +} + +export const readBlockUpstreamReferencesServerTool: BaseServerTool< + ReadBlockUpstreamReferencesArgs, + ReadBlockUpstreamReferencesResultType +> = { + name: CopilotTool.read_block_upstream_references, + async execute(args, context) { + const { workflowState: snapshot, variables } = await loadWorkflowSnapshotForCopilot( + args.entityId, + context, + 'read' + ) + const blocks = snapshot.blocks || {} + const edges = snapshot.edges || [] + const loops = snapshot.loops || {} + const parallels = snapshot.parallels || {} + const subBlockValues = extractSubBlockValuesFromBlocks(blocks) + const ctx = { blocks, loops, parallels, subBlockValues } + const variableOutputs = readWorkflowVariableOutputs(variables) + const graphEdges = edges.map((edge) => ({ source: edge.source, target: edge.target })) + const results: ReadBlockUpstreamReferencesResultType['results'] = [] + + for (const blockId of args.blockIds) { + const targetBlock = blocks[blockId] + if (!targetBlock) { + logger.warn('Workflow block not found while reading upstream references', { blockId }) + continue + } + + const insideSubflows: { blockId: string; blockName: string; blockType: string }[] = [] + const containingLoopIds = new Set() + const containingParallelIds = new Set() + + Object.values(loops as Record).forEach((loop) => { + if (!loop?.nodes?.includes(blockId)) return + containingLoopIds.add(loop.id) + const loopBlock = blocks[loop.id] + if (loopBlock) { + insideSubflows.push({ + blockId: loop.id, + blockName: loopBlock.name || loopBlock.type, + blockType: 'loop', + }) + } + }) + + Object.values(parallels as Record).forEach((parallel) => { + if (!parallel?.nodes?.includes(blockId)) return + containingParallelIds.add(parallel.id) + const parallelBlock = blocks[parallel.id] + if (parallelBlock) { + insideSubflows.push({ + blockId: parallel.id, + blockName: parallelBlock.name || parallelBlock.type, + blockType: 'parallel', + }) + } + }) + + const ancestorIds = BlockPathCalculator.findAllPathNodes(graphEdges, blockId) + const accessibleIds = new Set(ancestorIds) + accessibleIds.add(blockId) + + containingLoopIds.forEach((loopId) => { + accessibleIds.add(loopId) + loops[loopId]?.nodes?.forEach((nodeId) => accessibleIds.add(nodeId)) + }) + + containingParallelIds.forEach((parallelId) => { + accessibleIds.add(parallelId) + parallels[parallelId]?.nodes?.forEach((nodeId) => accessibleIds.add(nodeId)) + }) + + const accessibleBlocks: ReadBlockUpstreamReferencesResultType['results'][0]['accessibleBlocks'] = + [] + + for (const accessibleBlockId of accessibleIds) { + const block = blocks[accessibleBlockId] + if (!block?.type) continue + + const canSelfReference = block.type === 'approval' || block.type === 'human_in_the_loop' + if (accessibleBlockId === blockId && !canSelfReference) continue + + const blockName = block.name || block.type + let accessContext: 'inside' | 'outside' | undefined + let outputs: ReadBlockUpstreamReferencesResultType['results'][0]['accessibleBlocks'][0]['outputs'] + + if (block.type === 'loop' || block.type === 'parallel') { + const isInside = + (block.type === 'loop' && containingLoopIds.has(accessibleBlockId)) || + (block.type === 'parallel' && containingParallelIds.has(accessibleBlockId)) + + accessContext = isInside ? 'inside' : 'outside' + outputs = isInside + ? getSubflowInsideOutputReferences( + block.type, + accessibleBlockId, + blockName, + loops, + parallels + ) + : getSubflowOutsideOutputReferences(blockName) + } else { + outputs = computeBlockOutputReferences(block, ctx, variableOutputs) + } + + const entry: ReadBlockUpstreamReferencesResultType['results'][0]['accessibleBlocks'][0] = { + blockId: accessibleBlockId, + blockName, + blockType: block.type, + outputs, + } + + if (accessContext) entry.accessContext = accessContext + accessibleBlocks.push(entry) + } + + const resultEntry: ReadBlockUpstreamReferencesResultType['results'][0] = { + blockId, + blockName: targetBlock.name || targetBlock.type, + accessibleBlocks, + variables: variableOutputs, + } + + if (insideSubflows.length > 0) resultEntry.insideSubflows = insideSubflows + results.push(resultEntry) + } + + return { results } + }, +} diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.test.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.test.ts new file mode 100644 index 000000000..67e67b9ef --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' +import { loadBaseWorkflowState } from '@/lib/copilot/tools/server/workflow/workflow-mutation-utils' +import { setWorkflowState, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' + +const mocks = vi.hoisted(() => ({ + readBootstrappedReviewTargetSnapshot: vi.fn(), + verifyWorkflowAccess: vi.fn(), +})) + +vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ + verifyWorkflowAccess: (...args: any[]) => mocks.verifyWorkflowAccess(...args), +})) + +vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ + readBootstrappedReviewTargetSnapshot: (...args: any[]) => + mocks.readBootstrappedReviewTargetSnapshot(...args), +})) + +function encodeWorkflowSnapshot(workflowState: WorkflowSnapshot): string { + const doc = new Y.Doc() + try { + setWorkflowState(doc, workflowState, 'test') + return Buffer.from(Y.encodeStateAsUpdate(doc)).toString('base64') + } finally { + doc.destroy() + } +} + +describe('workflow mutation Yjs loader', () => { + beforeEach(() => { + mocks.readBootstrappedReviewTargetSnapshot.mockReset() + mocks.verifyWorkflowAccess.mockReset() + }) + + it('loads the base workflow state from an authorized Yjs snapshot', async () => { + const workflowState: WorkflowSnapshot = { + direction: 'TD', + blocks: { + fn1: { + id: 'fn1', + type: 'function', + name: 'Function', + position: { x: 0, y: 0 }, + enabled: true, + subBlocks: {}, + outputs: {}, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } + + mocks.verifyWorkflowAccess.mockResolvedValue({ + hasAccess: true, + workspaceId: 'workspace-1', + userPermission: 'admin', + isOwner: false, + }) + mocks.readBootstrappedReviewTargetSnapshot.mockResolvedValue({ + snapshotBase64: encodeWorkflowSnapshot(workflowState), + descriptor: {}, + runtime: { docState: 'active', replaySafe: true, reseededFromCanonical: false }, + }) + + const result = await loadBaseWorkflowState('workflow-1', { + userId: 'user-1', + workspaceId: 'workspace-from-context', + }) + + expect(mocks.verifyWorkflowAccess).toHaveBeenCalledWith('user-1', 'workflow-1', 'write') + expect(mocks.readBootstrappedReviewTargetSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + entityKind: 'workflow', + entityId: 'workflow-1', + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: 'workflow-1', + }) + ) + expect(result.blocks.fn1.name).toBe('Function') + }) + + it('rejects workflow edits without authenticated user context', async () => { + await expect(loadBaseWorkflowState('workflow-1')).rejects.toThrow( + 'Authenticated user is required to edit workflow state' + ) + + expect(mocks.verifyWorkflowAccess).not.toHaveBeenCalled() + expect(mocks.readBootstrappedReviewTargetSnapshot).not.toHaveBeenCalled() + }) + + it('rejects workflow edits without write access', async () => { + mocks.verifyWorkflowAccess.mockResolvedValue({ + hasAccess: false, + workspaceId: 'workspace-1', + userPermission: null, + isOwner: false, + }) + + await expect(loadBaseWorkflowState('workflow-1', { userId: 'user-1' })).rejects.toThrow( + 'Access denied: You do not have permission to edit this workflow' + ) + + expect(mocks.readBootstrappedReviewTargetSnapshot).not.toHaveBeenCalled() + }) +}) diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts index 4274a65ac..616dfec71 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts @@ -1,3 +1,5 @@ +import * as Y from 'yjs' +import type { ServerToolExecutionContext } from '@/lib/copilot/tools/server/base-tool' import { findIntroducedNonCanonicalSubBlocks } from '@/lib/workflows/block-config-canonicalization' import { WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' import { @@ -8,25 +10,49 @@ import { } from '@/lib/workflows/studio-workflow-mermaid' import { validateWorkflowState } from '@/lib/workflows/validation' import { normalizeWorkflowStateToMermaidDirection } from '@/lib/workflows/workflow-direction' -import { createWorkflowSnapshot, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' +import { + createWorkflowSnapshot, + readWorkflowSnapshot, + type WorkflowSnapshot, +} from '@/lib/yjs/workflow-session' import type { WorkflowDirection } from '@/stores/workflows/workflow/types' -function parseCurrentWorkflowState(currentWorkflowState: string): WorkflowSnapshot { - try { - return createWorkflowSnapshot(JSON.parse(currentWorkflowState)) - } catch { - throw new Error('Invalid currentWorkflowState format') - } -} - export async function loadBaseWorkflowState( workflowId: string, - currentWorkflowState: string + context?: ServerToolExecutionContext ): Promise { - if (!currentWorkflowState) { + const userId = context?.userId?.trim() + if (!userId) { + throw new Error('Authenticated user is required to edit workflow state') + } + + const { verifyWorkflowAccess } = await import('@/lib/copilot/review-sessions/permissions') + const access = await verifyWorkflowAccess(userId, workflowId, 'write') + if (!access.hasAccess) { + throw new Error('Access denied: You do not have permission to edit this workflow') + } + + const snapshot = await readBootstrappedReviewTargetSnapshot({ + workspaceId: access.workspaceId ?? context?.workspaceId ?? null, + entityKind: 'workflow', + entityId: workflowId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: workflowId, + }) + + if (!snapshot.snapshotBase64) { throw new Error(`Current Yjs workflow state is required for ${workflowId}`) } - return parseCurrentWorkflowState(currentWorkflowState) + + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) + return createWorkflowSnapshot(readWorkflowSnapshot(doc)) + } finally { + doc.destroy() + } } export function buildWorkflowMutationResult(params: { @@ -67,7 +93,9 @@ export function buildWorkflowMutationResult(params: { finalWorkflowState = createWorkflowSnapshot(normalizedWorkflow.workflowState) const preview = buildWorkflowDocumentPreviewDiff(baseWorkflowState, finalWorkflowState) - const warnings = Array.from(new Set([...orientationWarnings, ...preview.warnings, ...validation.warnings])) + const warnings = Array.from( + new Set([...orientationWarnings, ...preview.warnings, ...validation.warnings]) + ) const entityDocument = params.entityDocument ?? (documentFormat === WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT @@ -75,6 +103,7 @@ export function buildWorkflowMutationResult(params: { : serializeWorkflowToTgMermaid(finalWorkflowState, { direction })) return { + requiresReview: true, success: true, entityKind: 'workflow' as const, entityId: workflowId, diff --git a/apps/tradinggoose/lib/copilot/tools/shared/schemas.ts b/apps/tradinggoose/lib/copilot/tools/shared/schemas.ts index 06be1f9fe..b511ae9e9 100644 --- a/apps/tradinggoose/lib/copilot/tools/shared/schemas.ts +++ b/apps/tradinggoose/lib/copilot/tools/shared/schemas.ts @@ -70,7 +70,7 @@ export const BlockInputReferencePatternSchema = z.object({ z.enum([ 'read_block_outputs', 'read_block_upstream_references', - 'read_workflow_variables', + 'read_workflow', 'read_environment_variables', ]) ) @@ -146,7 +146,7 @@ export type GetBlocksMetadataResultType = z.infer -// knowledge_base - shared schema used by client tool, server tool, and registry -export const KnowledgeBaseArgsSchema = z.object({ - operation: z.enum(['create', 'list', 'get', 'query']), - args: z - .object({ - /** Name of the knowledge base (required for create) */ - name: z.string().optional(), - /** Description of the knowledge base (optional for create) */ - description: z.string().optional(), - /** Workspace ID (required for create/list) */ - workspaceId: z.string().optional(), - /** Knowledge base ID (required for get, query) */ - knowledgeBaseId: z.string().optional(), - /** Search query text (required for query) */ - query: z.string().optional(), - /** Number of results to return (optional for query, defaults to 5) */ - topK: z.number().min(1).max(50).optional(), - /** Chunking configuration (optional for create) */ - chunkingConfig: z - .object({ - maxSize: z.number().min(100).max(4000).default(1024), - minSize: z.number().min(1).max(2000).default(1), - overlap: z.number().min(0).max(500).default(200), - }) - .optional(), - }) - .optional(), -}) -export type KnowledgeBaseArgs = z.infer - -export const KnowledgeBaseResultSchema = z.object({ - success: z.boolean(), - message: z.string(), - data: z.any().optional(), -}) -export type KnowledgeBaseResult = z.infer - export const ReadBlockOutputsInput = z.object({ blockIds: z .array(z.string()) diff --git a/apps/tradinggoose/lib/copilot/workflow/block-output-utils.ts b/apps/tradinggoose/lib/copilot/workflow/block-output-utils.ts new file mode 100644 index 000000000..773596ab8 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/workflow/block-output-utils.ts @@ -0,0 +1,195 @@ +import { getBlockOutputPaths, getBlockOutputType } from '@/lib/workflows/block-outputs' +import type { Variable } from '@/stores/variables/types' +import { normalizeBlockName } from '@/stores/workflows/utils' +import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' + +export interface WorkflowContext { + blocks: Record + loops: Record + parallels: Record + subBlockValues: Record> +} + +export interface VariableOutput { + id: string + name: string + type: string + tag: string +} + +export interface BlockOutputReference { + path: string + type: string +} + +export function extractSubBlockValuesFromBlocks( + blocks: Record +): Record> { + const result: Record> = {} + for (const [blockId, block] of Object.entries(blocks)) { + if (!block?.subBlocks) { + continue + } + + const blockValues: Record = {} + for (const [subId, sub] of Object.entries(block.subBlocks as Record)) { + if (sub && typeof sub === 'object' && 'value' in sub) { + blockValues[subId] = sub.value + } + } + result[blockId] = blockValues + } + return result +} + +export function getMergedSubBlocks( + blocks: Record, + subBlockValues: Record>, + targetBlockId: string +): Record { + const base = blocks[targetBlockId]?.subBlocks || {} + const live = subBlockValues?.[targetBlockId] || {} + const merged: Record = { ...base } + for (const [subId, liveVal] of Object.entries(live)) { + merged[subId] = { ...(base[subId] || {}), value: liveVal } + } + return merged +} + +export function getSubBlockValue( + blocks: Record, + subBlockValues: Record>, + targetBlockId: string, + subBlockId: string +): any { + const live = subBlockValues?.[targetBlockId]?.[subBlockId] + if (live !== undefined) return live + return blocks[targetBlockId]?.subBlocks?.[subBlockId]?.value +} + +export function readWorkflowVariableOutputs( + variablesRecord: Record | null | undefined +): VariableOutput[] { + const varsSnapshot = variablesRecord + if (!varsSnapshot) return [] + const workflowVariables = Object.values(varsSnapshot) as Variable[] + const validVariables = workflowVariables.filter( + (variable: Variable) => variable.name && variable.name.trim() !== '' + ) + return validVariables.map((variable: Variable) => ({ + id: variable.id, + name: variable.name, + type: variable.type, + tag: `variable.${normalizeBlockName(variable.name)}`, + })) +} + +function getSubflowInsidePaths( + blockType: 'loop' | 'parallel', + blockId: string, + loops: Record, + parallels: Record +): string[] { + const paths = ['index'] + if (blockType === 'loop') { + const loopType = loops[blockId]?.loopType || 'for' + if (loopType === 'forEach') { + paths.push('currentItem', 'items') + } + } else { + const parallelType = parallels[blockId]?.parallelType || 'count' + if (parallelType === 'collection') { + paths.push('currentItem', 'items') + } + } + return paths +} + +function formatOutputReferencesWithPrefix( + paths: string[], + blockName: string, + resolveType: (path: string) => string +): BlockOutputReference[] { + const normalizedName = normalizeBlockName(blockName) + return paths.map((path) => ({ + path: `${normalizedName}.${path}`, + type: resolveType(path), + })) +} + +function resolveSubflowOutputType(path: string): string { + if (path === 'index') return 'number' + if (path === 'results' || path === 'items') return 'json' + return 'any' +} + +export function getSubflowInsideOutputReferences( + blockType: 'loop' | 'parallel', + blockId: string, + blockName: string, + loops: Record, + parallels: Record +): BlockOutputReference[] { + return formatOutputReferencesWithPrefix( + getSubflowInsidePaths(blockType, blockId, loops, parallels), + blockName, + resolveSubflowOutputType + ) +} + +export function getSubflowOutsideOutputReferences(blockName: string): BlockOutputReference[] { + return formatOutputReferencesWithPrefix(['results'], blockName, resolveSubflowOutputType) +} + +export function computeBlockOutputReferences( + block: BlockState, + ctx: WorkflowContext, + workflowVariables: VariableOutput[] = [] +): BlockOutputReference[] { + const { blocks, loops, parallels, subBlockValues } = ctx + const blockName = block.name || block.type + + if (block.type === 'loop' || block.type === 'parallel') { + return formatOutputReferencesWithPrefix( + ['results', ...getSubflowInsidePaths(block.type, block.id, loops, parallels)], + blockName, + resolveSubflowOutputType + ) + } + + if (block.type === 'evaluator') { + const metricsValue = getSubBlockValue(blocks, subBlockValues, block.id, 'metrics') + const metricPaths = + metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0 + ? metricsValue + .filter((metric: { name?: string }) => metric?.name) + .map((metric: { name: string }) => metric.name.toLowerCase()) + : null + + if (metricPaths) { + return formatOutputReferencesWithPrefix(metricPaths, blockName, () => 'number') + } + } + + if (block.type === 'variables') { + const variablesValue = getSubBlockValue(blocks, subBlockValues, block.id, 'variables') + const variableNames = + variablesValue && Array.isArray(variablesValue) && variablesValue.length > 0 + ? variablesValue + .filter((assignment: { variableName?: string }) => assignment?.variableName?.trim()) + .map((assignment: { variableName: string }) => assignment.variableName.trim()) + : [] + + return formatOutputReferencesWithPrefix(variableNames, blockName, (path) => { + const variable = workflowVariables.find((entry) => entry.name === path) + return variable?.type || 'any' + }) + } + + const mergedSubBlocks = getMergedSubBlocks(blocks, subBlockValues, block.id) + const outputPaths = getBlockOutputPaths(block.type, mergedSubBlocks, block.triggerMode) + + return formatOutputReferencesWithPrefix(outputPaths, blockName, (path) => + getBlockOutputType(block.type, path, mergedSubBlocks, block.triggerMode) + ) +} diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index a6f9dd199..71163b445 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -8,6 +8,7 @@ import { } from '@tradinggoose/db/schema' import { and, count, eq, inArray, isNull } from 'drizzle-orm' import { checkStorageQuota, incrementStorageUsage } from '@/lib/billing/storage' +import { ENTITY_KIND_KNOWLEDGE_BASE } from '@/lib/copilot/review-sessions/types' import { enqueueDocumentProcessingJobs } from '@/lib/knowledge/documents/service' import { copyKnowledgeDocumentFile, @@ -20,6 +21,13 @@ import type { } from '@/lib/knowledge/types' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/permissions/utils' +import { + applySavedEntityYjsStateToRow, + applySavedEntityYjsStateToRows, + savedEntityRowToFields, +} from '@/lib/yjs/entity-state' +import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('KnowledgeBaseService') @@ -58,11 +66,14 @@ export async function getKnowledgeBases( .groupBy(knowledgeBase.id) .orderBy(knowledgeBase.createdAt) - return knowledgeBasesWithCounts.map((kb) => ({ - ...kb, - chunkingConfig: kb.chunkingConfig as ChunkingConfig, - docCount: Number(kb.docCount), - })) + return applySavedEntityYjsStateToRows( + ENTITY_KIND_KNOWLEDGE_BASE, + knowledgeBasesWithCounts.map((kb) => ({ + ...kb, + chunkingConfig: kb.chunkingConfig as ChunkingConfig, + docCount: Number(kb.docCount), + })) + ) } /** @@ -99,7 +110,7 @@ export async function createKnowledgeBase( logger.info(`[${requestId}] Created knowledge base: ${data.name} (${kbId})`) - return { + const created = { id: kbId, name: data.name, description: data.description ?? null, @@ -112,6 +123,14 @@ export async function createKnowledgeBase( workspaceId: data.workspaceId, docCount: 0, } + + await applySavedEntityState( + ENTITY_KIND_KNOWLEDGE_BASE, + created.id, + savedEntityRowToFields(ENTITY_KIND_KNOWLEDGE_BASE, created) + ) + + return created } export async function copyKnowledgeBaseToWorkspace( @@ -134,6 +153,10 @@ export async function copyKnowledgeBaseToWorkspace( if (!sourceKnowledgeBase) { throw new Error(`Knowledge base ${sourceKnowledgeBaseId} not found`) } + const sourceKnowledgeBaseFields = await applySavedEntityYjsStateToRow( + ENTITY_KIND_KNOWLEDGE_BASE, + sourceKnowledgeBase + ) const sourceDocuments = await db .select() @@ -181,12 +204,12 @@ export async function copyKnowledgeBaseToWorkspace( id: newKnowledgeBaseId, userId, workspaceId: targetWorkspaceId, - name: `${sourceKnowledgeBase.name} (Copy)`, - description: sourceKnowledgeBase.description, + name: `${sourceKnowledgeBaseFields.name} (Copy)`, + description: sourceKnowledgeBaseFields.description, tokenCount: sourceKnowledgeBase.tokenCount, embeddingModel: sourceKnowledgeBase.embeddingModel, embeddingDimension: sourceKnowledgeBase.embeddingDimension, - chunkingConfig: sourceKnowledgeBase.chunkingConfig, + chunkingConfig: sourceKnowledgeBaseFields.chunkingConfig, createdAt: now, updatedAt: now, deletedAt: null, @@ -228,9 +251,10 @@ export async function copyKnowledgeBaseToWorkspace( mimeType: sourceDocument.mimeType, }, processingOptions: { - chunkSize: (sourceKnowledgeBase.chunkingConfig as ChunkingConfig).maxSize, - minCharactersPerChunk: (sourceKnowledgeBase.chunkingConfig as ChunkingConfig).minSize, - chunkOverlap: (sourceKnowledgeBase.chunkingConfig as ChunkingConfig).overlap, + chunkSize: (sourceKnowledgeBaseFields.chunkingConfig as ChunkingConfig).maxSize, + minCharactersPerChunk: (sourceKnowledgeBaseFields.chunkingConfig as ChunkingConfig) + .minSize, + chunkOverlap: (sourceKnowledgeBaseFields.chunkingConfig as ChunkingConfig).overlap, }, requestId, }) @@ -333,93 +357,48 @@ export async function copyKnowledgeBaseToWorkspace( `[${requestId}] Copied knowledge base ${sourceKnowledgeBaseId} to workspace ${targetWorkspaceId} as ${newKnowledgeBaseId}` ) - return { + const copied = { id: newKnowledgeBaseId, - name: `${sourceKnowledgeBase.name} (Copy)`, - description: sourceKnowledgeBase.description, + name: `${sourceKnowledgeBaseFields.name} (Copy)`, + description: sourceKnowledgeBaseFields.description, tokenCount: sourceKnowledgeBase.tokenCount, embeddingModel: sourceKnowledgeBase.embeddingModel, embeddingDimension: sourceKnowledgeBase.embeddingDimension, - chunkingConfig: sourceKnowledgeBase.chunkingConfig as ChunkingConfig, + chunkingConfig: sourceKnowledgeBaseFields.chunkingConfig as ChunkingConfig, createdAt: now, updatedAt: now, workspaceId: targetWorkspaceId, docCount: sourceDocuments.length, } + + await applySavedEntityState( + ENTITY_KIND_KNOWLEDGE_BASE, + copied.id, + savedEntityRowToFields(ENTITY_KIND_KNOWLEDGE_BASE, copied) + ) + + return copied } -/** - * Update a knowledge base - */ -export async function updateKnowledgeBase( +export async function applyKnowledgeBaseMetadata( knowledgeBaseId: string, - updates: { - name?: string - description?: string - chunkingConfig?: { - maxSize: number - minSize: number - overlap: number - } + fields: { + name: string + description: string + chunkingConfig: ChunkingConfig }, requestId: string ): Promise { - const now = new Date() - const updateData: { - updatedAt: Date - name?: string - description?: string | null - chunkingConfig?: { - maxSize: number - minSize: number - overlap: number - } - } = { - updatedAt: now, - } - - if (updates.name !== undefined) updateData.name = updates.name - if (updates.description !== undefined) updateData.description = updates.description - if (updates.chunkingConfig !== undefined) { - updateData.chunkingConfig = updates.chunkingConfig - } - - await db.update(knowledgeBase).set(updateData).where(eq(knowledgeBase.id, knowledgeBaseId)) - - const updatedKb = await db - .select({ - id: knowledgeBase.id, - name: knowledgeBase.name, - description: knowledgeBase.description, - tokenCount: knowledgeBase.tokenCount, - embeddingModel: knowledgeBase.embeddingModel, - embeddingDimension: knowledgeBase.embeddingDimension, - chunkingConfig: knowledgeBase.chunkingConfig, - createdAt: knowledgeBase.createdAt, - updatedAt: knowledgeBase.updatedAt, - workspaceId: knowledgeBase.workspaceId, - docCount: count(document.id), - }) - .from(knowledgeBase) - .leftJoin( - document, - and(eq(document.knowledgeBaseId, knowledgeBase.id), isNull(document.deletedAt)) - ) - .where(eq(knowledgeBase.id, knowledgeBaseId)) - .groupBy(knowledgeBase.id) - .limit(1) - - if (updatedKb.length === 0) { + const existing = await getKnowledgeBaseById(knowledgeBaseId) + if (!existing) { throw new Error(`Knowledge base ${knowledgeBaseId} not found`) } - logger.info(`[${requestId}] Updated knowledge base: ${knowledgeBaseId}`) + await applySavedEntityState(ENTITY_KIND_KNOWLEDGE_BASE, knowledgeBaseId, fields) - return { - ...updatedKb[0], - chunkingConfig: updatedKb[0].chunkingConfig as ChunkingConfig, - docCount: Number(updatedKb[0].docCount), - } + logger.info(`[${requestId}] Applied knowledge base metadata through Yjs: ${knowledgeBaseId}`) + + return (await getKnowledgeBaseById(knowledgeBaseId)) ?? existing } /** @@ -455,11 +434,11 @@ export async function getKnowledgeBaseById( return null } - return { + return applySavedEntityYjsStateToRow(ENTITY_KIND_KNOWLEDGE_BASE, { ...result[0], chunkingConfig: result[0].chunkingConfig as ChunkingConfig, docCount: Number(result[0].docCount), - } + }) } /** @@ -471,6 +450,7 @@ export async function deleteKnowledgeBase( ): Promise { const now = new Date() + await deleteYjsSessionInSocketServer(knowledgeBaseId) await db .update(knowledgeBase) .set({ diff --git a/apps/tradinggoose/lib/yjs/entity-session.ts b/apps/tradinggoose/lib/yjs/entity-session.ts index 938722bf8..8ec4e916d 100644 --- a/apps/tradinggoose/lib/yjs/entity-session.ts +++ b/apps/tradinggoose/lib/yjs/entity-session.ts @@ -12,6 +12,7 @@ * - skill: name, description, content * - custom_tool: title, schemaText (Y.Text), codeText (Y.Text) * - indicator: name, color, pineCode (Y.Text), inputMeta + * - knowledge_base: name, description, chunkingConfig * - mcp_server: name, description, transport, url, headers, command, * args, env, timeout, retries, enabled */ @@ -88,6 +89,12 @@ export function seedEntitySession(doc: Y.Doc, options: EntitySessionSeedOptions) break } + case 'knowledge_base': + fields.set('name', payload.name ?? '') + fields.set('description', payload.description ?? '') + fields.set('chunkingConfig', payload.chunkingConfig) + break + case 'mcp_server': fields.set('name', payload.name ?? MCP_SERVER_DEFAULTS.name) fields.set('description', payload.description ?? MCP_SERVER_DEFAULTS.description) @@ -136,6 +143,12 @@ export function getEntityFields(doc: Y.Doc, entityKind: ReviewEntityKind): Recor result.inputMeta = fields.get('inputMeta') break + case 'knowledge_base': + result.name = fields.get('name') ?? '' + result.description = fields.get('description') ?? '' + result.chunkingConfig = fields.get('chunkingConfig') + break + case 'mcp_server': result.name = fields.get('name') ?? MCP_SERVER_DEFAULTS.name result.description = fields.get('description') ?? MCP_SERVER_DEFAULTS.description diff --git a/apps/tradinggoose/lib/yjs/entity-state.ts b/apps/tradinggoose/lib/yjs/entity-state.ts index 26e9aae1d..e7f2e3286 100644 --- a/apps/tradinggoose/lib/yjs/entity-state.ts +++ b/apps/tradinggoose/lib/yjs/entity-state.ts @@ -1,12 +1,9 @@ import * as Y from 'yjs' -import type { - ReviewEntityKind, - ReviewTargetDescriptor, -} from '@/lib/copilot/review-sessions/types' import { buildYjsTransportEnvelope, serializeYjsTransportEnvelope, } from '@/lib/copilot/review-sessions/identity' +import type { ReviewEntityKind, ReviewTargetDescriptor } from '@/lib/copilot/review-sessions/types' import { getEntityFields } from '@/lib/yjs/entity-session' import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' @@ -69,6 +66,12 @@ export function savedEntityRowToFields( ? row.inputMeta : null, } + case 'knowledge_base': + return { + name: row.name ?? '', + description: row.description ?? '', + chunkingConfig: row.chunkingConfig, + } case 'mcp_server': return { name: row.name ?? '', @@ -122,6 +125,13 @@ export function applySavedEntityFieldsToRow( ? fields.inputMeta : null, } + case 'knowledge_base': + return { + ...row, + name: String(fields.name ?? ''), + description: String(fields.description ?? ''), + chunkingConfig: fields.chunkingConfig, + } case 'mcp_server': return { ...row, diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index d1d35622b..33adbedb4 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -6,27 +6,28 @@ import { buildYjsTransportEnvelope, serializeYjsTransportEnvelope, } from '@/lib/copilot/review-sessions/identity' -import { - loadCustomTool, - loadIndicator, - loadMcpServer, - loadSkill, -} from '@/lib/yjs/server/entity-loaders' +import { getReviewTargetRuntimeState } from '@/lib/copilot/review-sessions/runtime' import type { ResolvedReviewTarget, ReviewTargetDescriptor, ReviewTargetRuntimeState, } from '@/lib/copilot/review-sessions/types' -import { getReviewTargetRuntimeState } from '@/lib/copilot/review-sessions/runtime' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' import { seedEntitySession } from '@/lib/yjs/entity-session' +import { + loadCustomTool, + loadIndicator, + loadKnowledgeBase, + loadMcpServer, + loadSkill, +} from '@/lib/yjs/server/entity-loaders' +import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' +import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' import { getMetadataMap as readWorkflowMetadataMap, setVariables, setWorkflowState, } from '@/lib/yjs/workflow-session' -import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' -import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' import { getState as getPersistedYjsState } from '@/socket-server/yjs/persistence' export class ReviewTargetBootstrapError extends Error { @@ -207,7 +208,11 @@ async function bootstrapWorkflowTarget( const doc = await getBootstrapDoc(workflowId) setWorkflowState(doc, workflowSnapshot, 'bootstrap') - setVariables(doc, ((workflowRow.variables as Record | null) ?? {}) as Record, 'bootstrap') + setVariables( + doc, + ((workflowRow.variables as Record | null) ?? {}) as Record, + 'bootstrap' + ) doc.transact(() => { const metadata = readWorkflowMetadataMap(doc) @@ -317,6 +322,21 @@ async function loadCanonicalEntitySeed(descriptor: ReviewTargetDescriptor): Prom }, } } + case 'knowledge_base': { + const row = await loadKnowledgeBase(descriptor.entityId!, descriptor.workspaceId!) + if (!row) { + throw new ReviewTargetBootstrapError(404, 'Knowledge base target no longer exists') + } + + return { + workspaceId: row.workspaceId, + payload: { + name: row.name, + description: row.description ?? '', + chunkingConfig: row.chunkingConfig, + }, + } + } case 'mcp_server': { const row = await loadMcpServer(descriptor.entityId!, descriptor.workspaceId!) if (!row) { @@ -336,8 +356,7 @@ async function loadCanonicalEntitySeed(descriptor: ReviewTargetDescriptor): Prom : {}, command: row.command ?? '', args: Array.isArray(row.args) ? row.args : [], - env: - row.env && typeof row.env === 'object' && !Array.isArray(row.env) ? row.env : {}, + env: row.env && typeof row.env === 'object' && !Array.isArray(row.env) ? row.env : {}, timeout: row.timeout ?? 30000, retries: row.retries ?? 3, enabled: row.enabled ?? true, diff --git a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts index 9bc143c60..4198d4810 100644 --- a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts +++ b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts @@ -1,5 +1,11 @@ import { db } from '@tradinggoose/db' -import { customTools, mcpServers, pineIndicators, skill } from '@tradinggoose/db/schema' +import { + customTools, + knowledgeBase, + mcpServers, + pineIndicators, + skill, +} from '@tradinggoose/db/schema' import { and, eq, isNull } from 'drizzle-orm' import type { SavedEntityKind } from '@/lib/yjs/entity-state' @@ -32,6 +38,14 @@ export async function resolveEntityWorkspaceId( .limit(1) return row?.workspaceId ?? null } + case 'knowledge_base': { + const [row] = await db + .select({ workspaceId: knowledgeBase.workspaceId }) + .from(knowledgeBase) + .where(and(eq(knowledgeBase.id, entityId), isNull(knowledgeBase.deletedAt))) + .limit(1) + return row?.workspaceId ?? null + } case 'mcp_server': { const [row] = await db .select({ workspaceId: mcpServers.workspaceId }) @@ -73,6 +87,22 @@ export async function loadIndicator(entityId: string, workspaceId: string) { return row ?? null } +export async function loadKnowledgeBase(entityId: string, workspaceId: string) { + const [row] = await db + .select() + .from(knowledgeBase) + .where( + and( + eq(knowledgeBase.id, entityId), + eq(knowledgeBase.workspaceId, workspaceId), + isNull(knowledgeBase.deletedAt) + ) + ) + .limit(1) + + return row ?? null +} + export async function loadMcpServer(entityId: string, workspaceId: string) { const [row] = await db .select() diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index 36c238f26..e30f5a45e 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -189,6 +189,7 @@ function parseApplyEntityStateRequest(body: unknown): ApplyEntityStateRequest { candidate.entityKind !== 'skill' && candidate.entityKind !== 'custom_tool' && candidate.entityKind !== 'indicator' && + candidate.entityKind !== 'knowledge_base' && candidate.entityKind !== 'mcp_server' ) { throw new InvalidInternalYjsRequestError('Invalid entityKind') diff --git a/apps/tradinggoose/stores/copilot/store.test.ts b/apps/tradinggoose/stores/copilot/store.test.ts index fc859bec6..98be421a4 100644 --- a/apps/tradinggoose/stores/copilot/store.test.ts +++ b/apps/tradinggoose/stores/copilot/store.test.ts @@ -1298,6 +1298,11 @@ describe('copilot streaming regressions', () => { const toolCallId = 'edit-workflow-limited-tool' const assistantMessageId = 'assistant-message-limited-edit' const reviewResult = { + requiresReview: true, + entityKind: 'workflow', + entityId: 'wf-limited-edit', + entityDocument: 'flowchart TD', + documentFormat: 'tg-workflow-graph-mermaid-v1', workflowState: { blocks: {}, edges: [], @@ -1305,19 +1310,19 @@ describe('copilot streaming regressions', () => { parallels: {}, }, } - let toolState = ClientToolCallState.pending - const fakeTool: any = { - persistedToolCall: {} as any, - setExecutionContext: vi.fn(), - hydratePersistedToolCall: vi.fn(), - handleUserAction: vi.fn(async () => { - toolState = ClientToolCallState.review - fakeTool.persistedToolCall = { result: reviewResult } - }), - getState: vi.fn(() => toolState), - } + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + if (url === '/api/copilot/execute-copilot-server-tool') { + return { + ok: true, + status: 200, + json: async () => ({ success: true, result: reviewResult }), + } + } - registerClientTool(toolCallId, fakeTool) + throw new Error(`Unexpected fetch: ${url}`) + }) + vi.stubGlobal('fetch', fetchMock) try { const store = getCopilotStore('copilot-limited-access-edit-workflow') @@ -1355,12 +1360,15 @@ describe('copilot streaming regressions', () => { ) await vi.advanceTimersByTimeAsync(0) - expect(fakeTool.handleUserAction).toHaveBeenCalledTimes(1) expect(store.getState().toolCallsById[toolCallId]?.state).toBe(ClientToolCallState.review) expect(store.getState().toolCallsById[toolCallId]?.result).toEqual(reviewResult) - expect(store.getState().isSendingMessage).toBe(true) + expect(fetchMock).toHaveBeenCalledWith( + '/api/copilot/execute-copilot-server-tool', + expect.objectContaining({ + method: 'POST', + }) + ) } finally { - unregisterClientTool(toolCallId) vi.useRealTimers() } }) @@ -2101,11 +2109,18 @@ describe('copilot streaming regressions', () => { } } - if (url === '/api/workflows?workspaceId=workspace-1') { + if (url === '/api/copilot/execute-copilot-server-tool') { return { ok: true, status: 200, - json: async () => ({ data: [] }), + json: async () => ({ + success: true, + result: { + entityKind: 'workflow', + entities: [], + count: 0, + }, + }), } } @@ -2167,8 +2182,18 @@ describe('copilot streaming regressions', () => { await store.getState().executeCopilotToolCall(toolCallId) - expect(fetchMock).toHaveBeenCalledWith('/api/workflows?workspaceId=workspace-1', { - method: 'GET', + const executeRequest = fetchMock.mock.calls.find(([input]) => { + const url = typeof input === 'string' ? input : input.toString() + return url === '/api/copilot/execute-copilot-server-tool' + }) + expect(parseJsonRequestBody(executeRequest)).toEqual({ + toolName: 'list_workflows', + payload: { + workspaceId: 'workspace-1', + }, + context: { + workspaceId: 'workspace-1', + }, }) }) @@ -2857,28 +2882,36 @@ describe('copilot tool user action delegation', () => { resetCopilotWorkspaceSelectionState() }) - it('delegates pending tool execution to the client tool user-action handler', async () => { + it('routes pending server-managed workflow edits through server tool execution', async () => { const channelId = 'copilot-edit-workflow-order' const toolCallId = 'edit-workflow-order-tool' const store = getCopilotStore(channelId) - const calls: string[] = [] - const fakeTool = { - setExecutionContext: vi.fn(), - handleUserAction: vi.fn(async () => { - calls.push('userAction') - }), - execute: vi.fn(async () => { - calls.push('execute') - }), - handleAccept: vi.fn(async () => { - calls.push('accept') - }), - handleReject: vi.fn(async () => { - calls.push('reject') - }), + const reviewResult = { + requiresReview: true, + entityKind: 'workflow', + entityId: 'wf-edit-workflow-order', + entityDocument: + 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', + documentFormat: 'tg-workflow-graph-mermaid-v1', + workflowState: { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + }, } - - registerClientTool(toolCallId, fakeTool) + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + if (url === '/api/copilot/execute-copilot-server-tool') { + return { + ok: true, + status: 200, + json: async () => ({ success: true, result: reviewResult }), + } + } + throw new Error(`Unexpected fetch: ${url}`) + }) + vi.stubGlobal('fetch', fetchMock) store.setState({ currentChat: { @@ -2910,34 +2943,58 @@ describe('copilot tool user action delegation', () => { await store.getState().executeCopilotToolCall(toolCallId) - expect(calls).toEqual(['userAction']) - expect(store.getState().isSendingMessage).toBe(true) - - unregisterClientTool(toolCallId) + const executeRequest = fetchMock.mock.calls.find(([input]) => { + const url = typeof input === 'string' ? input : input.toString() + return url === '/api/copilot/execute-copilot-server-tool' + }) + expect(parseJsonRequestBody(executeRequest)).toEqual({ + toolName: 'edit_workflow', + payload: { + entityDocument: + 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', + entityId: 'wf-edit-workflow-order', + }, + }) + expect(store.getState().toolCallsById[toolCallId]?.state).toBe(ClientToolCallState.review) }) - it('delegates review-state tool execution to the same client tool user-action handler', async () => { + it('accepts review-state server-managed workflow edits through the review accept path', async () => { const channelId = 'copilot-edit-workflow-review' const toolCallId = 'edit-workflow-review-tool' const store = getCopilotStore(channelId) - const calls: string[] = [] - const fakeTool = { - setExecutionContext: vi.fn(), - handleUserAction: vi.fn(async () => { - calls.push('userAction') - }), - execute: vi.fn(async () => { - calls.push('execute') - }), - handleAccept: vi.fn(async () => { - calls.push('accept') - }), - handleReject: vi.fn(async () => { - calls.push('reject') - }), + const reviewResult = { + requiresReview: true, + entityKind: 'workflow', + entityId: 'wf-edit-workflow-review', + entityDocument: + 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', + documentFormat: 'tg-workflow-graph-mermaid-v1', + workflowState: { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + }, } - - registerClientTool(toolCallId, fakeTool) + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + if (url === '/api/copilot/execute-copilot-server-tool') { + return { + ok: true, + status: 200, + json: async () => ({ success: true, result: { ...reviewResult, success: true } }), + } + } + if (url === '/api/copilot/tools/mark-complete') { + return { + ok: true, + status: 200, + json: async () => ({ success: true }), + } + } + throw new Error(`Unexpected fetch: ${url}`) + }) + vi.stubGlobal('fetch', fetchMock) store.setState({ currentChat: { @@ -2963,23 +3020,23 @@ describe('copilot tool user action delegation', () => { 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', entityId: 'wf-edit-workflow-review', }, - result: { - workflowState: { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - }, - }, + result: reviewResult, } as any, }, }) await store.getState().executeCopilotToolCall(toolCallId) - expect(calls).toEqual(['userAction']) - - unregisterClientTool(toolCallId) + const executeRequest = fetchMock.mock.calls.find(([input]) => { + const url = typeof input === 'string' ? input : input.toString() + return url === '/api/copilot/execute-copilot-server-tool' + }) + expect(parseJsonRequestBody(executeRequest)).toEqual({ + toolName: 'edit_workflow', + reviewAction: 'accept', + reviewResult, + }) + expect(store.getState().toolCallsById[toolCallId]?.state).toBe(ClientToolCallState.success) }) it('auto-executes pending reviewed API tools when access switches to full', async () => { diff --git a/apps/tradinggoose/stores/copilot/store.ts b/apps/tradinggoose/stores/copilot/store.ts index e756aff7d..2c6463063 100644 --- a/apps/tradinggoose/stores/copilot/store.ts +++ b/apps/tradinggoose/stores/copilot/store.ts @@ -16,8 +16,10 @@ import { } from '@/lib/copilot/tools/client/base-tool' import { registerToolStateSync } from '@/lib/copilot/tools/client/manager' import { + acceptCopilotServerToolReview, executeCopilotServerTool, getCopilotServerToolErrorStatus, + isCopilotServerToolReviewResult, } from '@/lib/copilot/tools/client/server-tool-response' import { createLogger } from '@/lib/logs/console/logger' import { @@ -1383,6 +1385,7 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) logger.info('[toolCallsById] pending → executing (copilot tool)', { id, name }) if (isServerManagedCopilotTool(name)) { + const acceptingServerReview = toolCall.state === ClientToolCallState.review try { const serverContext = { ...(provenance?.contextEntityKind && provenance?.contextEntityId @@ -1393,12 +1396,20 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) : {}), ...(provenance?.workspaceId ? { workspaceId: provenance.workspaceId } : {}), } - const result = await executeCopilotServerTool({ - toolName: name, - payload: preparedArgs, - context: serverContext, - signal: get().abortController?.signal, - }) + const result = + acceptingServerReview + ? await acceptCopilotServerToolReview({ + toolName: name, + reviewResult: get().toolCallsById[id]?.result, + context: serverContext, + signal: get().abortController?.signal, + }) + : await executeCopilotServerTool({ + toolName: name, + payload: preparedArgs, + context: serverContext, + signal: get().abortController?.signal, + }) const logicalSuccess = !result || typeof result !== 'object' || @@ -1406,7 +1417,23 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) (result as any).success !== false const currentToolCall = get().toolCallsById[id] - if (isToolCallCompletionProtected(currentToolCall?.state)) { + if ( + isToolCallCompletionProtected(currentToolCall?.state) && + !acceptingServerReview + ) { + return + } + + if ( + !acceptingServerReview && + logicalSuccess && + isCopilotServerToolReviewResult(result) + ) { + applyToolStateUpdate(targetStore, id, ClientToolCallState.review, { result }) + + if (!shouldRequireToolApproval(get().accessLevel, true)) { + await get().executeCopilotToolCall(id) + } return } @@ -1442,7 +1469,7 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) return } catch (error) { const errorMap = { ...get().toolCallsById } - if (isToolCallCompletionProtected(errorMap[id]?.state)) { + if (isToolCallCompletionProtected(errorMap[id]?.state) && !acceptingServerReview) { return } diff --git a/apps/tradinggoose/stores/copilot/tool-registry.test.ts b/apps/tradinggoose/stores/copilot/tool-registry.test.ts index 41248271d..ee1c15a84 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.test.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.test.ts @@ -1,6 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' -import { unregisterClientTool } from '@/lib/copilot/tools/client/manager' +import { describe, expect, it } from 'vitest' import { createExecutionContext, ensureClientToolInstance, @@ -12,65 +10,13 @@ import { describe('tool-registry', () => { const toolCallId = 'tool-registry-edit-workflow' - afterEach(() => { - unregisterClientTool(toolCallId) - }) - - it('does not block edit_workflow execution before staged review exists', () => { - const instance = ensureClientToolInstance('edit_workflow', toolCallId) - - expect(instance).toBeDefined() - expect(getToolInterruptDisplays('edit_workflow', toolCallId)).toBeUndefined() - - instance?.setState(ClientToolCallState.review, { - result: { - workflowState: { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - }, - }, - }) - - expect(getToolInterruptDisplays('edit_workflow', toolCallId)).toBeDefined() - }) - - it('rehydrates review interrupts from persisted workflow tool state', () => { - const instance = ensureClientToolInstance('edit_workflow', toolCallId) - - instance?.hydratePersistedToolCall({ - id: toolCallId, - name: 'edit_workflow', - state: ClientToolCallState.review, - result: { - workflowState: { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - }, - }, - }) - + it('keeps workflow edit tools server-managed while exposing review interrupts from metadata', () => { + expect(ensureClientToolInstance('edit_workflow', toolCallId)).toBeUndefined() expect(getToolInterruptDisplays('edit_workflow', toolCallId)).toBeDefined() - }) - - it('surfaces review interrupts for edit_workflow_block once staged', () => { - const instance = ensureClientToolInstance('edit_workflow_block', toolCallId) - - instance?.setState(ClientToolCallState.review, { - result: { - workflowState: { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - }, - }, - }) - + expect(ensureClientToolInstance('edit_workflow_block', toolCallId)).toBeUndefined() expect(getToolInterruptDisplays('edit_workflow_block', toolCallId)).toBeDefined() + expect(ensureClientToolInstance('edit_workflow_variable', toolCallId)).toBeUndefined() + expect(getToolInterruptDisplays('edit_workflow_variable', toolCallId)).toBeDefined() }) it('requires explicit target args instead of injecting ambient entity context', () => { @@ -88,11 +34,11 @@ describe('tool-registry', () => { ).toEqual({ entityId: 'wf-explicit' }) }) - it('preserves only explicit server-routed GDrive args', () => { + it('injects hosted workspace context for workspace-targeted GDrive tools', () => { const context = createExecutionContext({ toolCallId, toolName: 'read_gdrive_file', - provenance: {}, + provenance: { workspaceId: 'workspace-1' }, }) expect( @@ -101,21 +47,122 @@ describe('tool-registry', () => { { credentialId: 'credential-1', fileId: 'file-1', type: 'doc' }, context ) - ).toEqual({ credentialId: 'credential-1', fileId: 'file-1', type: 'doc' }) + ).toEqual({ + workspaceId: 'workspace-1', + credentialId: 'credential-1', + fileId: 'file-1', + type: 'doc', + }) + }) + + it('requires workspace context for workspace-targeted GDrive tools', () => { + const context = createExecutionContext({ + toolCallId, + toolName: 'read_gdrive_file', + provenance: {}, + }) + + expect(() => + prepareCopilotToolArgs( + 'read_gdrive_file', + { credentialId: 'credential-1', fileId: 'file-1', type: 'doc' }, + context + ) + ).toThrow() + }) + + it('injects hosted workspace context into workspace-targeted knowledge base tools', () => { + const context = createExecutionContext({ + toolCallId, + toolName: 'list_knowledge_bases', + provenance: { workspaceId: 'workspace-1' }, + }) + + expect(prepareCopilotToolArgs('list_knowledge_bases', {}, context)).toEqual({ + workspaceId: 'workspace-1', + }) + + expect( + prepareCopilotToolArgs( + 'create_knowledge_base', + { + entityDocument: + '{"name":"Research","description":"","chunkingConfig":{"maxSize":1024,"minSize":1,"overlap":200}}', + documentFormat: 'tg-knowledge-base-document-v1', + }, + context + ) + ).toEqual({ + workspaceId: 'workspace-1', + entityDocument: + '{"name":"Research","description":"","chunkingConfig":{"maxSize":1024,"minSize":1,"overlap":200}}', + documentFormat: 'tg-knowledge-base-document-v1', + }) + }) + + it('requires workspaceId for local knowledge base list tools', () => { + const context = createExecutionContext({ + toolCallId, + toolName: 'list_knowledge_bases', + provenance: {}, + }) + + expect(() => prepareCopilotToolArgs('list_knowledge_bases', {}, context)).toThrow() }) it('classifies gated and non-gated tools explicitly', () => { expect(isGatedTool('make_api_request')).toBe(true) expect(isGatedTool('edit_workflow')).toBe(false) expect(isGatedTool('edit_workflow_block')).toBe(false) - expect(isGatedTool('edit_skill')).toBe(false) - expect(isGatedTool('edit_indicator')).toBe(false) - expect(isGatedTool('edit_custom_tool')).toBe(false) - expect(isGatedTool('edit_mcp_server')).toBe(false) + expect(isGatedTool('edit_workflow_variable')).toBe(false) + expect(isGatedTool('edit_skill')).toBe(true) + expect(isGatedTool('edit_indicator')).toBe(true) + expect(isGatedTool('edit_custom_tool')).toBe(true) + expect(isGatedTool('edit_mcp_server')).toBe(true) + expect(isGatedTool('list_knowledge_bases')).toBe(false) + expect(isGatedTool('read_knowledge_base')).toBe(false) + expect(isGatedTool('create_knowledge_base')).toBe(true) + expect(isGatedTool('edit_knowledge_base')).toBe(true) + expect(isGatedTool('rename_knowledge_base')).toBe(true) + expect(isGatedTool('query_knowledge_base')).toBe(false) + expect(isGatedTool('edit_monitor')).toBe(true) expect(isGatedTool('checkoff_todo')).toBe(false) expect(isGatedTool('mark_todo_in_progress')).toBe(false) expect(isGatedTool('get_blocks_metadata')).toBe(false) expect(isGatedTool('get_agent_accessory_catalog')).toBe(false) expect(isGatedTool('unknown_integration_tool')).toBe(true) }) + + it('keeps saved entity and workflow document tools off the client-staged execution path', () => { + expect(ensureClientToolInstance('create_workflow', 'create-workflow-tool')).toBeUndefined() + expect(ensureClientToolInstance('edit_workflow', 'edit-workflow-tool')).toBeUndefined() + expect(ensureClientToolInstance('edit_workflow_block', 'edit-workflow-block-tool')).toBeUndefined() + expect(ensureClientToolInstance('edit_workflow_variable', 'edit-workflow-variable-tool')).toBeUndefined() + expect(ensureClientToolInstance('rename_workflow', 'rename-workflow-tool')).toBeUndefined() + expect(ensureClientToolInstance('read_workflow', 'read-workflow-tool')).toBeUndefined() + expect(ensureClientToolInstance('list_workflows', 'list-workflows-tool')).toBeUndefined() + expect(ensureClientToolInstance('edit_skill', 'edit-skill-tool')).toBeUndefined() + expect(ensureClientToolInstance('edit_indicator', 'edit-indicator-tool')).toBeUndefined() + expect(ensureClientToolInstance('edit_custom_tool', 'edit-custom-tool-tool')).toBeUndefined() + expect(ensureClientToolInstance('edit_mcp_server', 'edit-mcp-server-tool')).toBeUndefined() + expect(ensureClientToolInstance('list_knowledge_bases', 'list-kb-tool')).toBeUndefined() + expect(ensureClientToolInstance('read_knowledge_base', 'read-kb-tool')).toBeUndefined() + expect(ensureClientToolInstance('create_knowledge_base', 'create-kb-tool')).toBeUndefined() + expect(ensureClientToolInstance('edit_knowledge_base', 'edit-kb-tool')).toBeUndefined() + expect(ensureClientToolInstance('rename_knowledge_base', 'rename-kb-tool')).toBeUndefined() + expect(ensureClientToolInstance('query_knowledge_base', 'query-kb-tool')).toBeUndefined() + expect(ensureClientToolInstance('list_monitors', 'list-monitors-tool')).toBeUndefined() + expect(ensureClientToolInstance('read_monitor', 'read-monitor-tool')).toBeUndefined() + expect(ensureClientToolInstance('edit_monitor', 'edit-monitor-tool')).toBeUndefined() + expect( + ensureClientToolInstance('check_deployment_status', 'check-deployment-status-tool') + ).toBeUndefined() + expect(ensureClientToolInstance('read_block_outputs', 'read-block-outputs-tool')).toBeUndefined() + expect( + ensureClientToolInstance( + 'read_block_upstream_references', + 'read-block-upstream-references-tool' + ) + ).toBeUndefined() + }) }) diff --git a/apps/tradinggoose/stores/copilot/tool-registry.ts b/apps/tradinggoose/stores/copilot/tool-registry.ts index b94dedd54..a93b46673 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.ts @@ -6,56 +6,19 @@ import type { ClientToolDisplay, ClientToolExecutionContext, } from '@/lib/copilot/tools/client/base-tool' -import { - CreateCustomToolClientTool, - CreateIndicatorClientTool, - CreateMcpServerClientTool, - CreateSkillClientTool, - EditCustomToolClientTool, - EditIndicatorClientTool, - EditMcpServerClientTool, - EditSkillClientTool, - ListCustomToolsClientTool, - ListIndicatorsClientTool, - ListMcpServersClientTool, - ListSkillsClientTool, - ReadCustomToolClientTool, - ReadIndicatorClientTool, - ReadMcpServerClientTool, - ReadSkillClientTool, - RenameCustomToolClientTool, - RenameIndicatorClientTool, - RenameMcpServerClientTool, - RenameSkillClientTool, -} from '@/lib/copilot/tools/client/entities/entity-document-tools' import { GDriveRequestAccessClientTool } from '@/lib/copilot/tools/client/google/gdrive-request-access' -import { KnowledgeBaseClientTool } from '@/lib/copilot/tools/client/knowledge/knowledge-base' import { getClientTool, registerClientTool } from '@/lib/copilot/tools/client/manager' -import { EditMonitorClientTool } from '@/lib/copilot/tools/client/monitor/edit-monitor' -import { ListMonitorsClientTool } from '@/lib/copilot/tools/client/monitor/list-monitors' -import { ReadMonitorClientTool } from '@/lib/copilot/tools/client/monitor/read-monitor' import { CheckoffTodoClientTool } from '@/lib/copilot/tools/client/other/checkoff-todo' import { MarkTodoInProgressClientTool } from '@/lib/copilot/tools/client/other/mark-todo-in-progress' import { OAuthRequestAccessClientTool } from '@/lib/copilot/tools/client/other/oauth-request-access' import { PlanClientTool } from '@/lib/copilot/tools/client/other/plan' import { SleepClientTool } from '@/lib/copilot/tools/client/other/sleep' import { SERVER_TOOL_METADATA } from '@/lib/copilot/tools/client/server-tool-metadata' -import { CheckDeploymentStatusClientTool } from '@/lib/copilot/tools/client/workflow/check-deployment-status' -import { CreateWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/create-workflow' import { DeployWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/deploy-workflow' -import { EditWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/edit-workflow' -import { EditWorkflowBlockClientTool } from '@/lib/copilot/tools/client/workflow/edit-workflow-block' -import { ListWorkflowsClientTool } from '@/lib/copilot/tools/client/workflow/list-workflows' -import { ReadBlockOutputsClientTool } from '@/lib/copilot/tools/client/workflow/read-block-outputs' -import { ReadBlockUpstreamReferencesClientTool } from '@/lib/copilot/tools/client/workflow/read-block-upstream-references' -import { ReadWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/read-workflow' -import { ReadWorkflowVariablesClientTool } from '@/lib/copilot/tools/client/workflow/read-workflow-variables' -import { RenameWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/rename-workflow' import { RunWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/run-workflow' -import { SetWorkflowVariablesClientTool } from '@/lib/copilot/tools/client/workflow/set-workflow-variables' import { createLogger } from '@/lib/logs/console/logger' -import { useEnvironmentStore } from '@/stores/settings/environment/store' import type { CopilotToolExecutionProvenance } from '@/stores/copilot/types' +import { useEnvironmentStore } from '@/stores/settings/environment/store' const logger = createLogger('CopilotToolRegistry') @@ -114,30 +77,35 @@ const COPILOT_TOOL_REGISTRY: Record = { [CopilotTool.read_environment_variables]: serverTool(CopilotTool.read_environment_variables), set_environment_variables: serverTool('set_environment_variables', true), [CopilotTool.read_credentials]: serverTool(CopilotTool.read_credentials), - knowledge_base: clientTool(KnowledgeBaseClientTool, true), - list_custom_tools: clientTool(ListCustomToolsClientTool), - [CopilotTool.read_custom_tool]: clientTool(ReadCustomToolClientTool), - create_custom_tool: clientTool(CreateCustomToolClientTool), - edit_custom_tool: clientTool(EditCustomToolClientTool), - rename_custom_tool: clientTool(RenameCustomToolClientTool), - list_monitors: clientTool(ListMonitorsClientTool), - [CopilotTool.read_monitor]: clientTool(ReadMonitorClientTool), - edit_monitor: clientTool(EditMonitorClientTool, true), - [CopilotTool.list_indicators]: clientTool(ListIndicatorsClientTool), - [CopilotTool.read_indicator]: clientTool(ReadIndicatorClientTool), - create_indicator: clientTool(CreateIndicatorClientTool), - edit_indicator: clientTool(EditIndicatorClientTool), - rename_indicator: clientTool(RenameIndicatorClientTool), - list_skills: clientTool(ListSkillsClientTool), - [CopilotTool.read_skill]: clientTool(ReadSkillClientTool), - create_skill: clientTool(CreateSkillClientTool), - edit_skill: clientTool(EditSkillClientTool), - rename_skill: clientTool(RenameSkillClientTool), - list_mcp_servers: clientTool(ListMcpServersClientTool), - [CopilotTool.read_mcp_server]: clientTool(ReadMcpServerClientTool), - create_mcp_server: clientTool(CreateMcpServerClientTool), - edit_mcp_server: clientTool(EditMcpServerClientTool), - rename_mcp_server: clientTool(RenameMcpServerClientTool), + list_knowledge_bases: serverTool('list_knowledge_bases'), + read_knowledge_base: serverTool('read_knowledge_base'), + create_knowledge_base: serverTool('create_knowledge_base', true), + edit_knowledge_base: serverTool('edit_knowledge_base', true), + rename_knowledge_base: serverTool('rename_knowledge_base', true), + query_knowledge_base: serverTool('query_knowledge_base'), + list_custom_tools: serverTool('list_custom_tools'), + [CopilotTool.read_custom_tool]: serverTool(CopilotTool.read_custom_tool), + create_custom_tool: serverTool('create_custom_tool', true), + edit_custom_tool: serverTool('edit_custom_tool', true), + rename_custom_tool: serverTool('rename_custom_tool', true), + list_monitors: serverTool('list_monitors'), + [CopilotTool.read_monitor]: serverTool(CopilotTool.read_monitor), + edit_monitor: serverTool('edit_monitor', true), + [CopilotTool.list_indicators]: serverTool(CopilotTool.list_indicators), + [CopilotTool.read_indicator]: serverTool(CopilotTool.read_indicator), + create_indicator: serverTool('create_indicator', true), + edit_indicator: serverTool('edit_indicator', true), + rename_indicator: serverTool('rename_indicator', true), + list_skills: serverTool('list_skills'), + [CopilotTool.read_skill]: serverTool(CopilotTool.read_skill), + create_skill: serverTool('create_skill', true), + edit_skill: serverTool('edit_skill', true), + rename_skill: serverTool('rename_skill', true), + list_mcp_servers: serverTool('list_mcp_servers'), + [CopilotTool.read_mcp_server]: serverTool(CopilotTool.read_mcp_server), + create_mcp_server: serverTool('create_mcp_server', true), + edit_mcp_server: serverTool('edit_mcp_server', true), + rename_mcp_server: serverTool('rename_mcp_server', true), list_gdrive_files: serverTool('list_gdrive_files'), read_gdrive_file: serverTool('read_gdrive_file'), [CopilotTool.read_oauth_credentials]: serverTool(CopilotTool.read_oauth_credentials), @@ -147,21 +115,44 @@ const COPILOT_TOOL_REGISTRY: Record = { mark_todo_in_progress: clientTool(MarkTodoInProgressClientTool), gdrive_request_access: clientTool(GDriveRequestAccessClientTool, true), oauth_request_access: clientTool(OAuthRequestAccessClientTool, true), - create_workflow: clientTool(CreateWorkflowClientTool, true), - edit_workflow: clientTool(EditWorkflowClientTool), - edit_workflow_block: clientTool(EditWorkflowBlockClientTool), - rename_workflow: clientTool(RenameWorkflowClientTool, true), - [CopilotTool.read_workflow]: clientTool(ReadWorkflowClientTool), - [CopilotTool.list_workflows]: clientTool(ListWorkflowsClientTool), - [CopilotTool.read_workflow_variables]: clientTool(ReadWorkflowVariablesClientTool), - [CopilotTool.set_workflow_variables]: clientTool(SetWorkflowVariablesClientTool, true), + create_workflow: serverTool('create_workflow', true), + edit_workflow: serverTool('edit_workflow'), + edit_workflow_block: serverTool('edit_workflow_block'), + rename_workflow: serverTool('rename_workflow', true), + [CopilotTool.read_workflow]: serverTool(CopilotTool.read_workflow), + [CopilotTool.list_workflows]: serverTool(CopilotTool.list_workflows), + [CopilotTool.edit_workflow_variable]: serverTool(CopilotTool.edit_workflow_variable), deploy_workflow: clientTool(DeployWorkflowClientTool, true), - check_deployment_status: clientTool(CheckDeploymentStatusClientTool), + check_deployment_status: serverTool('check_deployment_status'), sleep: clientTool(SleepClientTool), - [CopilotTool.read_block_outputs]: clientTool(ReadBlockOutputsClientTool), - [CopilotTool.read_block_upstream_references]: clientTool(ReadBlockUpstreamReferencesClientTool), + [CopilotTool.read_block_outputs]: serverTool(CopilotTool.read_block_outputs), + [CopilotTool.read_block_upstream_references]: serverTool( + CopilotTool.read_block_upstream_references + ), } +const WORKSPACE_TARGETED_TOOL_NAMES = new Set([ + CopilotTool.create_workflow, + CopilotTool.list_workflows, + CopilotTool.get_agent_accessory_catalog, + CopilotTool.read_environment_variables, + CopilotTool.read_credentials, + CopilotTool.read_oauth_credentials, + CopilotTool.list_gdrive_files, + CopilotTool.read_gdrive_file, + CopilotTool.list_knowledge_bases, + CopilotTool.create_knowledge_base, + CopilotTool.list_custom_tools, + CopilotTool.create_custom_tool, + CopilotTool.list_monitors, + CopilotTool.list_indicators, + CopilotTool.create_indicator, + CopilotTool.list_skills, + CopilotTool.create_skill, + CopilotTool.list_mcp_servers, + CopilotTool.create_mcp_server, +]) + export function createExecutionContext(params: { toolCallId: string toolName: string @@ -263,13 +254,21 @@ export function bindClientToolExecutionContext( export function prepareCopilotToolArgs( toolName: string | undefined, args: Record | undefined, - _context: ClientToolExecutionContext + context: ClientToolExecutionContext ): Record { const clonedArgs = cloneArgs(args) if (!toolName || !isToolId(toolName)) { return clonedArgs } + if ( + WORKSPACE_TARGETED_TOOL_NAMES.has(toolName) && + !clonedArgs.workspaceId && + context.workspaceId + ) { + clonedArgs.workspaceId = context.workspaceId + } + return ToolArgSchemas[toolName].parse(clonedArgs) as Record } diff --git a/apps/tradinggoose/stores/copilot/types.ts b/apps/tradinggoose/stores/copilot/types.ts index d7f07387f..1aaef2406 100644 --- a/apps/tradinggoose/stores/copilot/types.ts +++ b/apps/tradinggoose/stores/copilot/types.ts @@ -88,7 +88,7 @@ export type ChatContext = | { kind: 'blocks'; blockTypes?: string[]; label: string } | { kind: 'logs'; executionId?: string; label: string } | { kind: 'workflow_block'; workflowId: string; blockId: string; label: string } - | { kind: 'knowledge'; knowledgeId?: string; label: string } + | { kind: 'knowledge'; knowledgeId?: string; workspaceId?: string; label: string } | { kind: 'templates'; templateId?: string; label: string } | { kind: 'docs'; label: string } diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/hooks/use-user-input-mentions.ts b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/hooks/use-user-input-mentions.ts index 0c7d64abb..d2bead215 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/hooks/use-user-input-mentions.ts +++ b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/hooks/use-user-input-mentions.ts @@ -330,6 +330,7 @@ export function useUserInputMentions({ { kind: 'knowledge', knowledgeId: knowledgeBase.id, + workspaceId, label, }, insertion From b731adb552bc1a8eac8e1d2e9871e8385bd52687 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 19 Jun 2026 19:28:16 -0600 Subject: [PATCH 003/284] feat(mcp): add local copilot setup flow Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../[locale]/(auth)/mcp/authorize/page.tsx | 86 ++++++ .../app/api/auth/mcp/poll/route.test.ts | 58 ++++ .../app/api/auth/mcp/poll/route.ts | 19 ++ .../app/api/auth/mcp/revoke/route.test.ts | 52 ++++ .../app/api/auth/mcp/revoke/route.ts | 24 ++ .../app/api/auth/mcp/start/route.test.ts | 44 +++ .../app/api/auth/mcp/start/route.ts | 15 ++ .../app/api/copilot/mcp/route.test.ts | 157 +++++++++++ .../tradinggoose/app/api/copilot/mcp/route.ts | 231 ++++++++++++++++ .../app/api/users/me/api-keys/route.ts | 33 +-- apps/tradinggoose/app/mcp/route.test.ts | 53 ++++ apps/tradinggoose/app/mcp/route.ts | 14 + apps/tradinggoose/lib/api-key/auth.ts | 36 +-- apps/tradinggoose/lib/api-key/service.ts | 76 ++++++ apps/tradinggoose/lib/mcp/auth.ts | 210 +++++++++++++++ apps/tradinggoose/lib/mcp/install-script.ts | 251 ++++++++++++++++++ .../lib/mcp/local-config-writer-script.ts | 187 +++++++++++++ apps/tradinggoose/proxy.test.ts | 16 ++ apps/tradinggoose/proxy.ts | 1 + 19 files changed, 1500 insertions(+), 63 deletions(-) create mode 100644 apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx create mode 100644 apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts create mode 100644 apps/tradinggoose/app/api/auth/mcp/poll/route.ts create mode 100644 apps/tradinggoose/app/api/auth/mcp/revoke/route.test.ts create mode 100644 apps/tradinggoose/app/api/auth/mcp/revoke/route.ts create mode 100644 apps/tradinggoose/app/api/auth/mcp/start/route.test.ts create mode 100644 apps/tradinggoose/app/api/auth/mcp/start/route.ts create mode 100644 apps/tradinggoose/app/api/copilot/mcp/route.test.ts create mode 100644 apps/tradinggoose/app/api/copilot/mcp/route.ts create mode 100644 apps/tradinggoose/app/mcp/route.test.ts create mode 100644 apps/tradinggoose/app/mcp/route.ts create mode 100644 apps/tradinggoose/lib/mcp/auth.ts create mode 100644 apps/tradinggoose/lib/mcp/install-script.ts create mode 100644 apps/tradinggoose/lib/mcp/local-config-writer-script.ts diff --git a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx new file mode 100644 index 000000000..c9686ad65 --- /dev/null +++ b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx @@ -0,0 +1,86 @@ +import { getSessionCookie } from 'better-auth/cookies' +import { headers } from 'next/headers' +import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' +import { getSession } from '@/lib/auth' +import { approveMcpDeviceLogin } from '@/lib/mcp/auth' +import { redirect } from '@/i18n/navigation' +import type { LocaleCode } from '@/i18n/utils' + +export const dynamic = 'force-dynamic' + +type SearchParams = Promise<{ + code?: string | string[] +}> + +function getCode(searchParams: Awaited) { + const code = searchParams.code + return Array.isArray(code) ? code[0] : code +} + +function StatusPage({ title, description }: { title: string; description: string }) { + return ( +
+ +
+ ) +} + +export default async function McpAuthorizePage({ + params, + searchParams, +}: { + params: Promise<{ locale: string }> + searchParams: SearchParams +}) { + const [{ locale: routeLocale }, query, requestHeaders] = await Promise.all([ + params, + searchParams, + headers(), + ]) + const locale = routeLocale as LocaleCode + const code = getCode(query) + + if (!code) { + return ( + + ) + } + + const session = await getSession(requestHeaders) + if (!session?.user?.id) { + return redirect({ + href: { + pathname: '/login', + query: { + ...(getSessionCookie(requestHeaders) ? { reauth: '1' } : {}), + callbackUrl: `/mcp/authorize?code=${encodeURIComponent(code)}`, + }, + }, + locale, + }) + } + + const result = await approveMcpDeviceLogin({ + code, + userId: session.user.id, + }) + + if (result.status === 'expired') { + return ( + + ) + } + + return ( + + ) +} diff --git a/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts new file mode 100644 index 000000000..ca7880f3c --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts @@ -0,0 +1,58 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPollMcpDeviceLogin } = vi.hoisted(() => ({ + mockPollMcpDeviceLogin: vi.fn(), +})) + +vi.mock('@/lib/mcp/auth', () => ({ + pollMcpDeviceLogin: (...args: unknown[]) => mockPollMcpDeviceLogin(...args), +})) + +describe('MCP login poll route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPollMcpDeviceLogin.mockResolvedValue({ + status: 'approved', + apiKey: 'sk-tradinggoose-token', + expiresAt: '2026-06-19T12:00:00.000Z', + }) + }) + + it('polls the device login by code', async () => { + const { POST } = await import('./route') + + const response = await POST( + new NextRequest('https://studio.example.test/api/auth/mcp/poll', { + method: 'POST', + body: JSON.stringify({ code: 'login-code' }), + }) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + status: 'approved', + apiKey: 'sk-tradinggoose-token', + expiresAt: '2026-06-19T12:00:00.000Z', + }) + expect(mockPollMcpDeviceLogin).toHaveBeenCalledWith('login-code') + }) + + it('rejects malformed poll requests', async () => { + const { POST } = await import('./route') + + const response = await POST( + new NextRequest('https://studio.example.test/api/auth/mcp/poll', { + method: 'POST', + body: JSON.stringify({}), + }) + ) + + expect(response.status).toBe(400) + expect(mockPollMcpDeviceLogin).not.toHaveBeenCalled() + }) +}) diff --git a/apps/tradinggoose/app/api/auth/mcp/poll/route.ts b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts new file mode 100644 index 000000000..2e5c91fb4 --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts @@ -0,0 +1,19 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { pollMcpDeviceLogin } from '@/lib/mcp/auth' + +export const dynamic = 'force-dynamic' + +const PollRequestSchema = z.object({ + code: z.string().min(1), +}) + +export async function POST(request: NextRequest) { + const parsed = PollRequestSchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid MCP login poll request' }, { status: 400 }) + } + + const result = await pollMcpDeviceLogin(parsed.data.code) + return NextResponse.json(result) +} diff --git a/apps/tradinggoose/app/api/auth/mcp/revoke/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/revoke/route.test.ts new file mode 100644 index 000000000..d8f72a992 --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/revoke/route.test.ts @@ -0,0 +1,52 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRevokeMcpApiKeyByBearerToken } = vi.hoisted(() => ({ + mockRevokeMcpApiKeyByBearerToken: vi.fn(), +})) + +vi.mock('@/lib/mcp/auth', () => ({ + revokeMcpApiKeyByBearerToken: (...args: unknown[]) => + mockRevokeMcpApiKeyByBearerToken(...args), +})) + +describe('MCP auth revoke route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRevokeMcpApiKeyByBearerToken.mockResolvedValue({ revoked: true }) + }) + + it('revokes the bearer API key', async () => { + const { POST } = await import('./route') + + const response = await POST( + new NextRequest('https://studio.example.test/api/auth/mcp/revoke', { + method: 'POST', + headers: { + authorization: 'Bearer sk-tradinggoose-old', + }, + }) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ revoked: true }) + expect(mockRevokeMcpApiKeyByBearerToken).toHaveBeenCalledWith('sk-tradinggoose-old') + }) + + it('rejects missing bearer auth', async () => { + const { POST } = await import('./route') + + const response = await POST( + new NextRequest('https://studio.example.test/api/auth/mcp/revoke', { + method: 'POST', + }) + ) + + expect(response.status).toBe(400) + expect(mockRevokeMcpApiKeyByBearerToken).not.toHaveBeenCalled() + }) +}) diff --git a/apps/tradinggoose/app/api/auth/mcp/revoke/route.ts b/apps/tradinggoose/app/api/auth/mcp/revoke/route.ts new file mode 100644 index 000000000..8808f7a62 --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/revoke/route.ts @@ -0,0 +1,24 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { revokeMcpApiKeyByBearerToken } from '@/lib/mcp/auth' + +export const dynamic = 'force-dynamic' + +function getBearerToken(request: NextRequest) { + const authorization = request.headers.get('authorization') + if (!authorization?.startsWith('Bearer ')) { + return null + } + + const token = authorization.slice('Bearer '.length).trim() + return token || null +} + +export async function POST(request: NextRequest) { + const token = getBearerToken(request) + if (!token) { + return NextResponse.json({ error: 'Bearer token required' }, { status: 400 }) + } + + const result = await revokeMcpApiKeyByBearerToken(token) + return NextResponse.json(result) +} diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts new file mode 100644 index 000000000..8b6004ab1 --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts @@ -0,0 +1,44 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockStartMcpDeviceLogin } = vi.hoisted(() => ({ + mockStartMcpDeviceLogin: vi.fn(), +})) + +vi.mock('@/lib/mcp/auth', () => ({ + startMcpDeviceLogin: (...args: unknown[]) => mockStartMcpDeviceLogin(...args), +})) + +describe('MCP login start route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockStartMcpDeviceLogin.mockResolvedValue({ + code: 'login-code', + expiresAt: '2026-06-19T12:00:00.000Z', + intervalSeconds: 2, + }) + }) + + it('starts a browser approval login and returns an absolute approval URL', async () => { + const { POST } = await import('./route') + + const response = await POST( + new NextRequest('https://studio.example.test/api/auth/mcp/start', { + method: 'POST', + }) + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + code: 'login-code', + expiresAt: '2026-06-19T12:00:00.000Z', + intervalSeconds: 2, + authorizeUrl: 'https://studio.example.test/mcp/authorize?code=login-code', + }) + expect(mockStartMcpDeviceLogin).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.ts new file mode 100644 index 000000000..8fc6960a3 --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.ts @@ -0,0 +1,15 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { startMcpDeviceLogin } from '@/lib/mcp/auth' + +export const dynamic = 'force-dynamic' + +export async function POST(request: NextRequest) { + const login = await startMcpDeviceLogin() + const authorizeUrl = new URL('/mcp/authorize', request.nextUrl.origin) + authorizeUrl.searchParams.set('code', login.code) + + return NextResponse.json({ + ...login, + authorizeUrl: authorizeUrl.toString(), + }) +} diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts new file mode 100644 index 000000000..8f9052d8a --- /dev/null +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -0,0 +1,157 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthenticateApiKeyFromHeader, + mockGetCopilotRuntimeToolManifest, + mockGetServerToolIds, + mockGetUserWorkspaces, + mockRouteExecution, + mockUpdateApiKeyLastUsed, +} = vi.hoisted(() => ({ + mockAuthenticateApiKeyFromHeader: vi.fn(), + mockGetCopilotRuntimeToolManifest: vi.fn(), + mockGetServerToolIds: vi.fn(), + mockGetUserWorkspaces: vi.fn(), + mockRouteExecution: vi.fn(), + mockUpdateApiKeyLastUsed: vi.fn(), +})) + +vi.mock('@/lib/api-key/service', () => ({ + authenticateApiKeyFromHeader: (...args: unknown[]) => mockAuthenticateApiKeyFromHeader(...args), + updateApiKeyLastUsed: (...args: unknown[]) => mockUpdateApiKeyLastUsed(...args), +})) + +vi.mock('@/lib/copilot/runtime-tool-manifest', () => ({ + getCopilotRuntimeToolManifest: (...args: unknown[]) => mockGetCopilotRuntimeToolManifest(...args), +})) + +vi.mock('@/lib/copilot/tools/server/router', () => ({ + getServerToolIds: (...args: unknown[]) => mockGetServerToolIds(...args), + routeExecution: (...args: unknown[]) => mockRouteExecution(...args), +})) + +vi.mock('@/lib/workspaces/service', () => ({ + getUserWorkspaces: (...args: unknown[]) => mockGetUserWorkspaces(...args), +})) + +function createMcpRequest(body: unknown, authorization = 'Bearer sk-tradinggoose-test') { + return new NextRequest('https://studio.example.test/api/copilot/mcp', { + method: 'POST', + headers: { + authorization, + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }) +} + +describe('Copilot MCP route', () => { + beforeEach(() => { + vi.resetAllMocks() + mockAuthenticateApiKeyFromHeader.mockResolvedValue({ + success: true, + userId: 'user-1', + keyId: 'key-1', + keyType: 'personal', + }) + mockGetUserWorkspaces.mockResolvedValue([ + { id: 'workspace-1', name: 'Research', permissions: 'admin' }, + { id: 'workspace-2', name: 'Ops', permissions: 'read' }, + ]) + mockGetServerToolIds.mockReturnValue(['list_workflows', 'read_workflow']) + mockGetCopilotRuntimeToolManifest.mockResolvedValue({ + version: 'v1', + tools: [ + { + name: 'list_workflows', + description: 'List workflows.', + parameters: { type: 'object', properties: { workspaceId: { type: 'string' } } }, + }, + { + name: 'plan', + description: 'Client-only planning tool.', + parameters: { type: 'object', properties: {} }, + }, + ], + }) + mockRouteExecution.mockResolvedValue({ workflows: [] }) + }) + + it('rejects requests without bearer auth', async () => { + const { POST } = await import('./route') + + const response = await POST( + createMcpRequest({ jsonrpc: '2.0', id: 1, method: 'initialize' }, '') + ) + const body = await response.json() + + expect(response.status).toBe(401) + expect(body.error.message).toBe('Bearer token required') + expect(mockAuthenticateApiKeyFromHeader).not.toHaveBeenCalled() + }) + + it('returns initialize metadata with authenticated workspace context', async () => { + const { POST } = await import('./route') + + const response = await POST(createMcpRequest({ jsonrpc: '2.0', id: 1, method: 'initialize' })) + const body = await response.json() + + expect(response.headers.get('MCP-Protocol-Version')).toBe('2025-03-26') + expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('sk-tradinggoose-test', { + keyTypes: ['personal'], + }) + expect(mockUpdateApiKeyLastUsed).toHaveBeenCalledWith('key-1') + expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ userId: 'user-1', autoCreate: false }) + expect(body.result.capabilities).toEqual({ tools: {} }) + expect(body.result.instructions).toContain('workspaceId=workspace-1, permissions=admin') + expect(body.result.instructions).toContain('workspaceId=workspace-2, permissions=read') + expect(body.result.instructions).toContain( + 'Do not store workspaceId, entityId, or entity targets' + ) + }) + + it('lists only executable server copilot tools', async () => { + const { POST } = await import('./route') + + const response = await POST(createMcpRequest({ jsonrpc: '2.0', id: 2, method: 'tools/list' })) + const body = await response.json() + + expect(body.result.tools).toEqual([ + { + name: 'list_workflows', + description: 'List workflows.', + inputSchema: { type: 'object', properties: { workspaceId: { type: 'string' } } }, + }, + ]) + }) + + it('dispatches tool calls through the server tool router', async () => { + const { POST } = await import('./route') + + const response = await POST( + createMcpRequest({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'list_workflows', + arguments: { workspaceId: 'workspace-1' }, + }, + }) + ) + const body = await response.json() + + expect(mockRouteExecution).toHaveBeenCalledWith( + 'list_workflows', + { workspaceId: 'workspace-1' }, + { userId: 'user-1' } + ) + expect(body.result.structuredContent).toEqual({ workflows: [] }) + expect(body.result.content[0].text).toBe(JSON.stringify({ workflows: [] }, null, 2)) + }) +}) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts new file mode 100644 index 000000000..f994ef293 --- /dev/null +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -0,0 +1,231 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' +import { getCopilotRuntimeToolManifest } from '@/lib/copilot/runtime-tool-manifest' +import { getServerToolIds, routeExecution } from '@/lib/copilot/tools/server/router' +import { getUserWorkspaces } from '@/lib/workspaces/service' + +export const dynamic = 'force-dynamic' + +const MCP_PROTOCOL_VERSION = '2025-03-26' +const SERVER_NAME = 'tradinggoose-copilot' +const SERVER_VERSION = '0.1.0' + +type JsonRpcId = string | number | null + +type JsonRpcRequest = { + jsonrpc?: string + id?: JsonRpcId + method?: string + params?: unknown +} + +type AuthenticatedMcpUser = { + userId: string +} + +function jsonRpcResult(id: JsonRpcId, result: unknown) { + return { + jsonrpc: '2.0', + id, + result, + } +} + +function jsonRpcError(id: JsonRpcId, code: number, message: string, data?: unknown) { + return { + jsonrpc: '2.0', + id, + error: { + code, + message, + ...(data === undefined ? {} : { data }), + }, + } +} + +function mcpJsonResponse(body: unknown, init?: ResponseInit) { + const headers = new Headers(init?.headers) + headers.set('MCP-Protocol-Version', MCP_PROTOCOL_VERSION) + + return NextResponse.json(body, { + ...init, + headers, + }) +} + +function getBearerToken(request: NextRequest) { + const authorization = request.headers.get('authorization') + if (!authorization?.startsWith('Bearer ')) { + return null + } + + const token = authorization.slice('Bearer '.length).trim() + return token || null +} + +async function authenticateCopilotMcpRequest( + request: NextRequest +): Promise { + const token = getBearerToken(request) + if (!token) { + return { error: 'Bearer token required' } + } + + const auth = await authenticateApiKeyFromHeader(token, { keyTypes: ['personal'] }) + if (!auth.success || !auth.userId) { + return { error: 'Invalid Copilot MCP bearer token' } + } + + if (auth.keyId) { + await updateApiKeyLastUsed(auth.keyId) + } + + return { userId: auth.userId } +} + +async function buildInstructions(userId: string) { + const workspaces = await getUserWorkspaces({ userId, autoCreate: false }) + const workspaceLines = + workspaces.length > 0 + ? workspaces.map( + (workspace) => + `- ${workspace.name}: workspaceId=${workspace.id}, permissions=${workspace.permissions}` + ) + : ['- No accessible workspaces were found.'] + + return [ + 'TradingGoose Copilot MCP exposes the same server-side Copilot tools used by TradingGoose Studio.', + 'Local MCP config stores only this user auth token. Do not store workspaceId, entityId, or entity targets in the local MCP config.', + 'Use workspaceId only for workspace-scoped list/create tools. Use entityId for read/edit/rename tools that target an existing entity.', + 'Accessible workspaces for the authenticated user:', + ...workspaceLines, + ].join('\n') +} + +async function listMcpTools() { + const serverToolIds = new Set(getServerToolIds()) + const manifest = await getCopilotRuntimeToolManifest() + + return manifest.tools + .filter((tool) => serverToolIds.has(tool.name)) + .map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.parameters ?? { + type: 'object', + properties: {}, + additionalProperties: true, + }, + })) +} + +function getToolCallParams(params: unknown) { + if (!params || typeof params !== 'object' || Array.isArray(params)) { + return null + } + + const { name, arguments: args } = params as { name?: unknown; arguments?: unknown } + if (typeof name !== 'string' || name.trim().length === 0) { + return null + } + + return { + name, + args: args ?? {}, + } +} + +async function handleJsonRpcRequest(request: JsonRpcRequest, auth: AuthenticatedMcpUser) { + const id = request.id ?? null + if (typeof request.method !== 'string') { + return jsonRpcError(id, -32600, 'Invalid JSON-RPC request') + } + + if (request.id === undefined) { + return null + } + + switch (request.method) { + case 'initialize': + return jsonRpcResult(id, { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { + tools: {}, + }, + serverInfo: { + name: SERVER_NAME, + version: SERVER_VERSION, + }, + instructions: await buildInstructions(auth.userId), + }) + + case 'ping': + return jsonRpcResult(id, {}) + + case 'tools/list': + return jsonRpcResult(id, { + tools: await listMcpTools(), + }) + + case 'tools/call': { + const toolCall = getToolCallParams(request.params) + if (!toolCall) { + return jsonRpcError(id, -32602, 'Invalid tools/call params') + } + + try { + const result = await routeExecution(toolCall.name, toolCall.args, { + userId: auth.userId, + }) + return jsonRpcResult(id, { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + structuredContent: result, + }) + } catch (error) { + return jsonRpcResult(id, { + isError: true, + content: [ + { + type: 'text', + text: error instanceof Error ? error.message : 'Copilot MCP tool call failed', + }, + ], + }) + } + } + + case 'resources/list': + return jsonRpcResult(id, { resources: [] }) + + case 'prompts/list': + return jsonRpcResult(id, { prompts: [] }) + + default: + return jsonRpcError(id, -32601, `Unsupported MCP method: ${request.method}`) + } +} + +export async function POST(request: NextRequest) { + const auth = await authenticateCopilotMcpRequest(request) + if ('error' in auth) { + return mcpJsonResponse(jsonRpcError(null, -32001, auth.error), { status: 401 }) + } + + const body = (await request.json().catch(() => null)) as JsonRpcRequest | JsonRpcRequest[] | null + if (!body) { + return mcpJsonResponse(jsonRpcError(null, -32700, 'Invalid JSON body'), { status: 400 }) + } + + if (Array.isArray(body)) { + const responses = ( + await Promise.all(body.map((entry) => handleJsonRpcRequest(entry, auth))) + ).filter(Boolean) + + return responses.length > 0 + ? mcpJsonResponse(responses) + : new NextResponse(null, { status: 204 }) + } + + const response = await handleJsonRpcRequest(body, auth) + return response ? mcpJsonResponse(response) : new NextResponse(null, { status: 204 }) +} diff --git a/apps/tradinggoose/app/api/users/me/api-keys/route.ts b/apps/tradinggoose/app/api/users/me/api-keys/route.ts index 96179b231..89f958e36 100644 --- a/apps/tradinggoose/app/api/users/me/api-keys/route.ts +++ b/apps/tradinggoose/app/api/users/me/api-keys/route.ts @@ -1,9 +1,9 @@ import { db } from '@tradinggoose/db' import { apiKey } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' -import { nanoid } from 'nanoid' import { type NextRequest, NextResponse } from 'next/server' -import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' +import { getApiKeyDisplayFormat } from '@/lib/api-key/auth' +import { createPersonalApiKey } from '@/lib/api-key/service' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' @@ -86,35 +86,10 @@ export async function POST(request: NextRequest) { ) } - const { key: plainKey, encryptedKey } = await createApiKey(true) - - if (!encryptedKey) { - throw new Error('Failed to encrypt API key for storage') - } - - const [newKey] = await db - .insert(apiKey) - .values({ - id: nanoid(), - userId, - workspaceId: null, - name, - key: encryptedKey, - type: 'personal', - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning({ - id: apiKey.id, - name: apiKey.name, - createdAt: apiKey.createdAt, - }) + const newKey = await createPersonalApiKey({ userId, name }) return NextResponse.json({ - key: { - ...newKey, - key: plainKey, - }, + key: newKey, }) } catch (error) { logger.error('Failed to create API key', { error }) diff --git a/apps/tradinggoose/app/mcp/route.test.ts b/apps/tradinggoose/app/mcp/route.test.ts new file mode 100644 index 000000000..0f339ea4e --- /dev/null +++ b/apps/tradinggoose/app/mcp/route.test.ts @@ -0,0 +1,53 @@ +/** + * @vitest-environment node + */ + +import { spawnSync } from 'child_process' +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' + +describe('MCP install route', () => { + it('serves a setup script for auth and explicit local MCP target config', async () => { + const { GET } = await import('./route') + + const response = await GET(new NextRequest('https://studio.example.test/mcp')) + const script = await response.text() + + const shellCheck = spawnSync('sh', ['-n', '-c', script], { + encoding: 'utf8', + timeout: 5000, + }) + expect(shellCheck.status).toBe(0) + expect(shellCheck.stderr).toBe('') + expect(response.headers.get('Content-Type')).toBe('text/x-shellscript; charset=utf-8') + expect(script).toContain( + 'BASE_URL="$' + '{TRADINGGOOSE_BASE_URL:-https://studio.example.test}"' + ) + expect(script).toContain('curl -fsSL /mcp | sh -s -- setup --codex') + expect(script).toContain('$BASE_URL/api/auth/mcp/start') + expect(script).toContain('$BASE_URL/api/auth/mcp/poll') + expect(script).toContain('$BASE_URL/api/auth/mcp/revoke') + expect(script).toContain('$BASE_URL/api/copilot/mcp') + expect(script).toContain('Authorization: Bearer $TOKEN') + expect(script).toContain('setup Authenticate, rotate local MCP auth, and write config.') + expect(script).toContain('read-tokens') + expect(script).toContain('add_target codex') + expect(script).toContain('add_target cursor') + expect(script).toContain('add_target claude') + expect(script).toContain('add_target opencode') + expect(script).toContain('node - "$1" "$SCOPE" "$MCP_URL" "$TOKEN"') + expect(script).toContain('[mcp_servers.tradinggoose.http_headers]') + expect(script).toContain("path.join(os.homedir(), '.codex', 'config.toml')") + expect(script).toContain("path.join(os.homedir(), '.cursor', 'mcp.json')") + expect(script).toContain("path.join(os.homedir(), '.claude.json')") + expect(script).toContain("path.join(os.homedir(), '.config', 'opencode', 'opencode.json')") + expect(script).not.toContain('/mcp/copilot') + expect(script).not.toContain('/copilot-mcp |') + expect(script).not.toContain('/copilot-mcp/authorize') + expect(script).not.toContain('copilot-mcp.sh') + expect(script).not.toContain('TOKEN_FILE') + expect(script).not.toContain('copilot-mcp.json') + expect(script).not.toContain('workspaceId') + expect(script).not.toContain('entityId') + }) +}) diff --git a/apps/tradinggoose/app/mcp/route.ts b/apps/tradinggoose/app/mcp/route.ts new file mode 100644 index 000000000..43f030a36 --- /dev/null +++ b/apps/tradinggoose/app/mcp/route.ts @@ -0,0 +1,14 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { buildMcpInstallScript } from '../../lib/mcp/install-script' + +export const dynamic = 'force-dynamic' + +export async function GET(request: NextRequest) { + return new NextResponse(buildMcpInstallScript(request.nextUrl.origin), { + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/x-shellscript; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + }, + }) +} diff --git a/apps/tradinggoose/lib/api-key/auth.ts b/apps/tradinggoose/lib/api-key/auth.ts index 0036f1ec3..fff54366b 100644 --- a/apps/tradinggoose/lib/api-key/auth.ts +++ b/apps/tradinggoose/lib/api-key/auth.ts @@ -1,12 +1,9 @@ import { + createApiKeyMaterial, decryptApiKey, - encryptApiKey, - generateApiKey, - generateEncryptedApiKey, isEncryptedApiKeyFormat, isLegacyApiKeyFormat, } from '@/lib/api-key/service' -import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('ApiKeyAuth') @@ -81,21 +78,6 @@ export async function authenticateApiKey(inputKey: string, storedKey: string): P } } -/** - * Encrypts an API key for secure storage - * @param apiKey - The plain text API key to encrypt - * @returns Promise - The encrypted key - */ -export async function encryptApiKeyForStorage(apiKey: string): Promise { - try { - const { encrypted } = await encryptApiKey(apiKey) - return encrypted - } catch (error) { - logger.error('API key encryption error:', { error }) - throw new Error('Failed to encrypt API key') - } -} - /** * Creates a new API key * @param useStorage - Whether to encrypt the key before storage (default: true) @@ -105,21 +87,7 @@ export async function createApiKey(useStorage = true): Promise<{ key: string encryptedKey?: string }> { - try { - const hasEncryptionKey = env.API_ENCRYPTION_KEY !== undefined - - const plainKey = hasEncryptionKey ? generateEncryptedApiKey() : generateApiKey() - - if (useStorage) { - const encryptedKey = await encryptApiKeyForStorage(plainKey) - return { key: plainKey, encryptedKey } - } - - return { key: plainKey } - } catch (error) { - logger.error('API key creation error:', { error }) - throw new Error('Failed to create API key') - } + return createApiKeyMaterial(useStorage) } /** diff --git a/apps/tradinggoose/lib/api-key/service.ts b/apps/tradinggoose/lib/api-key/service.ts index 8e3a9bd58..06523586a 100644 --- a/apps/tradinggoose/lib/api-key/service.ts +++ b/apps/tradinggoose/lib/api-key/service.ts @@ -24,6 +24,82 @@ export interface ApiKeyAuthResult { error?: string } +export interface CreatePersonalApiKeyInput { + userId: string + name: string + createdAt?: Date +} + +export interface CreatedPersonalApiKey { + id: string + name: string + createdAt: Date + key: string +} + +export async function createApiKeyMaterial(useStorage = true): Promise<{ + key: string + encryptedKey?: string +}> { + try { + const hasEncryptionKey = env.API_ENCRYPTION_KEY !== undefined + const plainKey = hasEncryptionKey ? generateEncryptedApiKey() : generateApiKey() + + if (useStorage) { + const { encrypted } = await encryptApiKey(plainKey) + return { key: plainKey, encryptedKey: encrypted } + } + + return { key: plainKey } + } catch (error) { + logger.error('API key creation error:', { error }) + throw new Error('Failed to create API key') + } +} + +export async function createPersonalApiKey({ + userId, + name, + createdAt = new Date(), +}: CreatePersonalApiKeyInput): Promise { + const trimmedName = name.trim() + if (!trimmedName) { + throw new Error('API key name is required') + } + + const { key: plainKey, encryptedKey } = await createApiKeyMaterial(true) + if (!encryptedKey) { + throw new Error('Failed to encrypt API key for storage') + } + + const [newKey] = await db + .insert(apiKeyTable) + .values({ + id: nanoid(), + userId, + workspaceId: null, + name: trimmedName, + key: encryptedKey, + type: 'personal', + createdAt, + updatedAt: createdAt, + }) + .returning({ + id: apiKeyTable.id, + name: apiKeyTable.name, + createdAt: apiKeyTable.createdAt, + }) + + if (!newKey) { + throw new Error('Failed to create API key') + } + + return { + ...newKey, + key: plainKey, + } +} + /** * Authenticate an API key from header with flexible filtering options */ diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts new file mode 100644 index 000000000..6f1f3de51 --- /dev/null +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -0,0 +1,210 @@ +import { createHash, randomBytes } from 'crypto' +import { db } from '@tradinggoose/db' +import { apiKey, verification } from '@tradinggoose/db/schema' +import { and, eq } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { authenticateApiKeyFromHeader, createPersonalApiKey } from '@/lib/api-key/service' + +const DEVICE_LOGIN_TTL_MS = 10 * 60 * 1000 +const DEVICE_LOGIN_PREFIX = 'mcp:' +const POLL_INTERVAL_SECONDS = 2 + +type PendingDeviceLogin = { + status: 'pending' + createdAt: string +} + +type ApprovedDeviceLogin = { + status: 'approved' + createdAt: string + approvedAt: string + userId: string + keyId: string + apiKey: string +} + +type DeviceLoginState = PendingDeviceLogin | ApprovedDeviceLogin + +export type McpDeviceLoginPollResult = + | { status: 'pending'; intervalSeconds: number; expiresAt: string } + | { status: 'approved'; apiKey: string; expiresAt: string } + | { status: 'expired' } + +export type McpDeviceLoginApprovalResult = + | { status: 'approved'; expiresAt: string } + | { status: 'expired' } + +export type McpDeviceLoginStartResult = { + code: string + expiresAt: string + intervalSeconds: number +} + +export type McpApiKeyRevocationResult = { + revoked: boolean +} + +function getDeviceLoginIdentifier(code: string) { + const digest = createHash('sha256').update(code).digest('hex') + return `${DEVICE_LOGIN_PREFIX}${digest}` +} + +function parseDeviceLoginState(value: string): DeviceLoginState | null { + try { + const parsed = JSON.parse(value) as Record + if (parsed.status === 'pending' && typeof parsed.createdAt === 'string') { + return parsed as PendingDeviceLogin + } + if ( + parsed.status === 'approved' && + typeof parsed.createdAt === 'string' && + typeof parsed.approvedAt === 'string' && + typeof parsed.userId === 'string' && + typeof parsed.keyId === 'string' && + typeof parsed.apiKey === 'string' + ) { + return parsed as ApprovedDeviceLogin + } + return null + } catch { + return null + } +} + +async function readDeviceLogin(code: string) { + const identifier = getDeviceLoginIdentifier(code) + const [row] = await db + .select({ + id: verification.id, + value: verification.value, + expiresAt: verification.expiresAt, + }) + .from(verification) + .where(eq(verification.identifier, identifier)) + .limit(1) + + if (!row) { + return null + } + + const state = parseDeviceLoginState(row.value) + if (!state || row.expiresAt <= new Date()) { + await db.delete(verification).where(eq(verification.id, row.id)) + return null + } + + return { + id: row.id, + state, + expiresAt: row.expiresAt, + } +} + +export async function startMcpDeviceLogin(): Promise { + const code = randomBytes(32).toString('base64url') + const now = new Date() + const expiresAt = new Date(now.getTime() + DEVICE_LOGIN_TTL_MS) + + await db.insert(verification).values({ + id: nanoid(), + identifier: getDeviceLoginIdentifier(code), + value: JSON.stringify({ + status: 'pending', + createdAt: now.toISOString(), + } satisfies PendingDeviceLogin), + expiresAt, + createdAt: now, + updatedAt: now, + }) + + return { + code, + expiresAt: expiresAt.toISOString(), + intervalSeconds: POLL_INTERVAL_SECONDS, + } +} + +export async function pollMcpDeviceLogin(code: string): Promise { + const login = await readDeviceLogin(code) + if (!login) { + return { status: 'expired' } + } + + if (login.state.status === 'pending') { + return { + status: 'pending', + intervalSeconds: POLL_INTERVAL_SECONDS, + expiresAt: login.expiresAt.toISOString(), + } + } + + await db.delete(verification).where(eq(verification.id, login.id)) + return { + status: 'approved', + apiKey: login.state.apiKey, + expiresAt: login.expiresAt.toISOString(), + } +} + +export async function approveMcpDeviceLogin({ + code, + userId, +}: { + code: string + userId: string +}): Promise { + const login = await readDeviceLogin(code) + if (!login) { + return { status: 'expired' } + } + + if (login.state.status === 'approved') { + return { + status: 'approved', + expiresAt: login.expiresAt.toISOString(), + } + } + + const now = new Date() + const createdKey = await createPersonalApiKey({ + userId, + name: `TradingGoose MCP ${now.toISOString()}`, + createdAt: now, + }) + + await db + .update(verification) + .set({ + value: JSON.stringify({ + status: 'approved', + createdAt: login.state.createdAt, + approvedAt: now.toISOString(), + userId, + keyId: createdKey.id, + apiKey: createdKey.key, + } satisfies ApprovedDeviceLogin), + updatedAt: now, + }) + .where(eq(verification.id, login.id)) + + return { + status: 'approved', + expiresAt: login.expiresAt.toISOString(), + } +} + +export async function revokeMcpApiKeyByBearerToken( + token: string +): Promise { + const auth = await authenticateApiKeyFromHeader(token, { keyTypes: ['personal'] }) + if (!auth.success || !auth.keyId || !auth.userId) { + return { revoked: false } + } + + const deleted = await db + .delete(apiKey) + .where(and(eq(apiKey.id, auth.keyId), eq(apiKey.userId, auth.userId), eq(apiKey.type, 'personal'))) + .returning({ id: apiKey.id }) + + return { revoked: deleted.length > 0 } +} diff --git a/apps/tradinggoose/lib/mcp/install-script.ts b/apps/tradinggoose/lib/mcp/install-script.ts new file mode 100644 index 000000000..7002b2fb8 --- /dev/null +++ b/apps/tradinggoose/lib/mcp/install-script.ts @@ -0,0 +1,251 @@ +import { MCP_LOCAL_CONFIG_WRITER_SCRIPT } from './local-config-writer-script' + +export function buildMcpInstallScript(baseUrl: string) { + const normalizedBaseUrl = baseUrl.replace(/\/+$/, '') + const script = String.raw`#!/bin/sh +set -eu + +BASE_URL="\${TRADINGGOOSE_BASE_URL:-${normalizedBaseUrl}}" +SCOPE="global" +TARGETS="" + +usage() { + cat <<'USAGE' +TradingGoose MCP setup + +Usage: + curl -fsSL /mcp | sh -s -- login + curl -fsSL /mcp | sh -s -- setup --codex + curl -fsSL /mcp | sh -s -- setup --all + +Commands: + login Rotate local MCP auth and print a bearer token. + setup Authenticate, rotate local MCP auth, and write config. + +Options: + --base-url Override the Studio URL embedded in this script. + --codex Configure Codex. + --cursor Configure Cursor. + --claude Configure Claude Code. + --opencode Configure OpenCode. + --all Configure Codex, Cursor, Claude Code, and OpenCode. + --project Write project-local config from the current directory. + --global Write user-global config. This is the default. + -h, --help Show this help. +USAGE +} + +fail() { + echo "tradinggoose-mcp: $*" >&2 + exit 1 +} + +json_string() { + sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p" +} + +json_number() { + sed -n "s/.*\"$1\":\([0-9][0-9]*\).*/\1/p" +} + +add_target() { + case " $TARGETS " in + *" $1 "*) ;; + *) TARGETS="\${TARGETS}\${TARGETS:+ }$1" ;; + esac +} + +choose_targets() { + if [ -n "$TARGETS" ]; then + return 0 + fi + + if [ ! -r /dev/tty ]; then + fail "setup requires a target. Pass --codex, --cursor, --claude, --opencode, or --all." + fi + + { + echo "Choose local MCP target:" + echo " 1) Codex" + echo " 2) Cursor" + echo " 3) Claude Code" + echo " 4) OpenCode" + echo " 5) All" + printf "Target [1-5]: " + } >/dev/tty + + read -r choice /dev/null 2>&1 || fail "node is required to rotate MCP auth and write config." +} + +write_target_config() { + require_node + node - "$1" "$SCOPE" "$MCP_URL" "$TOKEN" <<'NODE' +${MCP_LOCAL_CONFIG_WRITER_SCRIPT} +NODE +} + +read_existing_tokens() { + require_node + node - read-tokens "$SCOPE" <<'NODE' +${MCP_LOCAL_CONFIG_WRITER_SCRIPT} +NODE +} + +revoke_existing_tokens() { + BASE_URL="\${BASE_URL%/}" + REVOKE_URL="$BASE_URL/api/auth/mcp/revoke" + TOKENS="$(read_existing_tokens)" + + [ -n "$TOKENS" ] || return 0 + + printf '%s\n' "$TOKENS" | while IFS= read -r OLD_TOKEN; do + [ -n "$OLD_TOKEN" ] || continue + curl -fsS -X POST -H "Authorization: Bearer $OLD_TOKEN" "$REVOKE_URL" >/dev/null + done +} + +authenticate() { + BASE_URL="\${BASE_URL%/}" + MCP_URL="$BASE_URL/api/copilot/mcp" + START_URL="$BASE_URL/api/auth/mcp/start" + POLL_URL="$BASE_URL/api/auth/mcp/poll" + + START_JSON="$(curl -fsS -X POST -H 'Content-Type: application/json' "$START_URL")" + CODE="$(printf '%s' "$START_JSON" | json_string code)" + AUTHORIZE_URL="$(printf '%s' "$START_JSON" | json_string authorizeUrl)" + INTERVAL="$(printf '%s' "$START_JSON" | json_number intervalSeconds)" + + [ -n "$CODE" ] || fail "Studio did not return a login code" + [ -n "$AUTHORIZE_URL" ] || fail "Studio did not return an authorization URL" + [ -n "$INTERVAL" ] || INTERVAL="2" + + echo "Open this URL in your browser to approve MCP access:" + echo "$AUTHORIZE_URL" + echo + + DEADLINE="$(($(date +%s) + 600))" + while [ "$(date +%s)" -lt "$DEADLINE" ]; do + POLL_JSON="$(curl -fsS -X POST -H 'Content-Type: application/json' -d "{\"code\":\"$CODE\"}" "$POLL_URL" || printf '{"status":"pending"}')" + STATUS="$(printf '%s' "$POLL_JSON" | json_string status)" + + case "$STATUS" in + approved) + TOKEN="$(printf '%s' "$POLL_JSON" | json_string apiKey)" + [ -n "$TOKEN" ] || fail "Studio approved login without returning a token" + return 0 + ;; + expired) + fail "Login expired. Run the command again." + ;; + pending|"") + sleep "$INTERVAL" + ;; + *) + fail "Unexpected login status: $STATUS" + ;; + esac + done + + fail "Timed out waiting for browser approval" +} + +COMMAND="\${1:-setup}" +if [ "$#" -gt 0 ]; then + shift +fi + +while [ "$#" -gt 0 ]; do + case "$1" in + --base-url) + shift + [ "$#" -gt 0 ] || fail "--base-url requires a value" + BASE_URL="$1" + ;; + --base-url=*) + BASE_URL="\${1#--base-url=}" + ;; + --codex) + add_target codex + ;; + --cursor) + add_target cursor + ;; + --claude) + add_target claude + ;; + --opencode) + add_target opencode + ;; + --all) + add_target codex + add_target cursor + add_target claude + add_target opencode + ;; + --project) + SCOPE="project" + ;; + --global) + SCOPE="global" + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "Unknown option: $1" + ;; + esac + shift +done + +case "$COMMAND" in + login) + revoke_existing_tokens + authenticate + echo "MCP endpoint:" + echo "$MCP_URL" + echo + echo "Bearer token:" + echo "$TOKEN" + echo + echo "Use this MCP auth header:" + echo "Authorization: Bearer $TOKEN" + ;; + setup) + choose_targets + revoke_existing_tokens + authenticate + echo "Using MCP endpoint: $MCP_URL" + for TARGET in $TARGETS; do + CONFIG_PATH="$(write_target_config "$TARGET")" + echo "Configured $TARGET: $CONFIG_PATH" + done + ;; + help|-h|--help) + usage + ;; + *) + fail "Unknown command: $COMMAND" + ;; +esac +` + return script.replaceAll('\\${', '${') +} diff --git a/apps/tradinggoose/lib/mcp/local-config-writer-script.ts b/apps/tradinggoose/lib/mcp/local-config-writer-script.ts new file mode 100644 index 000000000..49936189d --- /dev/null +++ b/apps/tradinggoose/lib/mcp/local-config-writer-script.ts @@ -0,0 +1,187 @@ +export const MCP_LOCAL_CONFIG_WRITER_SCRIPT = String.raw`const fs = require('fs') +const os = require('os') +const path = require('path') + +const target = process.argv[2] +const scope = process.argv[3] +const mcpUrl = process.argv[4] +const token = process.argv[5] +const authHeaders = { Authorization: 'Bearer ' + token } +const allTargets = ['codex', 'cursor', 'claude', 'opencode'] + +function resolvePathFor(candidate, candidateScope) { + if (candidateScope === 'project') { + switch (candidate) { + case 'codex': + return path.join(process.cwd(), '.codex', 'config.toml') + case 'cursor': + return path.join(process.cwd(), '.cursor', 'mcp.json') + case 'claude': + return path.join(process.cwd(), '.mcp.json') + case 'opencode': + return path.join(process.cwd(), 'opencode.json') + } + } + + switch (candidate) { + case 'codex': + return path.join(os.homedir(), '.codex', 'config.toml') + case 'cursor': + return path.join(os.homedir(), '.cursor', 'mcp.json') + case 'claude': + return path.join(os.homedir(), '.claude.json') + case 'opencode': + return path.join(os.homedir(), '.config', 'opencode', 'opencode.json') + } + + throw new Error('Unsupported setup target: ' + candidate) +} + +function resolvePath() { + return resolvePathFor(target, scope) +} + +function ensureParent(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) +} + +function writeCodexConfig(filePath) { + ensureParent(filePath) + const block = [ + '[mcp_servers.tradinggoose]', + 'type = "http"', + 'url = ' + JSON.stringify(mcpUrl), + '', + '[mcp_servers.tradinggoose.http_headers]', + 'Authorization = ' + JSON.stringify('Bearer ' + token), + '', + ].join('\n') + const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '' + const sectionHeader = '[mcp_servers.tradinggoose]' + const startIndex = current.indexOf(sectionHeader) + + if (startIndex === -1) { + const next = current.trim() ? current.replace(/\s*$/, '') + '\n\n' + block : block + fs.writeFileSync(filePath, next, 'utf8') + return + } + + const subPrefix = '[mcp_servers.tradinggoose.' + const rest = current.slice(startIndex + sectionHeader.length) + let endOffset = rest.length + const headerPattern = /^\[/gm + let match + + while ((match = headerPattern.exec(rest)) !== null) { + const lineEnd = rest.indexOf('\n', match.index) + const line = rest.slice(match.index, lineEnd === -1 ? undefined : lineEnd) + if (!line.startsWith(subPrefix)) { + endOffset = match.index + break + } + } + + const before = current.slice(0, startIndex).replace(/\n+$/, '') + const after = current.slice(startIndex + sectionHeader.length + endOffset).replace(/^\n+/, '') + const next = (before ? before + '\n\n' : '') + block + (after ? '\n' + after : '') + fs.writeFileSync(filePath, next, 'utf8') +} + +function readJson(filePath) { + if (!fs.existsSync(filePath)) { + return {} + } + const text = fs.readFileSync(filePath, 'utf8').trim() + return text ? JSON.parse(text) : {} +} + +function writeJsonConfig(filePath, section, entry) { + ensureParent(filePath) + const config = readJson(filePath) + if (!config || typeof config !== 'object' || Array.isArray(config)) { + throw new Error(filePath + ' must contain a JSON object') + } + if (!config[section] || typeof config[section] !== 'object' || Array.isArray(config[section])) { + config[section] = {} + } + config[section].tradinggoose = entry + fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n', 'utf8') +} + +function bearerTokenFromHeader(value) { + if (typeof value !== 'string') { + return null + } + const match = value.match(/^Bearer\s+(.+)$/) + return match ? match[1] : null +} + +function readCodexToken(filePath) { + if (!fs.existsSync(filePath)) { + return null + } + const text = fs.readFileSync(filePath, 'utf8') + const section = text.match(/\[mcp_servers\.tradinggoose\.http_headers\]([\s\S]*?)(?:\n\[|$)/) + if (!section) { + return null + } + const authorization = section[1].match(/\nAuthorization\s*=\s*["']([^"']+)["']/) + return authorization ? bearerTokenFromHeader(authorization[1]) : null +} + +function readJsonToken(filePath, section) { + const config = readJson(filePath) + return bearerTokenFromHeader(config?.[section]?.tradinggoose?.headers?.Authorization) +} + +function readTargetToken(candidate) { + const filePath = resolvePathFor(candidate, scope) + switch (candidate) { + case 'codex': + return readCodexToken(filePath) + case 'cursor': + case 'claude': + return readJsonToken(filePath, 'mcpServers') + case 'opencode': + return readJsonToken(filePath, 'mcp') + default: + throw new Error('Unsupported setup target: ' + candidate) + } +} + +if (target === 'read-tokens') { + const seen = new Set() + for (const candidate of allTargets) { + const existingToken = readTargetToken(candidate) + if (existingToken && !seen.has(existingToken)) { + seen.add(existingToken) + console.log(existingToken) + } + } + process.exit(0) +} + +const filePath = resolvePath() +switch (target) { + case 'codex': + writeCodexConfig(filePath) + break + case 'cursor': + writeJsonConfig(filePath, 'mcpServers', { url: mcpUrl, headers: authHeaders }) + break + case 'claude': + writeJsonConfig(filePath, 'mcpServers', { type: 'http', url: mcpUrl, headers: authHeaders }) + break + case 'opencode': + writeJsonConfig(filePath, 'mcp', { + type: 'remote', + url: mcpUrl, + enabled: true, + headers: authHeaders, + }) + break + default: + throw new Error('Unsupported setup target: ' + target) +} + +console.log(filePath)` diff --git a/apps/tradinggoose/proxy.test.ts b/apps/tradinggoose/proxy.test.ts index a303bfaf0..5e7dfce2e 100644 --- a/apps/tradinggoose/proxy.test.ts +++ b/apps/tradinggoose/proxy.test.ts @@ -329,6 +329,22 @@ describe('proxy auth routing', () => { expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined() }) + it('keeps the MCP script route canonical for curl clients', async () => { + const { proxy } = await import('./proxy') + const response = await proxy( + new NextRequest('http://localhost:3000/mcp', { + headers: { + 'user-agent': 'curl/8.0', + }, + }) + ) + + expect(response.status).toBe(200) + expect(response.headers.get('location')).toBeNull() + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined() + }) + it('does not exempt localized API-shaped webhook paths from suspicious user-agent filtering', async () => { const { proxy } = await import('./proxy') const response = await proxy( diff --git a/apps/tradinggoose/proxy.ts b/apps/tradinggoose/proxy.ts index 671ecb383..d08929964 100644 --- a/apps/tradinggoose/proxy.ts +++ b/apps/tradinggoose/proxy.ts @@ -69,6 +69,7 @@ function isCanonicalRouteHandlerPath(pathname: string) { pathname === '/llms.txt' || pathname === '/llms-full.txt' || pathname === '/manifest.webmanifest' || + pathname === '/mcp' || pathname === '/robots.txt' || pathname === '/sitemap.xml' ) From cb7ae0720ba1689cbaae8810d64a595682d7944d Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 19 Jun 2026 23:14:46 -0600 Subject: [PATCH 004/284] feat(copilot): apply full-access server tool mutations Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../execute-copilot-server-tool/route.ts | 4 +- .../app/api/copilot/mcp/route.test.ts | 2 +- .../tradinggoose/app/api/copilot/mcp/route.ts | 1 + .../tools/client/server-tool-response.ts | 5 + .../lib/copilot/tools/server/base-tool.ts | 15 +++ .../tools/server/entities/custom-tool.ts | 20 +++- .../tools/server/entities/indicator.ts | 20 +++- .../tools/server/entities/mcp-server.ts | 86 ++++---------- .../tools/server/entities/shared.test.ts | 107 ++++++++++++++++++ .../copilot/tools/server/entities/shared.ts | 75 ++++++++---- .../copilot/tools/server/entities/skill.ts | 10 +- .../server/entities/workflow-variable.test.ts | 42 ++++++- .../copilot/tools/server/entities/workflow.ts | 36 ++++-- .../tools/server/knowledge/knowledge-base.ts | 15 ++- .../workflow/edit-workflow-block.test.ts | 4 +- .../server/workflow/edit-workflow-block.ts | 9 +- .../server/workflow/edit-workflow.test.ts | 24 ++-- .../tools/server/workflow/edit-workflow.ts | 8 +- .../workflow/workflow-mutation-utils.ts | 23 +++- .../tradinggoose/stores/copilot/store.test.ts | 4 + apps/tradinggoose/stores/copilot/store.ts | 2 + 21 files changed, 376 insertions(+), 136 deletions(-) create mode 100644 apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts diff --git a/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts b/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts index f04c53416..b389052b2 100644 --- a/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts +++ b/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts @@ -16,6 +16,7 @@ const logger = createLogger('ExecuteCopilotServerToolAPI') const ExecuteSchema = z.object({ toolName: z.string().min(1), payload: z.unknown().optional(), + accessLevel: z.enum(['limited', 'full']), reviewAction: z.enum(['accept']).optional(), reviewResult: z.unknown().optional(), context: z @@ -66,7 +67,7 @@ export async function POST(req: NextRequest) { throw error } toolName = parsedBody.toolName - const { payload, context, reviewAction, reviewResult } = parsedBody + const { payload, accessLevel, context, reviewAction, reviewResult } = parsedBody const payloadWorkspaceId = readPayloadWorkspaceId(payload) const contextWorkspaceId = context?.workspaceId?.trim() @@ -103,6 +104,7 @@ export async function POST(req: NextRequest) { const executionContext = { userId, + accessLevel, ...executionContextInput, signal: req.signal, } diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index 8f9052d8a..924c073c2 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -149,7 +149,7 @@ describe('Copilot MCP route', () => { expect(mockRouteExecution).toHaveBeenCalledWith( 'list_workflows', { workspaceId: 'workspace-1' }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'full' } ) expect(body.result.structuredContent).toEqual({ workflows: [] }) expect(body.result.content[0].text).toBe(JSON.stringify({ workflows: [] }, null, 2)) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index f994ef293..e8a139afc 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -176,6 +176,7 @@ async function handleJsonRpcRequest(request: JsonRpcRequest, auth: Authenticated try { const result = await routeExecution(toolCall.name, toolCall.args, { userId: auth.userId, + accessLevel: 'full', }) return jsonRpcResult(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], diff --git a/apps/tradinggoose/lib/copilot/tools/client/server-tool-response.ts b/apps/tradinggoose/lib/copilot/tools/client/server-tool-response.ts index b287c090e..127f71f44 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/server-tool-response.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/server-tool-response.ts @@ -1,3 +1,4 @@ +import type { CopilotAccessLevel } from '@/lib/copilot/access-policy' import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas' @@ -69,6 +70,7 @@ export function getCopilotServerToolErrorStatus(error: unknown): number | undefi export async function executeCopilotServerTool(input: { toolName: string payload?: unknown + accessLevel: CopilotAccessLevel context?: { contextEntityKind?: ReviewEntityKind contextEntityId?: string @@ -85,6 +87,7 @@ export async function executeCopilotServerTool(input: { body: JSON.stringify({ toolName: input.toolName, payload: input.payload ?? {}, + accessLevel: input.accessLevel, ...(context ? { context } : {}), }), }) @@ -111,6 +114,7 @@ export function isCopilotServerToolReviewResult(result: unknown): result is { export async function acceptCopilotServerToolReview(input: { toolName: string reviewResult: unknown + accessLevel: CopilotAccessLevel context?: { contextEntityKind?: ReviewEntityKind contextEntityId?: string @@ -126,6 +130,7 @@ export async function acceptCopilotServerToolReview(input: { signal: input.signal, body: JSON.stringify({ toolName: input.toolName, + accessLevel: input.accessLevel, reviewAction: 'accept', reviewResult: input.reviewResult, ...(context ? { context } : {}), diff --git a/apps/tradinggoose/lib/copilot/tools/server/base-tool.ts b/apps/tradinggoose/lib/copilot/tools/server/base-tool.ts index e6d6e8d65..3d29856f3 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/base-tool.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/base-tool.ts @@ -1,8 +1,13 @@ +import { + type CopilotAccessLevel, + shouldRequireToolApproval, +} from '@/lib/copilot/access-policy' import type { ToolId } from '@/lib/copilot/registry' import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' export interface ServerToolExecutionContext { userId: string + accessLevel?: CopilotAccessLevel contextEntityKind?: ReviewEntityKind contextEntityId?: string workspaceId?: string @@ -49,6 +54,16 @@ export function throwIfServerToolAborted(context?: ServerToolExecutionContext): throw error } +export function shouldStageServerToolMutationForReview( + context?: ServerToolExecutionContext +): boolean { + if (!context?.accessLevel) { + throw new Error('Copilot accessLevel is required to execute mutation tools') + } + + return shouldRequireToolApproval(context.accessLevel, true) +} + export interface BaseServerTool { name: ToolId execute(args: TArgs, context?: ServerToolExecutionContext): Promise diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts index fd8fb7bbf..4452fa8ae 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts @@ -6,9 +6,9 @@ import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { acceptEntityDocumentReview, - buildCreateEntityReviewResult, buildDocumentEnvelope, - buildUpdateEntityReviewResult, + executeCreateEntityDocumentMutation, + executeUpdateEntityDocumentMutation, type EntityCreateResult, type EntityListEntry, type EntityServerTool, @@ -120,21 +120,31 @@ export const readCustomToolServerTool: EntityServerTool = { export const createCustomToolServerTool: EntityServerTool = { name: 'create_custom_tool', execute(args, context) { - return buildCreateEntityReviewResult(ENTITY_KIND_CUSTOM_TOOL, args, context) + return executeCreateEntityDocumentMutation( + ENTITY_KIND_CUSTOM_TOOL, + args, + context, + createCustomToolEntity + ) }, } export const editCustomToolServerTool: EntityServerTool = { name: 'edit_custom_tool', execute(args, context) { - return buildUpdateEntityReviewResult(ENTITY_KIND_CUSTOM_TOOL, 'edit_custom_tool', args, context) + return executeUpdateEntityDocumentMutation( + ENTITY_KIND_CUSTOM_TOOL, + 'edit_custom_tool', + args, + context + ) }, } export const renameCustomToolServerTool: EntityServerTool = { name: 'rename_custom_tool', execute(args, context) { - return buildUpdateEntityReviewResult( + return executeUpdateEntityDocumentMutation( ENTITY_KIND_CUSTOM_TOOL, 'rename_custom_tool', args, diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts index 9d26f9e67..2d7c83dbd 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts @@ -15,9 +15,9 @@ import { } from '@/lib/yjs/entity-state' import { acceptEntityDocumentReview, - buildCreateEntityReviewResult, buildDocumentEnvelope, - buildUpdateEntityReviewResult, + executeCreateEntityDocumentMutation, + executeUpdateEntityDocumentMutation, type CopilotIndicatorListEntry, type EntityCreateResult, type EntityServerTool, @@ -152,21 +152,31 @@ export const readIndicatorServerTool: EntityServerTool = { export const createIndicatorServerTool: EntityServerTool = { name: 'create_indicator', execute(args, context) { - return buildCreateEntityReviewResult(ENTITY_KIND_INDICATOR, args, context) + return executeCreateEntityDocumentMutation( + ENTITY_KIND_INDICATOR, + args, + context, + createIndicatorEntity + ) }, } export const editIndicatorServerTool: EntityServerTool = { name: 'edit_indicator', execute(args, context) { - return buildUpdateEntityReviewResult(ENTITY_KIND_INDICATOR, 'edit_indicator', args, context) + return executeUpdateEntityDocumentMutation(ENTITY_KIND_INDICATOR, 'edit_indicator', args, context) }, } export const renameIndicatorServerTool: EntityServerTool = { name: 'rename_indicator', execute(args, context) { - return buildUpdateEntityReviewResult(ENTITY_KIND_INDICATOR, 'rename_indicator', args, context) + return executeUpdateEntityDocumentMutation( + ENTITY_KIND_INDICATOR, + 'rename_indicator', + args, + context + ) }, } diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts index 0d5328a61..62ea6a664 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -13,12 +13,12 @@ import { import { acceptEntityDocumentReview, applySavedEntityDocument, - buildDocumentDiff, buildDocumentEnvelope, + executeCreateEntityDocumentMutation, + executeUpdateEntityDocumentMutation, type EntityCreateResult, type EntityListEntry, type EntityServerTool, - parseEntityMutationDocument, readSavedEntityYjsFields, requireEntityId, verifySavedEntityContext, @@ -138,62 +138,6 @@ async function applyMcpServerDocument(input: { mcpService.clearCache(input.workspaceId) } -async function buildCreateMcpServerReviewResult(args: any, context: any) { - if (args.entityId?.trim()) { - throw new Error('create_mcp_server does not accept entityId') - } - - const { workspaceId } = await verifyWorkspaceContext( - withWorkspaceArgContext(context, args), - 'write' - ) - const fields = normalizeMcpServerFields(parseEntityMutationDocument(ENTITY_KIND_MCP_SERVER, args)) - - return { - requiresReview: true, - success: true, - workspaceId, - ...buildDocumentEnvelope(ENTITY_KIND_MCP_SERVER, undefined, fields), - preview: { - documentDiff: { - before: '', - after: buildDocumentEnvelope(ENTITY_KIND_MCP_SERVER, undefined, fields).entityDocument, - }, - }, - } -} - -async function buildUpdateMcpServerReviewResult( - toolName: string, - args: any, - context: any -) { - const fields = normalizeMcpServerFields( - parseEntityMutationDocument(ENTITY_KIND_MCP_SERVER, args) - ) - const entityId = requireEntityId(args, toolName) - const { workspaceId } = await verifySavedEntityContext( - context, - ENTITY_KIND_MCP_SERVER, - entityId, - 'write' - ) - const currentFields = await readSavedEntityYjsFields( - ENTITY_KIND_MCP_SERVER, - entityId, - workspaceId - ) - - return { - requiresReview: true, - success: true, - ...buildDocumentEnvelope(ENTITY_KIND_MCP_SERVER, entityId, fields), - preview: { - documentDiff: buildDocumentDiff(ENTITY_KIND_MCP_SERVER, currentFields, fields), - }, - } -} - export const listMcpServersServerTool: EntityServerTool> = { name: 'list_mcp_servers', async execute(args, context) { @@ -234,21 +178,41 @@ export const readMcpServerServerTool: EntityServerTool = { export const createMcpServerServerTool: EntityServerTool = { name: 'create_mcp_server', execute(args, context) { - return buildCreateMcpServerReviewResult(args, context) + return executeCreateEntityDocumentMutation( + ENTITY_KIND_MCP_SERVER, + args, + context, + createMcpServerEntity, + normalizeMcpServerFields + ) }, } export const editMcpServerServerTool: EntityServerTool = { name: 'edit_mcp_server', execute(args, context) { - return buildUpdateMcpServerReviewResult('edit_mcp_server', args, context) + return executeUpdateEntityDocumentMutation( + ENTITY_KIND_MCP_SERVER, + 'edit_mcp_server', + args, + context, + applyMcpServerDocument, + normalizeMcpServerFields + ) }, } export const renameMcpServerServerTool: EntityServerTool = { name: 'rename_mcp_server', execute(args, context) { - return buildUpdateMcpServerReviewResult('rename_mcp_server', args, context) + return executeUpdateEntityDocumentMutation( + ENTITY_KIND_MCP_SERVER, + 'rename_mcp_server', + args, + context, + applyMcpServerDocument, + normalizeMcpServerFields + ) }, } diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts new file mode 100644 index 000000000..bc7707b6d --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SKILL_DOCUMENT_FORMAT } from '@/lib/copilot/entity-documents' +import { + executeCreateEntityDocumentMutation, + executeUpdateEntityDocumentMutation, +} from './shared' + +const mockApplySavedEntityState = vi.hoisted(() => vi.fn()) +const mockCheckWorkspaceAccess = vi.hoisted(() => vi.fn()) +const mockReadBootstrappedReviewTargetSnapshot = vi.hoisted(() => vi.fn()) +const mockVerifyReviewTargetAccess = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/permissions/utils', () => ({ + checkWorkspaceAccess: (...args: unknown[]) => mockCheckWorkspaceAccess(...args), +})) + +vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ + verifyReviewTargetAccess: (...args: unknown[]) => mockVerifyReviewTargetAccess(...args), +})) + +vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ + applySavedEntityState: (...args: unknown[]) => mockApplySavedEntityState(...args), +})) + +vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ + readBootstrappedReviewTargetSnapshot: (...args: unknown[]) => + mockReadBootstrappedReviewTargetSnapshot(...args), +})) + +describe('entity document mutation helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + }) + mockVerifyReviewTargetAccess.mockResolvedValue({ + hasAccess: true, + workspaceId: 'workspace-1', + }) + }) + + it('applies full-access updates without building a review preview', async () => { + const result = await executeUpdateEntityDocumentMutation( + 'skill', + 'edit_skill', + { + entityId: 'skill-1', + documentFormat: SKILL_DOCUMENT_FORMAT, + entityDocument: JSON.stringify({ + name: 'Updated Skill', + description: 'Updated description', + content: 'Use the updated process.', + }), + }, + { userId: 'user-1', accessLevel: 'full' } + ) + + expect(result).toMatchObject({ + success: true, + workspaceId: 'workspace-1', + entityKind: 'skill', + entityId: 'skill-1', + entityName: 'Updated Skill', + documentFormat: SKILL_DOCUMENT_FORMAT, + }) + expect(result).not.toHaveProperty('requiresReview') + expect(result).not.toHaveProperty('preview') + expect(mockApplySavedEntityState).toHaveBeenCalledWith('skill', 'skill-1', { + name: 'Updated Skill', + description: 'Updated description', + content: 'Use the updated process.', + }) + expect(mockReadBootstrappedReviewTargetSnapshot).not.toHaveBeenCalled() + }) + + it('keeps Studio create mutations in review mode', async () => { + const result = await executeCreateEntityDocumentMutation( + 'skill', + { + workspaceId: 'workspace-1', + documentFormat: SKILL_DOCUMENT_FORMAT, + entityDocument: JSON.stringify({ + name: 'New Skill', + description: 'New description', + content: 'Use the new process.', + }), + }, + { userId: 'user-1', accessLevel: 'limited' }, + vi.fn() + ) + + expect(result).toMatchObject({ + requiresReview: true, + success: true, + workspaceId: 'workspace-1', + entityKind: 'skill', + entityName: 'New Skill', + documentFormat: SKILL_DOCUMENT_FORMAT, + }) + expect('preview' in result ? result.preview.documentDiff.before : undefined).toBe('') + expect('preview' in result ? result.preview.documentDiff.after : undefined).toContain( + 'New Skill' + ) + }) +}) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index 270bf422e..3ece9c79b 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -11,7 +11,10 @@ import type { BaseServerTool, ServerToolExecutionContext, } from '@/lib/copilot/tools/server/base-tool' -import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' +import { + shouldStageServerToolMutationForReview, + withWorkspaceArgContext, +} from '@/lib/copilot/tools/server/base-tool' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { getEntityFields } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' @@ -67,6 +70,10 @@ export type ApplyEntityDocument = (input: { workspaceId: string }) => Promise +export type PrepareEntityDocumentFields = ( + fields: Record +) => Record + export const ENTITY_KIND_LABELS: Record = { skill: 'skill', custom_tool: 'custom tool', @@ -216,10 +223,12 @@ export async function applySavedEntityDocument( await applySavedEntityState(kind as SavedEntityKind, entityId, fields) } -export async function buildCreateEntityReviewResult( +export async function executeCreateEntityDocumentMutation( kind: SavedEntityDocumentKind, args: EntityDocumentArgs, - context: ServerToolExecutionContext | undefined + context: ServerToolExecutionContext | undefined, + create: CreateEntityFromDocument, + prepareFields?: PrepareEntityDocumentFields ) { if (args.entityId?.trim()) { throw new Error(`create_${kind} does not accept entityId`) @@ -227,40 +236,66 @@ export async function buildCreateEntityReviewResult( const scopedContext = withWorkspaceArgContext(context, args) const { workspaceId } = await verifyWorkspaceContext(scopedContext, 'write') - const fields = parseEntityMutationDocument(kind, args) + const parsedFields = parseEntityMutationDocument(kind, args) + const fields = prepareFields ? prepareFields(parsedFields) : parsedFields + if (shouldStageServerToolMutationForReview(context)) { + return { + requiresReview: true, + success: true, + workspaceId, + ...buildDocumentEnvelope(kind, undefined, fields), + preview: { + documentDiff: { + before: '', + after: serializeEntityDocument(kind, fields), + }, + }, + } + } + + const created = await create(fields, scopedContext) return { - requiresReview: true, success: true, workspaceId, - ...buildDocumentEnvelope(kind, undefined, fields), - preview: { - documentDiff: { - before: '', - after: serializeEntityDocument(kind, fields), - }, - }, + ...buildDocumentEnvelope(kind, created.entityId, created.fields), } } -export async function buildUpdateEntityReviewResult( +export async function executeUpdateEntityDocumentMutation( kind: SavedEntityDocumentKind, toolName: string, args: EntityDocumentArgs, - context: ServerToolExecutionContext | undefined + context: ServerToolExecutionContext | undefined, + apply?: ApplyEntityDocument, + prepareFields?: PrepareEntityDocumentFields ) { - const fields = parseEntityMutationDocument(kind, args) + const parsedFields = parseEntityMutationDocument(kind, args) + const fields = prepareFields ? prepareFields(parsedFields) : parsedFields const entityId = requireEntityId(args, toolName) const { workspaceId } = await verifySavedEntityContext(context, kind, entityId, 'write') - const currentFields = await readSavedEntityYjsFields(kind, entityId, workspaceId) + if (shouldStageServerToolMutationForReview(context)) { + const currentFields = await readSavedEntityYjsFields(kind, entityId, workspaceId) + return { + requiresReview: true, + success: true, + ...buildDocumentEnvelope(kind, entityId, fields), + preview: { + documentDiff: buildDocumentDiff(kind, currentFields, fields), + }, + } + } + + if (apply) { + await apply({ entityId, fields, workspaceId }) + } else { + await applySavedEntityDocument(kind, entityId, fields) + } return { - requiresReview: true, success: true, + workspaceId, ...buildDocumentEnvelope(kind, entityId, fields), - preview: { - documentDiff: buildDocumentDiff(kind, currentFields, fields), - }, } } diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts index ddd27adc8..a63859460 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts @@ -5,9 +5,9 @@ import { listSkills, upsertSkills } from '@/lib/skills/operations' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { acceptEntityDocumentReview, - buildCreateEntityReviewResult, buildDocumentEnvelope, - buildUpdateEntityReviewResult, + executeCreateEntityDocumentMutation, + executeUpdateEntityDocumentMutation, type EntityCreateResult, type EntityDocumentArgs, type EntityListEntry, @@ -92,21 +92,21 @@ export const readSkillServerTool: EntityServerTool = { export const createSkillServerTool: EntityServerTool = { name: 'create_skill', execute(args, context) { - return buildCreateEntityReviewResult(ENTITY_KIND_SKILL, args, context) + return executeCreateEntityDocumentMutation(ENTITY_KIND_SKILL, args, context, createSkillEntity) }, } export const editSkillServerTool: EntityServerTool = { name: 'edit_skill', execute(args, context) { - return buildUpdateEntityReviewResult(ENTITY_KIND_SKILL, 'edit_skill', args, context) + return executeUpdateEntityDocumentMutation(ENTITY_KIND_SKILL, 'edit_skill', args, context) }, } export const renameSkillServerTool: EntityServerTool = { name: 'rename_skill', execute(args, context) { - return buildUpdateEntityReviewResult(ENTITY_KIND_SKILL, 'rename_skill', args, context) + return executeUpdateEntityDocumentMutation(ENTITY_KIND_SKILL, 'rename_skill', args, context) }, } diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts index 680fe5adb..2eef6876e 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts @@ -90,7 +90,7 @@ describe('workflow variable server tools', () => { it('returns workflow variables through read_workflow', async () => { const result = await readWorkflowServerTool.execute( { entityId: 'wf-1' }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) expect(result.workflowVariableDocumentFormat).toBe(WORKFLOW_VARIABLE_DOCUMENT_FORMAT) @@ -111,7 +111,7 @@ describe('workflow variable server tools', () => { ], }), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) expect(result).toMatchObject({ @@ -142,6 +142,40 @@ describe('workflow variable server tools', () => { expect(result.preview.documentDiff.after).toContain('enabled') }) + it('applies full-access workflow variable edits through the workflow Yjs bridge', async () => { + const result = await editWorkflowVariableServerTool.execute( + { + entityId: 'wf-1', + documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, + entityDocument: JSON.stringify({ + variables: [ + { name: 'riskLimit', type: 'number', value: 25 }, + { name: 'enabled', type: 'boolean', value: true }, + ], + }), + }, + { userId: 'user-1', accessLevel: 'full' } + ) + + expect(result.requiresReview).toBeUndefined() + expect(result.preview).toBeUndefined() + expect(result).toMatchObject({ + success: true, + entityKind: 'workflow', + entityId: 'wf-1', + workspaceId: 'workspace-1', + documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, + }) + expect(mockApplyWorkflowStateInSocketServer).toHaveBeenCalledWith( + 'wf-1', + expect.objectContaining({ + blocks: {}, + edges: [], + }), + result.variables + ) + }) + it('applies accepted workflow variable reviews through the workflow Yjs bridge', async () => { const result = await editWorkflowVariableServerTool.execute( { @@ -151,10 +185,10 @@ describe('workflow variable server tools', () => { variables: [{ name: 'riskLimit', type: 'number', value: 25 }], }), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) - await acceptWorkflowDocumentReview('edit_workflow_variable', result, { userId: 'user-1' }) + await acceptWorkflowDocumentReview('edit_workflow_variable', result, { userId: 'user-1', accessLevel: 'limited' }) expect(mockApplyWorkflowStateInSocketServer).toHaveBeenCalledWith( 'wf-1', diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index 7daac105d..c21f57e45 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -14,7 +14,10 @@ import type { BaseServerTool, ServerToolExecutionContext, } from '@/lib/copilot/tools/server/base-tool' -import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' +import { + shouldStageServerToolMutationForReview, + withWorkspaceArgContext, +} from '@/lib/copilot/tools/server/base-tool' import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target' import { generateCreativeWorkflowName } from '@/lib/naming' import { checkWorkspaceAccess } from '@/lib/permissions/utils' @@ -427,7 +430,7 @@ export const editWorkflowVariableServerTool: BaseServerTool< ) } const workflowId = requireCopilotEntityId(args, { toolName: 'edit_workflow_variable' }) - const { workspaceId, variables } = await loadWorkflowSnapshotForCopilot( + const { workspaceId, workflowState, variables } = await loadWorkflowSnapshotForCopilot( workflowId, context, 'write' @@ -437,11 +440,30 @@ export const editWorkflowVariableServerTool: BaseServerTool< currentVariables: variables, entityDocument: args.entityDocument, }) - const currentDocument = serializeWorkflowVariableDocument(variables) const nextDocument = serializeWorkflowVariableDocument(nextVariables) + if (shouldStageServerToolMutationForReview(context)) { + const currentDocument = serializeWorkflowVariableDocument(variables) + return { + requiresReview: true, + success: true, + entityKind: ENTITY_KIND_WORKFLOW, + entityId: workflowId, + ...(workspaceId ? { workspaceId } : {}), + documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, + entityDocument: nextDocument, + variables: nextVariables, + preview: { + documentDiff: { + before: currentDocument, + after: nextDocument, + }, + }, + } + } + + await applyWorkflowStateInSocketServer(workflowId, workflowState, nextVariables) return { - requiresReview: true, success: true, entityKind: ENTITY_KIND_WORKFLOW, entityId: workflowId, @@ -449,12 +471,6 @@ export const editWorkflowVariableServerTool: BaseServerTool< documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, entityDocument: nextDocument, variables: nextVariables, - preview: { - documentDiff: { - before: currentDocument, - after: nextDocument, - }, - }, } }, } diff --git a/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts index 4f7a7d811..326474ead 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -16,9 +16,9 @@ import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { getQueryStrategy, handleVectorOnlySearch } from '@/app/api/knowledge/search/utils' import { acceptEntityDocumentReview, - buildCreateEntityReviewResult, buildDocumentEnvelope, - buildUpdateEntityReviewResult, + executeCreateEntityDocumentMutation, + executeUpdateEntityDocumentMutation, type EntityCreateResult, type EntityDocumentArgs, type EntityServerTool, @@ -140,14 +140,19 @@ export const readKnowledgeBaseServerTool: EntityServerTool = { export const createKnowledgeBaseServerTool: EntityServerTool = { name: 'create_knowledge_base', execute(args, context) { - return buildCreateEntityReviewResult(ENTITY_KIND_KNOWLEDGE_BASE, args, context) + return executeCreateEntityDocumentMutation( + ENTITY_KIND_KNOWLEDGE_BASE, + args, + context, + createKnowledgeBaseEntity + ) }, } export const editKnowledgeBaseServerTool: EntityServerTool = { name: 'edit_knowledge_base', execute(args, context) { - return buildUpdateEntityReviewResult( + return executeUpdateEntityDocumentMutation( ENTITY_KIND_KNOWLEDGE_BASE, 'edit_knowledge_base', args, @@ -159,7 +164,7 @@ export const editKnowledgeBaseServerTool: EntityServerTool = export const renameKnowledgeBaseServerTool: EntityServerTool = { name: 'rename_knowledge_base', execute(args, context) { - return buildUpdateEntityReviewResult( + return executeUpdateEntityDocumentMutation( ENTITY_KIND_KNOWLEDGE_BASE, 'rename_knowledge_base', args, diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.test.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.test.ts index 25fd6b17b..5257f4fef 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.test.ts @@ -72,7 +72,7 @@ describe('editWorkflowBlockServerTool', () => { code: 'return { rsi: 50 }', }, }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) expect(result.workflowState.blocks.fn1.name).toBe('Compute Market Indicators') @@ -92,7 +92,7 @@ describe('editWorkflowBlockServerTool', () => { madeUpField: 'bad', }, }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) ).rejects.toMatchObject({ code: 'invalid_workflow_block_edit', diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.ts index 9f2ad8368..3f88183f3 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.ts @@ -8,7 +8,11 @@ import { createLogger } from '@/lib/logs/console/logger' import { getAllowedSubBlockIds } from '@/lib/workflows/block-config-canonicalization' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' import { getBlock } from '@/blocks' -import { buildWorkflowMutationResult, loadBaseWorkflowState } from './workflow-mutation-utils' +import { + buildWorkflowMutationResult, + loadBaseWorkflowState, + resolveWorkflowMutationResultForExecution, +} from './workflow-mutation-utils' interface EditWorkflowBlockParams { entityId: string @@ -176,12 +180,13 @@ export const editWorkflowBlockServerTool: BaseServerTool { ' n1 --> n2', ]), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) expect(result.workflowState.blocks.fn1.name).toBe('Compute Indicators') @@ -110,7 +110,7 @@ describe('editWorkflowServerTool', () => { ' n1 --> n2', ]), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) ).rejects.toThrow('Use edit_workflow_block to rename existing blocks.') @@ -125,7 +125,7 @@ describe('editWorkflowServerTool', () => { ' input1 --> fn1', ]), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) ).rejects.toThrow('Use edit_workflow_block to rename existing blocks.') }) @@ -142,7 +142,7 @@ describe('editWorkflowServerTool', () => { ' n1 --> n2', ]), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) ).rejects.toThrow( 'Existing block ids are immutable identities in edit_workflow; this tool cannot replace an existing block or change its type.' @@ -165,7 +165,7 @@ describe('editWorkflowServerTool', () => { ' n1 --> n2', ]), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) expect(result.workflowState.blocks.fn2).toMatchObject({ @@ -196,7 +196,7 @@ describe('editWorkflowServerTool', () => { ' n3["Compute Indicators
id: fn1
type: function"]', ]), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) expect(result.workflowState.blocks.fn2.position).toEqual({ x: 0, y: 360 }) @@ -232,7 +232,7 @@ describe('editWorkflowServerTool', () => { ' end', ]), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) expect(result.workflowState.blocks.fn1.data).toMatchObject({ @@ -252,7 +252,7 @@ describe('editWorkflowServerTool', () => { ' n1["Input Form
id: input1
type: input_trigger
enabled: false
outputs: {}
data.foo: bar
subBlocks.code: return 1"]', ]), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) ).rejects.toThrow( 'Workflow graph Mermaid block "input1" includes block-internal fields (enabled, outputs, data.foo, subBlocks.code).' @@ -269,7 +269,7 @@ describe('editWorkflowServerTool', () => { ' n1["Input Form
id: input1
type: input_trigger"]', ]), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) ).rejects.toThrow( 'Existing block ids omitted from edit_workflow entityDocument without removedBlockIds: fn1' @@ -303,7 +303,7 @@ describe('editWorkflowServerTool', () => { entityDocument: graph(['flowchart TD', 'input1["Input Form"]']), removedBlockIds: ['loop1'], }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) expect(result.workflowState.blocks).toHaveProperty('input1') @@ -324,7 +324,7 @@ describe('editWorkflowServerTool', () => { ]), removedBlockIds: ['fn1'], }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) ).rejects.toThrow('removedBlockIds still appear in edit_workflow entityDocument: fn1') }) @@ -340,7 +340,7 @@ describe('editWorkflowServerTool', () => { '%% TG_BLOCK {"id":"input1","type":"input_trigger","name":"Input Form","position":{"x":0,"y":0},"subBlocks":{},"outputs":{},"enabled":true}', ]), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) ).rejects.toThrow('Workflow graph Mermaid must not include TG_* metadata comments') }) diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.ts index c01bf05ed..eda4c74fd 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.ts @@ -13,7 +13,11 @@ import { createWorkflowSnapshot, type WorkflowSnapshot } from '@/lib/yjs/workflo import { getBlock } from '@/blocks' import type { BlockState, Position } from '@/stores/workflows/workflow/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { buildWorkflowMutationResult, loadBaseWorkflowState } from './workflow-mutation-utils' +import { + buildWorkflowMutationResult, + loadBaseWorkflowState, + resolveWorkflowMutationResultForExecution, +} from './workflow-mutation-utils' interface EditWorkflowParams { entityId: string @@ -275,6 +279,6 @@ export const editWorkflowServerTool: BaseServerTool = { warningCount: result.preview?.warnings.length ?? 0, }) - return result + return resolveWorkflowMutationResultForExecution(result, context) }, } diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts index 616dfec71..d1199a951 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts @@ -1,5 +1,8 @@ import * as Y from 'yjs' -import type { ServerToolExecutionContext } from '@/lib/copilot/tools/server/base-tool' +import { + shouldStageServerToolMutationForReview, + type ServerToolExecutionContext, +} from '@/lib/copilot/tools/server/base-tool' import { findIntroducedNonCanonicalSubBlocks } from '@/lib/workflows/block-config-canonicalization' import { WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' import { @@ -11,6 +14,7 @@ import { import { validateWorkflowState } from '@/lib/workflows/validation' import { normalizeWorkflowStateToMermaidDirection } from '@/lib/workflows/workflow-direction' import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' +import { applyWorkflowStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot, readWorkflowSnapshot, @@ -120,3 +124,20 @@ export function buildWorkflowMutationResult(params: { }, } } + +export async function resolveWorkflowMutationResultForExecution( + result: ReturnType, + context?: ServerToolExecutionContext +) { + if (shouldStageServerToolMutationForReview(context)) { + return result + } + + await applyWorkflowStateInSocketServer( + result.entityId, + createWorkflowSnapshot(result.workflowState as Partial) + ) + + const { requiresReview: _requiresReview, preview: _preview, ...appliedResult } = result + return appliedResult +} diff --git a/apps/tradinggoose/stores/copilot/store.test.ts b/apps/tradinggoose/stores/copilot/store.test.ts index 98be421a4..b247d578a 100644 --- a/apps/tradinggoose/stores/copilot/store.test.ts +++ b/apps/tradinggoose/stores/copilot/store.test.ts @@ -2188,6 +2188,7 @@ describe('copilot streaming regressions', () => { }) expect(parseJsonRequestBody(executeRequest)).toEqual({ toolName: 'list_workflows', + accessLevel: 'limited', payload: { workspaceId: 'workspace-1', }, @@ -2949,6 +2950,7 @@ describe('copilot tool user action delegation', () => { }) expect(parseJsonRequestBody(executeRequest)).toEqual({ toolName: 'edit_workflow', + accessLevel: 'limited', payload: { entityDocument: 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', @@ -3033,6 +3035,7 @@ describe('copilot tool user action delegation', () => { }) expect(parseJsonRequestBody(executeRequest)).toEqual({ toolName: 'edit_workflow', + accessLevel: 'limited', reviewAction: 'accept', reviewResult, }) @@ -3104,6 +3107,7 @@ describe('copilot tool user action delegation', () => { }) expect(parseJsonRequestBody(executeRequest)).toEqual({ toolName: 'make_api_request', + accessLevel: 'full', payload: { url: 'https://example.com/data', method: 'GET', diff --git a/apps/tradinggoose/stores/copilot/store.ts b/apps/tradinggoose/stores/copilot/store.ts index 2c6463063..4043a38f2 100644 --- a/apps/tradinggoose/stores/copilot/store.ts +++ b/apps/tradinggoose/stores/copilot/store.ts @@ -1401,12 +1401,14 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) ? await acceptCopilotServerToolReview({ toolName: name, reviewResult: get().toolCallsById[id]?.result, + accessLevel: get().accessLevel, context: serverContext, signal: get().abortController?.signal, }) : await executeCopilotServerTool({ toolName: name, payload: preparedArgs, + accessLevel: get().accessLevel, context: serverContext, signal: get().abortController?.signal, }) From cba7a38342bb46251d16342566177374d74067ef Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 19 Jun 2026 23:15:11 -0600 Subject: [PATCH 005/284] feat(mcp): add path-based installer scripts Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../[locale]/(auth)/mcp/authorize/page.tsx | 2 +- .../app/api/copilot/mcp/route.test.ts | 1 + .../tradinggoose/app/api/copilot/mcp/route.ts | 2 +- .../app/mcp/[[...command]]/route.test.ts | 107 ++++ .../app/mcp/[[...command]]/route.ts | 70 +++ apps/tradinggoose/app/mcp/route.test.ts | 53 -- apps/tradinggoose/app/mcp/route.ts | 14 - apps/tradinggoose/lib/mcp/install-script.ts | 467 ++++++++++++------ .../mcp/local-config-writer-script.test.ts | 157 ++++++ .../lib/mcp/local-config-writer-script.ts | 147 ++++-- apps/tradinggoose/proxy.test.ts | 64 +++ apps/tradinggoose/proxy.ts | 32 +- 12 files changed, 840 insertions(+), 276 deletions(-) create mode 100644 apps/tradinggoose/app/mcp/[[...command]]/route.test.ts create mode 100644 apps/tradinggoose/app/mcp/[[...command]]/route.ts delete mode 100644 apps/tradinggoose/app/mcp/route.test.ts delete mode 100644 apps/tradinggoose/app/mcp/route.ts create mode 100644 apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts diff --git a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx index c9686ad65..6a71016e6 100644 --- a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx +++ b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx @@ -80,7 +80,7 @@ export default async function McpAuthorizePage({ return ( ) } diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index 924c073c2..9e67fd804 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -108,6 +108,7 @@ describe('Copilot MCP route', () => { expect(mockUpdateApiKeyLastUsed).toHaveBeenCalledWith('key-1') expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ userId: 'user-1', autoCreate: false }) expect(body.result.capabilities).toEqual({ tools: {} }) + expect(body.result.serverInfo).toEqual({ name: 'TradingGoose', version: '0.1.0' }) expect(body.result.instructions).toContain('workspaceId=workspace-1, permissions=admin') expect(body.result.instructions).toContain('workspaceId=workspace-2, permissions=read') expect(body.result.instructions).toContain( diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index e8a139afc..f4df784a9 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -7,7 +7,7 @@ import { getUserWorkspaces } from '@/lib/workspaces/service' export const dynamic = 'force-dynamic' const MCP_PROTOCOL_VERSION = '2025-03-26' -const SERVER_NAME = 'tradinggoose-copilot' +const SERVER_NAME = 'TradingGoose' const SERVER_VERSION = '0.1.0' type JsonRpcId = string | number | null diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts new file mode 100644 index 000000000..013482cea --- /dev/null +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts @@ -0,0 +1,107 @@ +/** + * @vitest-environment node + */ + +import { spawnSync } from 'child_process' +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' + +async function callInstaller(pathname: string, command?: string[], headers?: HeadersInit) { + const { GET } = await import('./route') + return GET(new NextRequest(`https://studio.example.test${pathname}`, { headers }), { + params: Promise.resolve({ command }), + }) +} + +function expectShellScript(script: string) { + const shellCheck = spawnSync('sh', ['-n', '-c', script], { + encoding: 'utf8', + timeout: 5000, + }) + expect(shellCheck.status).toBe(0) + expect(shellCheck.stderr).toBe('') +} + +describe('MCP install route', () => { + it('serves the default setup script at /mcp', async () => { + const response = await callInstaller('/mcp') + const script = await response.text() + + expectShellScript(script) + expect(response.headers.get('Content-Type')).toBe('text/x-shellscript; charset=utf-8') + expect(script).toContain('BASE_URL="https://studio.example.test"') + expect(script).toContain('COMMAND="setup"') + expect(script).toContain('TARGETS=""') + expect(script).toContain('curl -fsSL /mcp/setup | sh') + expect(script).toContain('curl -fsSL /mcp/setup/codex | sh') + expect(script).toContain('curl -fsSL /mcp/login | sh') + expect(script).toContain('irm /mcp/setup | iex') + expect(script).toContain('irm /mcp/setup/codex | iex') + expect(script).toContain('irm /mcp/login | iex') + expect(script).toContain("baseUrl + '/api/auth/mcp/start'") + expect(script).toContain("baseUrl + '/api/auth/mcp/poll'") + expect(script).toContain("baseUrl + '/api/auth/mcp/revoke'") + expect(script).toContain("baseUrl + '/api/copilot/mcp'") + expect(script).toContain("Authorization: Bearer ' + token") + expect(script).toContain('setup Authenticate, rotate local MCP auth, and write config.') + expect(script).toContain('read-tokens') + expect(script).toContain('node - "$BASE_URL" "$COMMAND" "$TARGETS"') + expect(script).toContain("runConfigWriter([target, mcpUrl, token])") + expect(script).toContain("const mcpServerName = 'TradingGoose'") + expect(script).toContain("const codexBearerTokenEnvVar = 'TRADINGGOOSE_BEARER_TOKEN'") + expect(script).toContain("'bearer_token_env_var = ' + JSON.stringify(codexBearerTokenEnvVar)") + expect(script).toContain("spawnSync('setx', [codexBearerTokenEnvVar, token]") + expect(script).toContain("path.join(os.homedir(), '.codex', 'config.toml')") + expect(script).toContain("path.join(os.homedir(), '.cursor', 'mcp.json')") + expect(script).toContain("path.join(os.homedir(), '.claude.json')") + expect(script).toContain("path.join(os.homedir(), '.config', 'opencode', 'opencode.json')") + expect(script).not.toContain('workspaceId') + expect(script).not.toContain('entityId') + }) + + it('serves target-specific setup scripts from the URL path', async () => { + const response = await callInstaller('/mcp/setup/codex', ['setup', 'codex']) + const script = await response.text() + + expectShellScript(script) + expect(script).toContain('COMMAND="setup"') + expect(script).toContain('TARGETS="codex"') + }) + + it('serves PowerShell scripts for PowerShell clients', async () => { + const response = await callInstaller('/mcp/setup/codex', ['setup', 'codex'], { + 'user-agent': 'Mozilla/5.0 PowerShell/7.5', + }) + const script = await response.text() + + expect(response.headers.get('Content-Type')).toBe('text/x-powershell; charset=utf-8') + expect(script).toContain("$BaseUrl = 'https://studio.example.test'") + expect(script).toContain("$Command = 'setup'") + expect(script).toContain("$Targets = @('codex')") + expect(script).toContain('irm /mcp/setup | iex') + expect(script).toContain("$NodeScript | & node - $BaseUrl $Command ($Targets -join ' ')") + expect(script).toContain("baseUrl + '/api/auth/mcp/start'") + expect(script).toContain("runConfigWriter(['read-tokens'])") + expect(script).toContain("const mcpServerName = 'TradingGoose'") + expect(script).toContain("const codexBearerTokenEnvVar = 'TRADINGGOOSE_BEARER_TOKEN'") + expect(script).toContain("'bearer_token_env_var = ' + JSON.stringify(codexBearerTokenEnvVar)") + expect(script).toContain("spawnSync('setx', [codexBearerTokenEnvVar, token]") + expect(script).not.toContain('#!/bin/sh') + }) + + it('serves login scripts from the URL path', async () => { + const response = await callInstaller('/mcp/login', ['login']) + const script = await response.text() + + expectShellScript(script) + expect(script).toContain('COMMAND="login"') + expect(script).toContain('TARGETS=""') + }) + + it('rejects unknown installer commands', async () => { + const response = await callInstaller('/mcp/authorize', ['authorize']) + + expect(response.status).toBe(404) + await expect(response.text()).resolves.toBe('Unknown MCP installer command\n') + }) +}) diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.ts new file mode 100644 index 000000000..068a40863 --- /dev/null +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.ts @@ -0,0 +1,70 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { + buildMcpInstallScript, + type McpInstallScriptFormat, + type McpInstallScriptOptions, +} from '../../../lib/mcp/install-script' + +export const dynamic = 'force-dynamic' + +const SETUP_TARGETS = new Set(['codex', 'cursor', 'claude', 'opencode', 'all']) + +function parseInstallOptions(command: string[] | undefined): McpInstallScriptOptions | null { + if (!command || command.length === 0) { + return { command: 'setup' } + } + + if (command.length === 1 && command[0] === 'login') { + return { command: 'login' } + } + + if (command[0] === 'setup') { + if (command.length === 1) { + return { command: 'setup' } + } + + const target = command[1] + if (command.length === 2 && SETUP_TARGETS.has(target)) { + return { + command: 'setup', + target: target as McpInstallScriptOptions['target'], + } + } + } + + return null +} + +function resolveScriptFormat(request: NextRequest): McpInstallScriptFormat { + const userAgent = request.headers.get('user-agent') ?? '' + return /\b(?:PowerShell|WindowsPowerShell|pwsh)\b/i.test(userAgent) ? 'powershell' : 'sh' +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ command?: string[] }> } +) { + const options = parseInstallOptions((await params).command) + if (!options) { + return new NextResponse('Unknown MCP installer command\n', { + status: 404, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + }, + }) + } + + const format = resolveScriptFormat(request) + + return new NextResponse(buildMcpInstallScript(request.nextUrl.origin, { ...options, format }), { + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': + format === 'powershell' + ? 'text/x-powershell; charset=utf-8' + : 'text/x-shellscript; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + }, + }) +} diff --git a/apps/tradinggoose/app/mcp/route.test.ts b/apps/tradinggoose/app/mcp/route.test.ts deleted file mode 100644 index 0f339ea4e..000000000 --- a/apps/tradinggoose/app/mcp/route.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @vitest-environment node - */ - -import { spawnSync } from 'child_process' -import { NextRequest } from 'next/server' -import { describe, expect, it } from 'vitest' - -describe('MCP install route', () => { - it('serves a setup script for auth and explicit local MCP target config', async () => { - const { GET } = await import('./route') - - const response = await GET(new NextRequest('https://studio.example.test/mcp')) - const script = await response.text() - - const shellCheck = spawnSync('sh', ['-n', '-c', script], { - encoding: 'utf8', - timeout: 5000, - }) - expect(shellCheck.status).toBe(0) - expect(shellCheck.stderr).toBe('') - expect(response.headers.get('Content-Type')).toBe('text/x-shellscript; charset=utf-8') - expect(script).toContain( - 'BASE_URL="$' + '{TRADINGGOOSE_BASE_URL:-https://studio.example.test}"' - ) - expect(script).toContain('curl -fsSL /mcp | sh -s -- setup --codex') - expect(script).toContain('$BASE_URL/api/auth/mcp/start') - expect(script).toContain('$BASE_URL/api/auth/mcp/poll') - expect(script).toContain('$BASE_URL/api/auth/mcp/revoke') - expect(script).toContain('$BASE_URL/api/copilot/mcp') - expect(script).toContain('Authorization: Bearer $TOKEN') - expect(script).toContain('setup Authenticate, rotate local MCP auth, and write config.') - expect(script).toContain('read-tokens') - expect(script).toContain('add_target codex') - expect(script).toContain('add_target cursor') - expect(script).toContain('add_target claude') - expect(script).toContain('add_target opencode') - expect(script).toContain('node - "$1" "$SCOPE" "$MCP_URL" "$TOKEN"') - expect(script).toContain('[mcp_servers.tradinggoose.http_headers]') - expect(script).toContain("path.join(os.homedir(), '.codex', 'config.toml')") - expect(script).toContain("path.join(os.homedir(), '.cursor', 'mcp.json')") - expect(script).toContain("path.join(os.homedir(), '.claude.json')") - expect(script).toContain("path.join(os.homedir(), '.config', 'opencode', 'opencode.json')") - expect(script).not.toContain('/mcp/copilot') - expect(script).not.toContain('/copilot-mcp |') - expect(script).not.toContain('/copilot-mcp/authorize') - expect(script).not.toContain('copilot-mcp.sh') - expect(script).not.toContain('TOKEN_FILE') - expect(script).not.toContain('copilot-mcp.json') - expect(script).not.toContain('workspaceId') - expect(script).not.toContain('entityId') - }) -}) diff --git a/apps/tradinggoose/app/mcp/route.ts b/apps/tradinggoose/app/mcp/route.ts deleted file mode 100644 index 43f030a36..000000000 --- a/apps/tradinggoose/app/mcp/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { buildMcpInstallScript } from '../../lib/mcp/install-script' - -export const dynamic = 'force-dynamic' - -export async function GET(request: NextRequest) { - return new NextResponse(buildMcpInstallScript(request.nextUrl.origin), { - headers: { - 'Cache-Control': 'no-store', - 'Content-Type': 'text/x-shellscript; charset=utf-8', - 'X-Content-Type-Options': 'nosniff', - }, - }) -} diff --git a/apps/tradinggoose/lib/mcp/install-script.ts b/apps/tradinggoose/lib/mcp/install-script.ts index 7002b2fb8..05448bfd8 100644 --- a/apps/tradinggoose/lib/mcp/install-script.ts +++ b/apps/tradinggoose/lib/mcp/install-script.ts @@ -1,36 +1,211 @@ import { MCP_LOCAL_CONFIG_WRITER_SCRIPT } from './local-config-writer-script' -export function buildMcpInstallScript(baseUrl: string) { +type McpInstallCommand = 'setup' | 'login' +type McpInstallTarget = 'codex' | 'cursor' | 'claude' | 'opencode' | 'all' +export type McpInstallScriptFormat = 'sh' | 'powershell' + +export interface McpInstallScriptOptions { + command: McpInstallCommand + target?: McpInstallTarget + format?: McpInstallScriptFormat +} + +function getInitialTargets(target: McpInstallTarget | undefined) { + if (!target) { + return '' + } + + return target === 'all' ? 'codex cursor claude opencode' : target +} + +function getInitialPowerShellTargets(target: McpInstallTarget | undefined) { + if (!target) { + return '@()' + } + + const targets = target === 'all' ? ['codex', 'cursor', 'claude', 'opencode'] : [target] + return `@(${targets.map((item) => `'${item}'`).join(', ')})` +} + +const MCP_LOCAL_INSTALLER_SCRIPT = String.raw`const { spawnSync } = require('child_process') + +const baseUrl = process.argv[2].replace(/\/+$/, '') +const command = process.argv[3] +const targets = process.argv[4] ? process.argv[4].split(/\s+/).filter(Boolean) : [] +const mcpUrl = baseUrl + '/api/copilot/mcp' +const configWriterScript = ${JSON.stringify(MCP_LOCAL_CONFIG_WRITER_SCRIPT)} + +function fail(message) { + console.error('tradinggoose-mcp: ' + message) + process.exit(1) +} + +function requireFetch() { + if (typeof fetch !== 'function') { + fail('node 18 or newer is required to rotate MCP auth.') + } +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function postJson(url, body, token) { + const response = await fetch(url, { + method: 'POST', + headers: { + ...(body ? { 'content-type': 'application/json' } : {}), + ...(token ? { authorization: 'Bearer ' + token } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) + + if (!response.ok) { + fail(url + ' failed with HTTP ' + response.status) + } + + return response.headers.get('content-type')?.includes('application/json') + ? response.json() + : null +} + +function runConfigWriter(args) { + const result = spawnSync(process.execPath, ['-', ...args], { + input: configWriterScript, + encoding: 'utf8', + }) + + if (result.status !== 0) { + fail((result.stderr || 'Failed to run the MCP config writer.').trim()) + } + + return result.stdout.trim() +} + +async function revokeExistingTokens() { + const tokens = runConfigWriter(['read-tokens']).split(/\r?\n/).filter(Boolean) + for (const token of tokens) { + await postJson(baseUrl + '/api/auth/mcp/revoke', null, token) + } +} + +async function authenticate() { + const startJson = await postJson(baseUrl + '/api/auth/mcp/start') + const code = String(startJson?.code || '') + const authorizeUrl = String(startJson?.authorizeUrl || '') + const intervalSeconds = Math.max(1, Number(startJson?.intervalSeconds) || 2) + + if (!code) { + fail('Studio did not return a login code') + } + if (!authorizeUrl) { + fail('Studio did not return an authorization URL') + } + + console.log('Open this URL in your browser to approve MCP access:') + console.log(authorizeUrl) + console.log('') + + const deadline = Date.now() + 600000 + while (Date.now() < deadline) { + const pollJson = await postJson(baseUrl + '/api/auth/mcp/poll', { code }) + const status = String(pollJson?.status || 'pending') + + if (status === 'approved') { + const token = String(pollJson?.apiKey || '') + if (!token) { + fail('Studio approved login without returning a token') + } + return token + } + + if (status === 'expired') { + fail('Login expired. Run the command again.') + } + + if (status !== 'pending') { + fail('Unexpected login status: ' + status) + } + + await sleep(intervalSeconds * 1000) + } + + fail('Timed out waiting for browser approval') +} + +async function main() { + requireFetch() + + if (command === 'login') { + await revokeExistingTokens() + const token = await authenticate() + console.log('MCP endpoint:') + console.log(mcpUrl) + console.log('') + console.log('Bearer token:') + console.log(token) + console.log('') + console.log('Use this MCP auth header:') + console.log('Authorization: Bearer ' + token) + return + } + + if (command === 'setup') { + if (targets.length === 0) { + fail('setup requires a selected target') + } + + await revokeExistingTokens() + const token = await authenticate() + console.log('Using MCP endpoint: ' + mcpUrl) + for (const target of targets) { + const configPath = runConfigWriter([target, mcpUrl, token]) + console.log('Configured ' + target + ': ' + configPath) + } + return + } + + fail('Unknown command: ' + command) +} + +main().catch((error) => fail(error instanceof Error ? error.message : String(error))) +` + +export function buildMcpInstallScript(baseUrl: string, options: McpInstallScriptOptions) { + return options.format === 'powershell' + ? buildPowerShellInstallScript(baseUrl, options) + : buildShellInstallScript(baseUrl, options) +} + +function buildShellInstallScript(baseUrl: string, options: McpInstallScriptOptions) { const normalizedBaseUrl = baseUrl.replace(/\/+$/, '') + const initialTargets = getInitialTargets(options.target) const script = String.raw`#!/bin/sh set -eu -BASE_URL="\${TRADINGGOOSE_BASE_URL:-${normalizedBaseUrl}}" -SCOPE="global" -TARGETS="" +BASE_URL="${normalizedBaseUrl}" +COMMAND="${options.command}" +TARGETS="${initialTargets}" usage() { cat <<'USAGE' TradingGoose MCP setup Usage: - curl -fsSL /mcp | sh -s -- login - curl -fsSL /mcp | sh -s -- setup --codex - curl -fsSL /mcp | sh -s -- setup --all + curl -fsSL /mcp/setup | sh + curl -fsSL /mcp/setup/codex | sh + curl -fsSL /mcp/login | sh + +PowerShell: + irm /mcp/setup | iex + irm /mcp/setup/codex | iex + irm /mcp/login | iex Commands: login Rotate local MCP auth and print a bearer token. setup Authenticate, rotate local MCP auth, and write config. Options: - --base-url Override the Studio URL embedded in this script. - --codex Configure Codex. - --cursor Configure Cursor. - --claude Configure Claude Code. - --opencode Configure OpenCode. - --all Configure Codex, Cursor, Claude Code, and OpenCode. - --project Write project-local config from the current directory. - --global Write user-global config. This is the default. -h, --help Show this help. USAGE } @@ -40,14 +215,6 @@ fail() { exit 1 } -json_string() { - sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p" -} - -json_number() { - sed -n "s/.*\"$1\":\([0-9][0-9]*\).*/\1/p" -} - add_target() { case " $TARGETS " in *" $1 "*) ;; @@ -61,7 +228,7 @@ choose_targets() { fi if [ ! -r /dev/tty ]; then - fail "setup requires a target. Pass --codex, --cursor, --claude, --opencode, or --all." + fail "setup requires an interactive terminal or a target URL such as /mcp/setup/codex." fi { @@ -90,121 +257,15 @@ choose_targets() { esac } -require_node() { +run_installer() { command -v node >/dev/null 2>&1 || fail "node is required to rotate MCP auth and write config." -} - -write_target_config() { - require_node - node - "$1" "$SCOPE" "$MCP_URL" "$TOKEN" <<'NODE' -${MCP_LOCAL_CONFIG_WRITER_SCRIPT} + node - "$BASE_URL" "$COMMAND" "$TARGETS" <<'NODE' +${MCP_LOCAL_INSTALLER_SCRIPT} NODE } -read_existing_tokens() { - require_node - node - read-tokens "$SCOPE" <<'NODE' -${MCP_LOCAL_CONFIG_WRITER_SCRIPT} -NODE -} - -revoke_existing_tokens() { - BASE_URL="\${BASE_URL%/}" - REVOKE_URL="$BASE_URL/api/auth/mcp/revoke" - TOKENS="$(read_existing_tokens)" - - [ -n "$TOKENS" ] || return 0 - - printf '%s\n' "$TOKENS" | while IFS= read -r OLD_TOKEN; do - [ -n "$OLD_TOKEN" ] || continue - curl -fsS -X POST -H "Authorization: Bearer $OLD_TOKEN" "$REVOKE_URL" >/dev/null - done -} - -authenticate() { - BASE_URL="\${BASE_URL%/}" - MCP_URL="$BASE_URL/api/copilot/mcp" - START_URL="$BASE_URL/api/auth/mcp/start" - POLL_URL="$BASE_URL/api/auth/mcp/poll" - - START_JSON="$(curl -fsS -X POST -H 'Content-Type: application/json' "$START_URL")" - CODE="$(printf '%s' "$START_JSON" | json_string code)" - AUTHORIZE_URL="$(printf '%s' "$START_JSON" | json_string authorizeUrl)" - INTERVAL="$(printf '%s' "$START_JSON" | json_number intervalSeconds)" - - [ -n "$CODE" ] || fail "Studio did not return a login code" - [ -n "$AUTHORIZE_URL" ] || fail "Studio did not return an authorization URL" - [ -n "$INTERVAL" ] || INTERVAL="2" - - echo "Open this URL in your browser to approve MCP access:" - echo "$AUTHORIZE_URL" - echo - - DEADLINE="$(($(date +%s) + 600))" - while [ "$(date +%s)" -lt "$DEADLINE" ]; do - POLL_JSON="$(curl -fsS -X POST -H 'Content-Type: application/json' -d "{\"code\":\"$CODE\"}" "$POLL_URL" || printf '{"status":"pending"}')" - STATUS="$(printf '%s' "$POLL_JSON" | json_string status)" - - case "$STATUS" in - approved) - TOKEN="$(printf '%s' "$POLL_JSON" | json_string apiKey)" - [ -n "$TOKEN" ] || fail "Studio approved login without returning a token" - return 0 - ;; - expired) - fail "Login expired. Run the command again." - ;; - pending|"") - sleep "$INTERVAL" - ;; - *) - fail "Unexpected login status: $STATUS" - ;; - esac - done - - fail "Timed out waiting for browser approval" -} - -COMMAND="\${1:-setup}" -if [ "$#" -gt 0 ]; then - shift -fi - while [ "$#" -gt 0 ]; do case "$1" in - --base-url) - shift - [ "$#" -gt 0 ] || fail "--base-url requires a value" - BASE_URL="$1" - ;; - --base-url=*) - BASE_URL="\${1#--base-url=}" - ;; - --codex) - add_target codex - ;; - --cursor) - add_target cursor - ;; - --claude) - add_target claude - ;; - --opencode) - add_target opencode - ;; - --all) - add_target codex - add_target cursor - add_target claude - add_target opencode - ;; - --project) - SCOPE="project" - ;; - --global) - SCOPE="global" - ;; -h|--help) usage exit 0 @@ -217,27 +278,12 @@ while [ "$#" -gt 0 ]; do done case "$COMMAND" in - login) - revoke_existing_tokens - authenticate - echo "MCP endpoint:" - echo "$MCP_URL" - echo - echo "Bearer token:" - echo "$TOKEN" - echo - echo "Use this MCP auth header:" - echo "Authorization: Bearer $TOKEN" - ;; setup) choose_targets - revoke_existing_tokens - authenticate - echo "Using MCP endpoint: $MCP_URL" - for TARGET in $TARGETS; do - CONFIG_PATH="$(write_target_config "$TARGET")" - echo "Configured $TARGET: $CONFIG_PATH" - done + run_installer + ;; + login) + run_installer ;; help|-h|--help) usage @@ -249,3 +295,120 @@ esac ` return script.replaceAll('\\${', '${') } + +function buildPowerShellInstallScript(baseUrl: string, options: McpInstallScriptOptions) { + const normalizedBaseUrl = baseUrl.replace(/\/+$/, '') + const initialTargets = getInitialPowerShellTargets(options.target) + + return String.raw`$ErrorActionPreference = 'Stop' + +$BaseUrl = '${normalizedBaseUrl}' +$Command = '${options.command}' +$Targets = ${initialTargets} + +function Show-Usage { + @' +TradingGoose MCP setup + +Usage: + irm /mcp/setup | iex + irm /mcp/setup/codex | iex + irm /mcp/login | iex + +POSIX shell: + curl -fsSL /mcp/setup | sh + curl -fsSL /mcp/setup/codex | sh + curl -fsSL /mcp/login | sh + +Commands: + login Rotate local MCP auth and print a bearer token. + setup Authenticate, rotate local MCP auth, and write config. + +Options: + -h, --help Show this help. +'@ | Write-Output +} + +function Fail([string] $Message) { + Write-Error "tradinggoose-mcp: $Message" + exit 1 +} + +function Add-Target([string] $Target) { + if ($script:Targets -notcontains $Target) { + $script:Targets += $Target + } +} + +function Choose-Targets { + if ($script:Targets.Count -gt 0) { + return + } + + Write-Host 'Choose local MCP target:' + Write-Host ' 1) Codex' + Write-Host ' 2) Cursor' + Write-Host ' 3) Claude Code' + Write-Host ' 4) OpenCode' + Write-Host ' 5) All' + $Choice = Read-Host 'Target [1-5]' + + switch ($Choice) { + '1' { Add-Target 'codex' } + '2' { Add-Target 'cursor' } + '3' { Add-Target 'claude' } + '4' { Add-Target 'opencode' } + '5' { + Add-Target 'codex' + Add-Target 'cursor' + Add-Target 'claude' + Add-Target 'opencode' + } + default { Fail "Invalid setup target: $Choice" } + } +} + +function Run-Installer { + if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + Fail 'node is required to rotate MCP auth and write config.' + } + + $NodeScript = @' +${MCP_LOCAL_INSTALLER_SCRIPT} +'@ + $NodeScript | & node - $BaseUrl $Command ($Targets -join ' ') + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } +} + +for ($Index = 0; $Index -lt $args.Count; $Index++) { + switch ($args[$Index]) { + '-h' { + Show-Usage + exit 0 + } + '--help' { + Show-Usage + exit 0 + } + default { + Fail "Unknown option: $($args[$Index])" + } + } +} + +switch ($Command) { + 'setup' { + Choose-Targets + Run-Installer + } + 'login' { + Run-Installer + } + default { + Fail "Unknown command: $Command" + } +} +` +} diff --git a/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts b/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts new file mode 100644 index 000000000..f895a899b --- /dev/null +++ b/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts @@ -0,0 +1,157 @@ +/** + * @vitest-environment node + */ + +import { spawnSync } from 'child_process' +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it } from 'vitest' +import { MCP_LOCAL_CONFIG_WRITER_SCRIPT } from './local-config-writer-script' + +type TestEnv = Record + +function runWriter(home: string, args: string[], env: TestEnv = {}) { + const scriptPath = join(home, 'writer.js') + writeFileSync(scriptPath, MCP_LOCAL_CONFIG_WRITER_SCRIPT, 'utf8') + const result = spawnSync('node', [scriptPath, ...args], { + cwd: home, + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + ...env, + }, + input: MCP_LOCAL_CONFIG_WRITER_SCRIPT, + timeout: 5000, + }) + + expect(result.status).toBe(0) + expect(result.stderr).toBe('') + return result.stdout +} + +function shellQuote(value: string) { + return `'${value.replaceAll("'", "'\\''")}'` +} + +function runWriterCapture(home: string, args: string[], env: TestEnv = {}) { + const scriptPath = join(home, 'writer.js') + const outputPath = join(home, 'writer.out') + writeFileSync(scriptPath, MCP_LOCAL_CONFIG_WRITER_SCRIPT, 'utf8') + const command = `node ${shellQuote(scriptPath)} ${args.map(shellQuote).join(' ')} > ${shellQuote(outputPath)}` + const result = spawnSync('sh', ['-c', command], { + cwd: home, + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + ...env, + }, + timeout: 5000, + }) + + expect(result.status).toBe(0) + expect(result.stderr).toBe('') + return readFileSync(outputPath, 'utf8') +} + +describe('MCP local config writer script', () => { + it('writes Codex config with a TradingGoose bearer token environment variable', () => { + const home = mkdtempSync(join(tmpdir(), 'tg-mcp-codex-')) + + runWriter(home, ['codex', 'http://localhost:3000/api/copilot/mcp', 'mcp-token']) + + const configPath = join(home, '.codex', 'config.toml') + expect(readFileSync(configPath, 'utf8')).toBe( + [ + '[mcp_servers.TradingGoose]', + 'url = "http://localhost:3000/api/copilot/mcp"', + 'bearer_token_env_var = "TRADINGGOOSE_BEARER_TOKEN"', + '', + ].join('\n') + ) + }) + + it('replaces the canonical Codex config while preserving other servers', () => { + const home = mkdtempSync(join(tmpdir(), 'tg-mcp-codex-replace-')) + const configPath = join(home, '.codex', 'config.toml') + mkdirSync(join(home, '.codex'), { recursive: true }) + writeFileSync( + configPath, + [ + '[mcp_servers.TradingGoose]', + 'url = "http://localhost:3000/api/copilot/mcp"', + '', + '[mcp_servers.other]', + 'command = "npx"', + '', + ].join('\n'), + 'utf8' + ) + + runWriter(home, ['codex', 'http://localhost:3000/api/copilot/mcp', 'new-token']) + + const config = readFileSync(configPath, 'utf8') + expect(config.match(/\[mcp_servers\.TradingGoose\]/g)).toHaveLength(1) + expect(config).toContain('bearer_token_env_var = "TRADINGGOOSE_BEARER_TOKEN"') + expect(config).toContain('[mcp_servers.other]') + expect(config).not.toContain('Authorization = "Bearer') + }) + + it('reads Codex bearer token from the configured environment variable', () => { + const home = mkdtempSync(join(tmpdir(), 'tg-mcp-codex-token-')) + const configPath = join(home, '.codex', 'config.toml') + mkdirSync(join(home, '.codex'), { recursive: true }) + writeFileSync( + configPath, + [ + '[mcp_servers.TradingGoose]', + 'url = "http://localhost:3000/api/copilot/mcp"', + 'bearer_token_env_var = "TRADINGGOOSE_BEARER_TOKEN"', + '', + ].join('\n'), + 'utf8' + ) + + const stdout = runWriterCapture(home, ['read-tokens'], { + TRADINGGOOSE_BEARER_TOKEN: 'existing-token', + }) + + expect(stdout.trim()).toBe('existing-token') + }) + + it('writes JSON client configs with the TradingGoose server name', () => { + const home = mkdtempSync(join(tmpdir(), 'tg-mcp-cursor-')) + const configPath = join(home, '.cursor', 'mcp.json') + mkdirSync(join(home, '.cursor'), { recursive: true }) + writeFileSync( + configPath, + JSON.stringify( + { + mcpServers: { + Other: { url: 'http://other.example' }, + TradingGoose: { url: 'http://old.example' }, + }, + }, + null, + 2 + ), + 'utf8' + ) + + runWriter(home, ['cursor', 'http://localhost:3000/api/copilot/mcp', 'mcp-token']) + + expect(JSON.parse(readFileSync(configPath, 'utf8'))).toEqual({ + mcpServers: { + Other: { url: 'http://other.example' }, + TradingGoose: { + url: 'http://localhost:3000/api/copilot/mcp', + headers: { Authorization: 'Bearer mcp-token' }, + }, + }, + }) + }) +}) diff --git a/apps/tradinggoose/lib/mcp/local-config-writer-script.ts b/apps/tradinggoose/lib/mcp/local-config-writer-script.ts index 49936189d..e37ffd5c5 100644 --- a/apps/tradinggoose/lib/mcp/local-config-writer-script.ts +++ b/apps/tradinggoose/lib/mcp/local-config-writer-script.ts @@ -3,26 +3,14 @@ const os = require('os') const path = require('path') const target = process.argv[2] -const scope = process.argv[3] -const mcpUrl = process.argv[4] -const token = process.argv[5] +const mcpUrl = process.argv[3] +const token = process.argv[4] const authHeaders = { Authorization: 'Bearer ' + token } const allTargets = ['codex', 'cursor', 'claude', 'opencode'] +const mcpServerName = 'TradingGoose' +const codexBearerTokenEnvVar = 'TRADINGGOOSE_BEARER_TOKEN' -function resolvePathFor(candidate, candidateScope) { - if (candidateScope === 'project') { - switch (candidate) { - case 'codex': - return path.join(process.cwd(), '.codex', 'config.toml') - case 'cursor': - return path.join(process.cwd(), '.cursor', 'mcp.json') - case 'claude': - return path.join(process.cwd(), '.mcp.json') - case 'opencode': - return path.join(process.cwd(), 'opencode.json') - } - } - +function resolvePathFor(candidate) { switch (candidate) { case 'codex': return path.join(os.homedir(), '.codex', 'config.toml') @@ -38,7 +26,7 @@ function resolvePathFor(candidate, candidateScope) { } function resolvePath() { - return resolvePathFor(target, scope) + return resolvePathFor(target) } function ensureParent(filePath) { @@ -48,43 +36,49 @@ function ensureParent(filePath) { function writeCodexConfig(filePath) { ensureParent(filePath) const block = [ - '[mcp_servers.tradinggoose]', - 'type = "http"', + '[mcp_servers.' + mcpServerName + ']', 'url = ' + JSON.stringify(mcpUrl), - '', - '[mcp_servers.tradinggoose.http_headers]', - 'Authorization = ' + JSON.stringify('Bearer ' + token), + 'bearer_token_env_var = ' + JSON.stringify(codexBearerTokenEnvVar), '', ].join('\n') const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '' - const sectionHeader = '[mcp_servers.tradinggoose]' - const startIndex = current.indexOf(sectionHeader) + const withoutCurrent = removeTomlMcpServerBlock(current) + fs.writeFileSync( + filePath, + withoutCurrent.trim() ? withoutCurrent.replace(/\s*$/, '') + '\n\n' + block : block, + 'utf8' + ) +} - if (startIndex === -1) { - const next = current.trim() ? current.replace(/\s*$/, '') + '\n\n' + block : block - fs.writeFileSync(filePath, next, 'utf8') - return - } +function removeTomlMcpServerBlock(current) { + const sectionHeader = '[mcp_servers.' + mcpServerName + ']' + const subPrefix = '[mcp_servers.' + mcpServerName + '.' + let next = current - const subPrefix = '[mcp_servers.tradinggoose.' - const rest = current.slice(startIndex + sectionHeader.length) - let endOffset = rest.length - const headerPattern = /^\[/gm - let match - - while ((match = headerPattern.exec(rest)) !== null) { - const lineEnd = rest.indexOf('\n', match.index) - const line = rest.slice(match.index, lineEnd === -1 ? undefined : lineEnd) - if (!line.startsWith(subPrefix)) { - endOffset = match.index - break + while (true) { + const startIndex = next.indexOf(sectionHeader) + if (startIndex === -1) { + return next + } + + const rest = next.slice(startIndex + sectionHeader.length) + let endOffset = rest.length + const headerPattern = /^\[/gm + let match + + while ((match = headerPattern.exec(rest)) !== null) { + const lineEnd = rest.indexOf('\n', match.index) + const line = rest.slice(match.index, lineEnd === -1 ? undefined : lineEnd) + if (!line.startsWith(subPrefix)) { + endOffset = match.index + break + } } - } - const before = current.slice(0, startIndex).replace(/\n+$/, '') - const after = current.slice(startIndex + sectionHeader.length + endOffset).replace(/^\n+/, '') - const next = (before ? before + '\n\n' : '') + block + (after ? '\n' + after : '') - fs.writeFileSync(filePath, next, 'utf8') + const before = next.slice(0, startIndex).replace(/\n+$/, '') + const after = next.slice(startIndex + sectionHeader.length + endOffset).replace(/^\n+/, '') + next = before + (before && after ? '\n\n' : '') + after + } } function readJson(filePath) { @@ -104,7 +98,7 @@ function writeJsonConfig(filePath, section, entry) { if (!config[section] || typeof config[section] !== 'object' || Array.isArray(config[section])) { config[section] = {} } - config[section].tradinggoose = entry + config[section][mcpServerName] = entry fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n', 'utf8') } @@ -121,21 +115,67 @@ function readCodexToken(filePath) { return null } const text = fs.readFileSync(filePath, 'utf8') - const section = text.match(/\[mcp_servers\.tradinggoose\.http_headers\]([\s\S]*?)(?:\n\[|$)/) + const section = findTomlMcpServerSection(text) if (!section) { return null } - const authorization = section[1].match(/\nAuthorization\s*=\s*["']([^"']+)["']/) - return authorization ? bearerTokenFromHeader(authorization[1]) : null + const envVar = section.match(/\nbearer_token_env_var\s*=\s*["']([^"']+)["']/) + return envVar ? readEnvironmentVariable(envVar[1]) : null +} + +function findTomlMcpServerSection(text) { + const sectionHeader = '[mcp_servers.' + mcpServerName + ']' + const startIndex = text.indexOf(sectionHeader) + if (startIndex === -1) { + return null + } + const rest = text.slice(startIndex + sectionHeader.length) + const nextHeaderIndex = rest.search(/\n\[/) + return nextHeaderIndex === -1 ? rest : rest.slice(0, nextHeaderIndex) +} + +function persistCodexBearerToken() { + process.env[codexBearerTokenEnvVar] = token + + if (process.platform === 'win32') { + const { spawnSync } = require('child_process') + const result = spawnSync('setx', [codexBearerTokenEnvVar, token], { stdio: 'ignore' }) + if (result.status !== 0) { + throw new Error('Failed to persist ' + codexBearerTokenEnvVar) + } + } +} + +function readEnvironmentVariable(name) { + if (process.env[name]) { + return process.env[name] + } + if (process.platform !== 'win32') { + return null + } + + const { spawnSync } = require('child_process') + const result = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + '$Value = [Environment]::GetEnvironmentVariable($args[0], [EnvironmentVariableTarget]::User); if ($Value) { [Console]::Out.Write($Value) }', + name, + ], + { encoding: 'utf8' } + ) + return result.status === 0 && result.stdout ? result.stdout : null } function readJsonToken(filePath, section) { const config = readJson(filePath) - return bearerTokenFromHeader(config?.[section]?.tradinggoose?.headers?.Authorization) + return bearerTokenFromHeader(config?.[section]?.[mcpServerName]?.headers?.Authorization) } function readTargetToken(candidate) { - const filePath = resolvePathFor(candidate, scope) + const filePath = resolvePathFor(candidate) switch (candidate) { case 'codex': return readCodexToken(filePath) @@ -164,6 +204,7 @@ if (target === 'read-tokens') { const filePath = resolvePath() switch (target) { case 'codex': + persistCodexBearerToken() writeCodexConfig(filePath) break case 'cursor': diff --git a/apps/tradinggoose/proxy.test.ts b/apps/tradinggoose/proxy.test.ts index 5e7dfce2e..13f0c8d5d 100644 --- a/apps/tradinggoose/proxy.test.ts +++ b/apps/tradinggoose/proxy.test.ts @@ -329,6 +329,37 @@ describe('proxy auth routing', () => { expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined() }) + it('exempts Codex MCP requests with an empty user-agent from suspicious user-agent filtering', async () => { + const { proxy } = await import('./proxy') + const response = await proxy( + new NextRequest('http://localhost:3000/api/copilot/mcp', { + method: 'POST', + headers: { + 'user-agent': '', + }, + }) + ) + + expect(response.status).toBe(200) + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined() + }) + + it('does not exempt Codex MCP requests from non-empty suspicious user-agent filtering', async () => { + const { proxy } = await import('./proxy') + const response = await proxy( + new NextRequest('http://localhost:3000/api/copilot/mcp', { + method: 'POST', + headers: { + 'user-agent': 'sqlmap', + }, + }) + ) + + expect(response.status).toBe(403) + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + }) + it('keeps the MCP script route canonical for curl clients', async () => { const { proxy } = await import('./proxy') const response = await proxy( @@ -345,6 +376,39 @@ describe('proxy auth routing', () => { expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined() }) + it('keeps target-specific MCP setup script routes canonical for curl clients', async () => { + const { proxy } = await import('./proxy') + const response = await proxy( + new NextRequest('http://localhost:3000/mcp/setup/codex', { + headers: { + 'user-agent': 'curl/8.0', + }, + }) + ) + + expect(response.status).toBe(200) + expect(response.headers.get('location')).toBeNull() + expect(response.headers.get('x-middleware-rewrite')).toBeNull() + expect(response.cookies.get('NEXT_LOCALE')).toBeUndefined() + }) + + it('localizes the MCP browser authorization page instead of treating it as a script route', async () => { + const { proxy } = await import('./proxy') + const response = await proxy( + new NextRequest('http://localhost:3000/mcp/authorize?code=login-code', { + headers: { + 'user-agent': 'vitest', + }, + }) + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'http://localhost:3000/en/mcp/authorize?code=login-code' + ) + expect(response.cookies.get('NEXT_LOCALE')?.value).toBe('en') + }) + it('does not exempt localized API-shaped webhook paths from suspicious user-agent filtering', async () => { const { proxy } = await import('./proxy') const response = await proxy( diff --git a/apps/tradinggoose/proxy.ts b/apps/tradinggoose/proxy.ts index d08929964..2c1cdbcf0 100644 --- a/apps/tradinggoose/proxy.ts +++ b/apps/tradinggoose/proxy.ts @@ -24,6 +24,7 @@ import { generateRuntimeCSP } from './lib/security/csp' const logger = createLogger('Proxy') const handleI18nRouting = createMiddleware(routing) +const MCP_INSTALL_TARGETS = new Set(['codex', 'cursor', 'claude', 'opencode', 'all']) const SUSPICIOUS_UA_PATTERNS = [ /^\s*$/, @@ -69,12 +70,37 @@ function isCanonicalRouteHandlerPath(pathname: string) { pathname === '/llms.txt' || pathname === '/llms-full.txt' || pathname === '/manifest.webmanifest' || - pathname === '/mcp' || + isMcpInstallScriptPath(pathname) || pathname === '/robots.txt' || pathname === '/sitemap.xml' ) } +function isMcpInstallScriptPath(pathname: string) { + const segments = pathname.split('/').filter(Boolean) + if (segments[0] !== 'mcp') { + return false + } + + if (segments.length === 1) { + return true + } + + if (segments[1] === 'login') { + return segments.length === 2 + } + + if (segments[1] !== 'setup') { + return false + } + + const target = segments[2] + return ( + segments.length === 2 || + (segments.length === 3 && !!target && MCP_INSTALL_TARGETS.has(target)) + ) +} + function getLocaleCookie(request: NextRequest): LocaleCode | null { const locale = request.cookies.get(LOCALE_COOKIE)?.value return locale && isLocaleCode(locale) ? locale : null @@ -233,9 +259,11 @@ function routeToCanonicalLocale( function handleSecurityFiltering(request: NextRequest): NextResponse | null { const userAgent = request.headers.get('user-agent') || '' const isWebhookEndpoint = request.nextUrl.pathname.startsWith('/api/webhooks/trigger/') + const isCodexMcpClientRequest = + request.nextUrl.pathname === '/api/copilot/mcp' && /^\s*$/.test(userAgent) const isSuspicious = SUSPICIOUS_UA_PATTERNS.some((pattern) => pattern.test(userAgent)) - if (isSuspicious && !isWebhookEndpoint) { + if (isSuspicious && !isWebhookEndpoint && !isCodexMcpClientRequest) { logger.warn('Blocked suspicious request', { userAgent, ip: request.headers.get('x-forwarded-for') || 'unknown', From b2589a29da36c2aa2382fbfe3142e3d30b429d80 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 19 Jun 2026 23:15:29 -0600 Subject: [PATCH 006/284] test(gdrive): cover workspace-scoped server tools Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../tools/server/gdrive/list-files.test.ts | 18 ++++++++---------- .../tools/server/gdrive/read-file.test.ts | 18 ++++++++---------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/tools/server/gdrive/list-files.test.ts b/apps/tradinggoose/lib/copilot/tools/server/gdrive/list-files.test.ts index 09edf2c74..441690531 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/gdrive/list-files.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/gdrive/list-files.test.ts @@ -2,17 +2,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { listGDriveFilesServerTool } from './list-files' const mocks = vi.hoisted(() => ({ + checkWorkspaceAccess: vi.fn(), executeTool: vi.fn(), getOAuthAccessTokenForUserCredential: vi.fn(), - verifyWorkflowAccess: vi.fn(), })) vi.mock('@/lib/credentials/oauth', () => ({ getOAuthAccessTokenForUserCredential: mocks.getOAuthAccessTokenForUserCredential, })) -vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ - verifyWorkflowAccess: mocks.verifyWorkflowAccess, +vi.mock('@/lib/permissions/utils', () => ({ + checkWorkspaceAccess: mocks.checkWorkspaceAccess, })) vi.mock('@/lib/logs/console/logger', () => ({ @@ -33,10 +33,10 @@ describe('listGDriveFilesServerTool', () => { vi.clearAllMocks() }) - it('uses authenticated route context as the user source', async () => { - mocks.verifyWorkflowAccess.mockResolvedValue({ + it('uses explicit workspaceId and authenticated route context as the user source', async () => { + mocks.checkWorkspaceAccess.mockResolvedValue({ + exists: true, hasAccess: true, - workspaceId: 'workspace-1', }) mocks.getOAuthAccessTokenForUserCredential.mockResolvedValue('google-token') mocks.executeTool.mockResolvedValue({ @@ -49,11 +49,9 @@ describe('listGDriveFilesServerTool', () => { await expect( listGDriveFilesServerTool.execute( - { credentialId: 'credential-1', search_query: 'report' }, + { workspaceId: 'workspace-1', credentialId: 'credential-1', search_query: 'report' }, { userId: 'auth-user', - contextEntityKind: 'workflow', - contextEntityId: 'workflow-1', } ) ).resolves.toEqual({ @@ -62,7 +60,7 @@ describe('listGDriveFilesServerTool', () => { nextPageToken: 'next-page', }) - expect(mocks.verifyWorkflowAccess).toHaveBeenCalledWith('auth-user', 'workflow-1', 'read') + expect(mocks.checkWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'auth-user') expect(mocks.getOAuthAccessTokenForUserCredential).toHaveBeenCalledWith({ credentialId: 'credential-1', userId: 'auth-user', diff --git a/apps/tradinggoose/lib/copilot/tools/server/gdrive/read-file.test.ts b/apps/tradinggoose/lib/copilot/tools/server/gdrive/read-file.test.ts index ec6c25112..8aa021098 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/gdrive/read-file.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/gdrive/read-file.test.ts @@ -2,17 +2,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { readGDriveFileServerTool } from './read-file' const mocks = vi.hoisted(() => ({ + checkWorkspaceAccess: vi.fn(), executeTool: vi.fn(), getOAuthAccessTokenForUserCredential: vi.fn(), - verifyWorkflowAccess: vi.fn(), })) vi.mock('@/lib/credentials/oauth', () => ({ getOAuthAccessTokenForUserCredential: mocks.getOAuthAccessTokenForUserCredential, })) -vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ - verifyWorkflowAccess: mocks.verifyWorkflowAccess, +vi.mock('@/lib/permissions/utils', () => ({ + checkWorkspaceAccess: mocks.checkWorkspaceAccess, })) vi.mock('@/lib/logs/console/logger', () => ({ @@ -33,10 +33,10 @@ describe('readGDriveFileServerTool', () => { vi.clearAllMocks() }) - it('uses authenticated route context as the user source', async () => { - mocks.verifyWorkflowAccess.mockResolvedValue({ + it('uses explicit workspaceId and authenticated route context as the user source', async () => { + mocks.checkWorkspaceAccess.mockResolvedValue({ + exists: true, hasAccess: true, - workspaceId: 'workspace-1', }) mocks.getOAuthAccessTokenForUserCredential.mockResolvedValue('google-token') mocks.executeTool.mockResolvedValue({ @@ -49,11 +49,9 @@ describe('readGDriveFileServerTool', () => { await expect( readGDriveFileServerTool.execute( - { credentialId: 'credential-1', fileId: 'file-1', type: 'doc' }, + { workspaceId: 'workspace-1', credentialId: 'credential-1', fileId: 'file-1', type: 'doc' }, { userId: 'auth-user', - contextEntityKind: 'workflow', - contextEntityId: 'workflow-1', } ) ).resolves.toEqual({ @@ -62,7 +60,7 @@ describe('readGDriveFileServerTool', () => { metadata: { title: 'Report' }, }) - expect(mocks.verifyWorkflowAccess).toHaveBeenCalledWith('auth-user', 'workflow-1', 'read') + expect(mocks.checkWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'auth-user') expect(mocks.getOAuthAccessTokenForUserCredential).toHaveBeenCalledWith({ credentialId: 'credential-1', userId: 'auth-user', From 4ee63d2684390f449bf4778bf3b445ab79a4465e Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 19 Jun 2026 23:27:26 -0600 Subject: [PATCH 007/284] feat(mcp): localize authorization status copy Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../[locale]/(auth)/mcp/authorize/page.tsx | 33 +++++++++++++------ apps/tradinggoose/i18n/messages/en.json | 15 +++++++++ apps/tradinggoose/i18n/messages/es.json | 15 +++++++++ apps/tradinggoose/i18n/messages/zh.json | 15 +++++++++ apps/tradinggoose/i18n/public-copy.test.ts | 3 ++ 5 files changed, 71 insertions(+), 10 deletions(-) diff --git a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx index 6a71016e6..6bccb976c 100644 --- a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx +++ b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx @@ -4,7 +4,8 @@ import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' import { getSession } from '@/lib/auth' import { approveMcpDeviceLogin } from '@/lib/mcp/auth' import { redirect } from '@/i18n/navigation' -import type { LocaleCode } from '@/i18n/utils' +import { getPublicCopy } from '@/i18n/public-copy' +import { normalizeLocaleCode } from '@/i18n/utils' export const dynamic = 'force-dynamic' @@ -17,10 +18,18 @@ function getCode(searchParams: Awaited) { return Array.isArray(code) ? code[0] : code } -function StatusPage({ title, description }: { title: string; description: string }) { +function StatusPage({ + eyebrow, + title, + description, +}: { + eyebrow: string + title: string + description: string +}) { return (
- +
) } @@ -37,14 +46,16 @@ export default async function McpAuthorizePage({ searchParams, headers(), ]) - const locale = routeLocale as LocaleCode + const locale = normalizeLocaleCode(routeLocale) + const mcpCopy = getPublicCopy(locale).auth.mcp const code = getCode(query) if (!code) { return ( ) } @@ -71,16 +82,18 @@ export default async function McpAuthorizePage({ if (result.status === 'expired') { return ( ) } return ( ) } diff --git a/apps/tradinggoose/i18n/messages/en.json b/apps/tradinggoose/i18n/messages/en.json index 2feb70333..00ca2d44e 100644 --- a/apps/tradinggoose/i18n/messages/en.json +++ b/apps/tradinggoose/i18n/messages/en.json @@ -356,6 +356,21 @@ "ssoDisabled": "SSO authentication is disabled. Please use another sign-in method.", "failed": "SSO sign-in failed. Please try again." } + }, + "mcp": { + "eyebrow": "MCP authorization", + "invalid": { + "title": "Invalid MCP login", + "description": "The local setup command did not provide a valid login code." + }, + "expired": { + "title": "MCP login expired", + "description": "Return to your terminal and run the TradingGoose MCP login command again." + }, + "approved": { + "title": "MCP login approved", + "description": "Return to your terminal to finish configuring your local agent." + } } }, "localeNames": { diff --git a/apps/tradinggoose/i18n/messages/es.json b/apps/tradinggoose/i18n/messages/es.json index 77a91d41d..ec3d5fc3a 100644 --- a/apps/tradinggoose/i18n/messages/es.json +++ b/apps/tradinggoose/i18n/messages/es.json @@ -356,6 +356,21 @@ "ssoDisabled": "La autenticación SSO está deshabilitada. Usa otro método de inicio de sesión.", "failed": "El inicio de sesión SSO falló. Inténtalo de nuevo." } + }, + "mcp": { + "eyebrow": "Autorización MCP", + "invalid": { + "title": "Inicio de sesión MCP no válido", + "description": "El comando de configuración local no proporcionó un código de inicio de sesión válido." + }, + "expired": { + "title": "El inicio de sesión MCP expiró", + "description": "Vuelve a la terminal y ejecuta de nuevo el comando de inicio de sesión de TradingGoose MCP." + }, + "approved": { + "title": "Inicio de sesión MCP aprobado", + "description": "Vuelve a la terminal para terminar de configurar tu agente local." + } } }, "localeNames": { diff --git a/apps/tradinggoose/i18n/messages/zh.json b/apps/tradinggoose/i18n/messages/zh.json index 36ea2520b..c191d5868 100644 --- a/apps/tradinggoose/i18n/messages/zh.json +++ b/apps/tradinggoose/i18n/messages/zh.json @@ -356,6 +356,21 @@ "ssoDisabled": "SSO身份验证已禁用。请使用其他登录方式。", "failed": "SSO登录失败。请重试。" } + }, + "mcp": { + "eyebrow": "MCP 授权", + "invalid": { + "title": "MCP 登录无效", + "description": "本地设置命令未提供有效的登录代码。" + }, + "expired": { + "title": "MCP 登录已过期", + "description": "返回终端并重新运行 TradingGoose MCP 登录命令。" + }, + "approved": { + "title": "MCP 登录已批准", + "description": "返回终端完成本地代理配置。" + } } }, "localeNames": { diff --git a/apps/tradinggoose/i18n/public-copy.test.ts b/apps/tradinggoose/i18n/public-copy.test.ts index 01e064805..c815fde03 100644 --- a/apps/tradinggoose/i18n/public-copy.test.ts +++ b/apps/tradinggoose/i18n/public-copy.test.ts @@ -153,6 +153,9 @@ describe('public copy', () => { getPublicCopy('en').auth.common.verifyEmail ) expect(getPublicCopy('en').auth.common.loading).toBe('Loading...') + expect(getPublicCopy('en').auth.mcp.approved.title).toBe('MCP login approved') + expect(getPublicCopy('es').auth.mcp.approved.title).toBe('Inicio de sesión MCP aprobado') + expect(getPublicCopy('zh').auth.mcp.approved.title).toBe('MCP 登录已批准') }) it('includes localized verification screen copy', () => { From eb52745fb478fe63acddaf1dd6e615f5b4ab87e5 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 00:21:56 -0600 Subject: [PATCH 008/284] style(mcp): wrap API key revocation condition Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/mcp/auth.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 6f1f3de51..300c99de9 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -203,7 +203,9 @@ export async function revokeMcpApiKeyByBearerToken( const deleted = await db .delete(apiKey) - .where(and(eq(apiKey.id, auth.keyId), eq(apiKey.userId, auth.userId), eq(apiKey.type, 'personal'))) + .where( + and(eq(apiKey.id, auth.keyId), eq(apiKey.userId, auth.userId), eq(apiKey.type, 'personal')) + ) .returning({ id: apiKey.id }) return { revoked: deleted.length > 0 } From 6323e1cb7340b860987e6aadb863d96e25ace135 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 00:22:04 -0600 Subject: [PATCH 009/284] feat(mcp): require explicit device login approval Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../[locale]/(auth)/mcp/authorize/page.tsx | 109 ++++++++++-------- .../app/api/auth/mcp/authorize/route.ts | 53 +++++++++ apps/tradinggoose/i18n/messages/en.json | 11 ++ apps/tradinggoose/i18n/messages/es.json | 11 ++ apps/tradinggoose/i18n/messages/zh.json | 11 ++ apps/tradinggoose/lib/mcp/auth.ts | 70 +++++++++++ 6 files changed, 218 insertions(+), 47 deletions(-) create mode 100644 apps/tradinggoose/app/api/auth/mcp/authorize/route.ts diff --git a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx index 6bccb976c..29bea6f6b 100644 --- a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx +++ b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx @@ -1,8 +1,10 @@ import { getSessionCookie } from 'better-auth/cookies' import { headers } from 'next/headers' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' +import { inter } from '@/app/fonts/inter' +import { Button } from '@/components/ui/button' import { getSession } from '@/lib/auth' -import { approveMcpDeviceLogin } from '@/lib/mcp/auth' +import { createMcpDeviceLoginApprovalChallenge } from '@/lib/mcp/auth' import { redirect } from '@/i18n/navigation' import { getPublicCopy } from '@/i18n/public-copy' import { normalizeLocaleCode } from '@/i18n/utils' @@ -11,29 +13,9 @@ export const dynamic = 'force-dynamic' type SearchParams = Promise<{ code?: string | string[] + status?: string | string[] }> -function getCode(searchParams: Awaited) { - const code = searchParams.code - return Array.isArray(code) ? code[0] : code -} - -function StatusPage({ - eyebrow, - title, - description, -}: { - eyebrow: string - title: string - description: string -}) { - return ( -
- -
- ) -} - export default async function McpAuthorizePage({ params, searchParams, @@ -48,16 +30,30 @@ export default async function McpAuthorizePage({ ]) const locale = normalizeLocaleCode(routeLocale) const mcpCopy = getPublicCopy(locale).auth.mcp - const code = getCode(query) + const code = Array.isArray(query.code) ? query.code[0] : query.code + const rawStatus = Array.isArray(query.status) ? query.status[0] : query.status + const statusCopy = + rawStatus === 'approved' + ? mcpCopy.approved + : rawStatus === 'cancelled' + ? mcpCopy.cancelled + : rawStatus === 'expired' + ? mcpCopy.expired + : rawStatus === 'invalid' + ? mcpCopy.invalid + : null + const renderStatus = (copy: { title: string; description: string }) => ( +
+ +
+ ) + + if (statusCopy) { + return renderStatus(statusCopy) + } if (!code) { - return ( - - ) + return renderStatus(mcpCopy.invalid) } const session = await getSession(requestHeaders) @@ -74,26 +70,45 @@ export default async function McpAuthorizePage({ }) } - const result = await approveMcpDeviceLogin({ - code, - userId: session.user.id, - }) + const challenge = await createMcpDeviceLoginApprovalChallenge(code, session.user.id) - if (result.status === 'expired') { - return ( - - ) + if (challenge.status === 'expired') { + return renderStatus(mcpCopy.expired) + } + + if (challenge.status === 'approved') { + return renderStatus(mcpCopy.approved) } return ( - +
+ +
+ + + +
+ + +
+
+

+ {mcpCopy.confirm.terminalHint} +

+
) } diff --git a/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts b/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts new file mode 100644 index 000000000..ff78a296e --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts @@ -0,0 +1,53 @@ +import { getSessionCookie } from 'better-auth/cookies' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { approveMcpDeviceLogin, cancelMcpDeviceLogin } from '@/lib/mcp/auth' +import { normalizeLocaleCode } from '@/i18n/utils' + +export const dynamic = 'force-dynamic' + +function redirectToAuthorizeStatus(request: NextRequest, locale: string, status: string) { + const url = new URL(`/${normalizeLocaleCode(locale)}/mcp/authorize`, request.nextUrl.origin) + url.searchParams.set('status', status) + return NextResponse.redirect(url) +} + +function redirectToLogin(request: NextRequest, locale: string, code: string) { + const url = new URL(`/${normalizeLocaleCode(locale)}/login`, request.nextUrl.origin) + if (getSessionCookie(request.headers)) { + url.searchParams.set('reauth', '1') + } + url.searchParams.set('callbackUrl', `/mcp/authorize?code=${encodeURIComponent(code)}`) + return NextResponse.redirect(url) +} + +export async function POST(request: NextRequest) { + const formData = await request.formData().catch(() => null) + const action = formData?.get('action') + const approvalToken = formData?.get('approvalToken') + const code = formData?.get('code') + + if ( + (action !== 'approve' && action !== 'cancel') || + typeof approvalToken !== 'string' || + !approvalToken || + typeof code !== 'string' || + !code + ) { + return redirectToAuthorizeStatus(request, 'en', 'invalid') + } + + const localeValue = formData?.get('locale') + const locale = typeof localeValue === 'string' ? localeValue : 'en' + const session = await getSession(request.headers) + if (!session?.user?.id) { + return redirectToLogin(request, locale, code) + } + + const result = + action === 'approve' + ? await approveMcpDeviceLogin({ code, approvalToken, userId: session.user.id }) + : await cancelMcpDeviceLogin({ code, approvalToken, userId: session.user.id }) + + return redirectToAuthorizeStatus(request, locale, result.status) +} diff --git a/apps/tradinggoose/i18n/messages/en.json b/apps/tradinggoose/i18n/messages/en.json index 00ca2d44e..36621c834 100644 --- a/apps/tradinggoose/i18n/messages/en.json +++ b/apps/tradinggoose/i18n/messages/en.json @@ -359,6 +359,13 @@ }, "mcp": { "eyebrow": "MCP authorization", + "confirm": { + "title": "Approve MCP access", + "description": "A local TradingGoose MCP setup command is requesting an API key for this account.", + "approve": "Approve access", + "cancel": "Cancel", + "terminalHint": "Only approve this if you started the setup or login command in your own terminal." + }, "invalid": { "title": "Invalid MCP login", "description": "The local setup command did not provide a valid login code." @@ -370,6 +377,10 @@ "approved": { "title": "MCP login approved", "description": "Return to your terminal to finish configuring your local agent." + }, + "cancelled": { + "title": "MCP login cancelled", + "description": "No API key was created. You can close this page." } } }, diff --git a/apps/tradinggoose/i18n/messages/es.json b/apps/tradinggoose/i18n/messages/es.json index ec3d5fc3a..b8d5fa02c 100644 --- a/apps/tradinggoose/i18n/messages/es.json +++ b/apps/tradinggoose/i18n/messages/es.json @@ -359,6 +359,13 @@ }, "mcp": { "eyebrow": "Autorización MCP", + "confirm": { + "title": "Aprobar acceso MCP", + "description": "Un comando local de configuración de TradingGoose MCP solicita una clave API para esta cuenta.", + "approve": "Aprobar acceso", + "cancel": "Cancelar", + "terminalHint": "Aprueba esto solo si iniciaste el comando de configuración o inicio de sesión en tu propia terminal." + }, "invalid": { "title": "Inicio de sesión MCP no válido", "description": "El comando de configuración local no proporcionó un código de inicio de sesión válido." @@ -370,6 +377,10 @@ "approved": { "title": "Inicio de sesión MCP aprobado", "description": "Vuelve a la terminal para terminar de configurar tu agente local." + }, + "cancelled": { + "title": "Inicio de sesión MCP cancelado", + "description": "No se creó ninguna clave API. Puedes cerrar esta página." } } }, diff --git a/apps/tradinggoose/i18n/messages/zh.json b/apps/tradinggoose/i18n/messages/zh.json index c191d5868..501415397 100644 --- a/apps/tradinggoose/i18n/messages/zh.json +++ b/apps/tradinggoose/i18n/messages/zh.json @@ -359,6 +359,13 @@ }, "mcp": { "eyebrow": "MCP 授权", + "confirm": { + "title": "批准 MCP 访问", + "description": "本地 TradingGoose MCP 设置命令正在请求为此账户创建 API 密钥。", + "approve": "批准访问", + "cancel": "取消", + "terminalHint": "仅在你自己终端中启动了设置或登录命令时才批准。" + }, "invalid": { "title": "MCP 登录无效", "description": "本地设置命令未提供有效的登录代码。" @@ -370,6 +377,10 @@ "approved": { "title": "MCP 登录已批准", "description": "返回终端完成本地代理配置。" + }, + "cancelled": { + "title": "MCP 登录已取消", + "description": "未创建 API 密钥。你可以关闭此页面。" } } }, diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 300c99de9..65147acc5 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -12,6 +12,7 @@ const POLL_INTERVAL_SECONDS = 2 type PendingDeviceLogin = { status: 'pending' createdAt: string + userApprovalTokenHash?: string } type ApprovedDeviceLogin = { @@ -33,6 +34,7 @@ export type McpDeviceLoginPollResult = export type McpDeviceLoginApprovalResult = | { status: 'approved'; expiresAt: string } | { status: 'expired' } + | { status: 'invalid' } export type McpDeviceLoginStartResult = { code: string @@ -49,6 +51,10 @@ function getDeviceLoginIdentifier(code: string) { return `${DEVICE_LOGIN_PREFIX}${digest}` } +function getUserApprovalTokenHash(code: string, approvalToken: string, userId: string) { + return createHash('sha256').update(`${code}:${approvalToken}:${userId}`).digest('hex') +} + function parseDeviceLoginState(value: string): DeviceLoginState | null { try { const parsed = JSON.parse(value) as Record @@ -124,6 +130,39 @@ export async function startMcpDeviceLogin(): Promise } } +export async function createMcpDeviceLoginApprovalChallenge(code: string, userId: string) { + const login = await readDeviceLogin(code) + if (!login) { + return { status: 'expired' } + } + + if (login.state.status === 'approved') { + return { + status: 'approved', + expiresAt: login.expiresAt.toISOString(), + } + } + + const now = new Date() + const approvalToken = randomBytes(32).toString('base64url') + await db + .update(verification) + .set({ + value: JSON.stringify({ + ...login.state, + userApprovalTokenHash: getUserApprovalTokenHash(code, approvalToken, userId), + } satisfies PendingDeviceLogin), + updatedAt: now, + }) + .where(eq(verification.id, login.id)) + + return { + status: 'pending', + approvalToken, + expiresAt: login.expiresAt.toISOString(), + } +} + export async function pollMcpDeviceLogin(code: string): Promise { const login = await readDeviceLogin(code) if (!login) { @@ -148,9 +187,11 @@ export async function pollMcpDeviceLogin(code: string): Promise { const login = await readDeviceLogin(code) @@ -165,6 +206,10 @@ export async function approveMcpDeviceLogin({ } } + if (login.state.userApprovalTokenHash !== getUserApprovalTokenHash(code, approvalToken, userId)) { + return { status: 'invalid' } + } + const now = new Date() const createdKey = await createPersonalApiKey({ userId, @@ -193,6 +238,31 @@ export async function approveMcpDeviceLogin({ } } +export async function cancelMcpDeviceLogin({ + code, + approvalToken, + userId, +}: { + code: string + approvalToken: string + userId: string +}) { + const login = await readDeviceLogin(code) + if (!login) { + return { status: 'expired' } + } + + if ( + login.state.status !== 'pending' || + login.state.userApprovalTokenHash !== getUserApprovalTokenHash(code, approvalToken, userId) + ) { + return { status: 'invalid' } + } + + await db.delete(verification).where(eq(verification.id, login.id)) + return { status: 'cancelled' } +} + export async function revokeMcpApiKeyByBearerToken( token: string ): Promise { From 9e81f32c88d3ce11a7e5277d10ff26a129f1efac Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 00:22:11 -0600 Subject: [PATCH 010/284] test(mcp): cover device authorization confirmation Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../(auth)/mcp/authorize/page.test.tsx | 98 +++++++++++++++ .../app/api/auth/mcp/authorize/route.test.ts | 115 ++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx create mode 100644 apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts diff --git a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx new file mode 100644 index 000000000..f1bb12e25 --- /dev/null +++ b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx @@ -0,0 +1,98 @@ +import type React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCreateMcpDeviceLoginApprovalChallenge, + mockGetSession, + mockGetSessionCookie, + mockHeaders, + mockRedirect, +} = vi.hoisted(() => ({ + mockCreateMcpDeviceLoginApprovalChallenge: vi.fn(), + mockGetSession: vi.fn(), + mockGetSessionCookie: vi.fn(), + mockHeaders: vi.fn(), + mockRedirect: vi.fn(), +})) + +vi.mock('next/headers', () => ({ + headers: () => mockHeaders(), +})) + +vi.mock('better-auth/cookies', () => ({ + getSessionCookie: (...args: unknown[]) => mockGetSessionCookie(...args), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: (...args: unknown[]) => mockGetSession(...args), +})) + +vi.mock('@/lib/mcp/auth', () => ({ + createMcpDeviceLoginApprovalChallenge: (...args: unknown[]) => + mockCreateMcpDeviceLoginApprovalChallenge(...args), +})) + +vi.mock('@/app/(auth)/components/auth-page-header', () => ({ + AuthPageHeader: ({ + description, + eyebrow, + title, + }: { + description: string + eyebrow: string + title: string + }) => ( +
+

{eyebrow}

+

{title}

+

{description}

+
+ ), +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), +})) + +vi.mock('@/app/fonts/inter', () => ({ + inter: { className: 'inter' }, +})) + +vi.mock('@/i18n/navigation', () => ({ + redirect: (...args: unknown[]) => mockRedirect(...args), +})) + +describe('MCP authorize page', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + mockHeaders.mockResolvedValue(new Headers()) + mockGetSessionCookie.mockReturnValue(null) + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockCreateMcpDeviceLoginApprovalChallenge.mockResolvedValue({ + status: 'pending', + approvalToken: 'approval-token', + expiresAt: '2026-06-19T12:00:00.000Z', + }) + }) + + it('renders a confirmation form instead of approving on page load', async () => { + const McpAuthorizePage = (await import('./page')).default + + const result = await McpAuthorizePage({ + params: Promise.resolve({ locale: 'es' }), + searchParams: Promise.resolve({ code: 'login-code' }), + }) + const markup = renderToStaticMarkup(result) + + expect(mockCreateMcpDeviceLoginApprovalChallenge).toHaveBeenCalledWith('login-code', 'user-1') + expect(markup).toContain('Aprobar acceso MCP') + expect(markup).toContain('method="post"') + expect(markup).toContain('action="/api/auth/mcp/authorize"') + expect(markup).toContain('name="approvalToken"') + expect(markup).toContain('value="approval-token"') + }) +}) diff --git a/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts new file mode 100644 index 000000000..bba37be4e --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockApproveMcpDeviceLogin, + mockCancelMcpDeviceLogin, + mockGetSession, + mockGetSessionCookie, +} = vi.hoisted(() => ({ + mockApproveMcpDeviceLogin: vi.fn(), + mockCancelMcpDeviceLogin: vi.fn(), + mockGetSession: vi.fn(), + mockGetSessionCookie: vi.fn(), +})) + +vi.mock('better-auth/cookies', () => ({ + getSessionCookie: (...args: unknown[]) => mockGetSessionCookie(...args), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: (...args: unknown[]) => mockGetSession(...args), +})) + +vi.mock('@/lib/mcp/auth', () => ({ + approveMcpDeviceLogin: (...args: unknown[]) => mockApproveMcpDeviceLogin(...args), + cancelMcpDeviceLogin: (...args: unknown[]) => mockCancelMcpDeviceLogin(...args), +})) + +function createAuthorizeRequest(body: Record) { + return new NextRequest('https://studio.example.test/api/auth/mcp/authorize', { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(body), + }) +} + +describe('MCP authorize route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetSessionCookie.mockReturnValue(null) + mockApproveMcpDeviceLogin.mockResolvedValue({ + status: 'approved', + expiresAt: '2026-06-19T12:00:00.000Z', + }) + mockCancelMcpDeviceLogin.mockResolvedValue({ status: 'cancelled' }) + }) + + it('approves a device login only from a submitted confirmation token', async () => { + const { POST } = await import('./route') + + const response = await POST( + createAuthorizeRequest({ + action: 'approve', + approvalToken: 'approval-token', + code: 'login-code', + locale: 'es', + }) + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://studio.example.test/es/mcp/authorize?status=approved' + ) + expect(mockApproveMcpDeviceLogin).toHaveBeenCalledWith({ + code: 'login-code', + approvalToken: 'approval-token', + userId: 'user-1', + }) + expect(mockCancelMcpDeviceLogin).not.toHaveBeenCalled() + }) + + it('cancels a pending device login through the same confirmation token', async () => { + const { POST } = await import('./route') + + const response = await POST( + createAuthorizeRequest({ + action: 'cancel', + approvalToken: 'approval-token', + code: 'login-code', + locale: 'zh', + }) + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://studio.example.test/zh/mcp/authorize?status=cancelled' + ) + expect(mockCancelMcpDeviceLogin).toHaveBeenCalledWith({ + code: 'login-code', + approvalToken: 'approval-token', + userId: 'user-1', + }) + expect(mockApproveMcpDeviceLogin).not.toHaveBeenCalled() + }) + + it('rejects malformed confirmation submissions before auth mutation', async () => { + const { POST } = await import('./route') + + const response = await POST(createAuthorizeRequest({ action: 'approve', code: 'login-code' })) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://studio.example.test/en/mcp/authorize?status=invalid' + ) + expect(mockApproveMcpDeviceLogin).not.toHaveBeenCalled() + expect(mockCancelMcpDeviceLogin).not.toHaveBeenCalled() + }) +}) From 0195df8c3f8de6dda3f12b4c621c80a62e555eb2 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 00:22:17 -0600 Subject: [PATCH 011/284] feat(mcp): persist Codex MCP token locally Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/mcp/local-config-writer-script.ts | 57 ++++++++++++++++++- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/apps/tradinggoose/lib/mcp/local-config-writer-script.ts b/apps/tradinggoose/lib/mcp/local-config-writer-script.ts index e37ffd5c5..11d26853c 100644 --- a/apps/tradinggoose/lib/mcp/local-config-writer-script.ts +++ b/apps/tradinggoose/lib/mcp/local-config-writer-script.ts @@ -50,6 +50,55 @@ function writeCodexConfig(filePath) { ) } +function resolveCodexBearerTokenFilePath() { + return path.join(os.homedir(), '.codex', 'tradinggoose-mcp.env') +} + +function shellSingleQuote(value) { + return "'" + value.replaceAll("'", "'\\''") + "'" +} + +function writeCodexBearerTokenFile() { + const filePath = resolveCodexBearerTokenFilePath() + ensureParent(filePath) + fs.writeFileSync( + filePath, + 'export ' + codexBearerTokenEnvVar + '=' + shellSingleQuote(token) + '\n', + 'utf8' + ) + fs.chmodSync(filePath, 0o600) + return filePath +} + +function readCodexBearerTokenFile() { + const filePath = resolveCodexBearerTokenFilePath() + const match = (fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '').match( + new RegExp("^export " + codexBearerTokenEnvVar + "='(.+)'$", 'm') + ) + return match ? match[1].replaceAll("'\\''", "'") : null +} + +function resolveShellProfilePath() { + const shellName = path.basename(process.env.SHELL || '') + const fileName = shellName === 'zsh' ? '.zshrc' : shellName === 'bash' ? '.bashrc' : '.profile' + return path.join(os.homedir(), fileName) +} + +function sourceCodexBearerTokenFileFromShellProfile(filePath) { + const profilePath = resolveShellProfilePath() + const sourceLine = '[ -f ' + shellSingleQuote(filePath) + ' ] && . ' + shellSingleQuote(filePath) + const current = fs.existsSync(profilePath) ? fs.readFileSync(profilePath, 'utf8') : '' + if (current.split(/\r?\n/).includes(sourceLine)) { + return + } + + fs.writeFileSync( + profilePath, + current.trim() ? current.replace(/\s*$/, '') + '\n' + sourceLine + '\n' : sourceLine + '\n', + 'utf8' + ) +} + function removeTomlMcpServerBlock(current) { const sectionHeader = '[mcp_servers.' + mcpServerName + ']' const subPrefix = '[mcp_servers.' + mcpServerName + '.' @@ -135,15 +184,17 @@ function findTomlMcpServerSection(text) { } function persistCodexBearerToken() { - process.env[codexBearerTokenEnvVar] = token - if (process.platform === 'win32') { const { spawnSync } = require('child_process') const result = spawnSync('setx', [codexBearerTokenEnvVar, token], { stdio: 'ignore' }) if (result.status !== 0) { throw new Error('Failed to persist ' + codexBearerTokenEnvVar) } + return } + + const tokenFilePath = writeCodexBearerTokenFile() + sourceCodexBearerTokenFileFromShellProfile(tokenFilePath) } function readEnvironmentVariable(name) { @@ -151,7 +202,7 @@ function readEnvironmentVariable(name) { return process.env[name] } if (process.platform !== 'win32') { - return null + return name === codexBearerTokenEnvVar ? readCodexBearerTokenFile() : null } const { spawnSync } = require('child_process') From b2bd66f6869d5a192c2eb8b783028fbcdc295c07 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 00:22:25 -0600 Subject: [PATCH 012/284] test(mcp): cover durable Codex token persistence Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../mcp/local-config-writer-script.test.ts | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts b/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts index f895a899b..27e9a9aa7 100644 --- a/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts +++ b/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts @@ -62,7 +62,9 @@ describe('MCP local config writer script', () => { it('writes Codex config with a TradingGoose bearer token environment variable', () => { const home = mkdtempSync(join(tmpdir(), 'tg-mcp-codex-')) - runWriter(home, ['codex', 'http://localhost:3000/api/copilot/mcp', 'mcp-token']) + runWriter(home, ['codex', 'http://localhost:3000/api/copilot/mcp', 'mcp-token'], { + SHELL: '/bin/zsh', + }) const configPath = join(home, '.codex', 'config.toml') expect(readFileSync(configPath, 'utf8')).toBe( @@ -73,6 +75,16 @@ describe('MCP local config writer script', () => { '', ].join('\n') ) + expect(readFileSync(join(home, '.codex', 'tradinggoose-mcp.env'), 'utf8')).toBe( + "export TRADINGGOOSE_BEARER_TOKEN='mcp-token'\n" + ) + expect(readFileSync(join(home, '.zshrc'), 'utf8')).toBe( + `[ -f '${join(home, '.codex', 'tradinggoose-mcp.env')}' ] && . '${join( + home, + '.codex', + 'tradinggoose-mcp.env' + )}'\n` + ) }) it('replaces the canonical Codex config while preserving other servers', () => { @@ -101,23 +113,12 @@ describe('MCP local config writer script', () => { expect(config).not.toContain('Authorization = "Bearer') }) - it('reads Codex bearer token from the configured environment variable', () => { + it('reads Codex bearer token from durable local state after setup', () => { const home = mkdtempSync(join(tmpdir(), 'tg-mcp-codex-token-')) - const configPath = join(home, '.codex', 'config.toml') - mkdirSync(join(home, '.codex'), { recursive: true }) - writeFileSync( - configPath, - [ - '[mcp_servers.TradingGoose]', - 'url = "http://localhost:3000/api/copilot/mcp"', - 'bearer_token_env_var = "TRADINGGOOSE_BEARER_TOKEN"', - '', - ].join('\n'), - 'utf8' - ) + runWriter(home, ['codex', 'http://localhost:3000/api/copilot/mcp', 'existing-token']) const stdout = runWriterCapture(home, ['read-tokens'], { - TRADINGGOOSE_BEARER_TOKEN: 'existing-token', + TRADINGGOOSE_BEARER_TOKEN: undefined, }) expect(stdout.trim()).toBe('existing-token') From 70bc279884f432da06870b9477f72d3d50474fc0 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 01:09:06 -0600 Subject: [PATCH 013/284] style(mcp): format revoke auth mock Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/auth/mcp/revoke/route.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/tradinggoose/app/api/auth/mcp/revoke/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/revoke/route.test.ts index d8f72a992..6d35dd4d0 100644 --- a/apps/tradinggoose/app/api/auth/mcp/revoke/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/revoke/route.test.ts @@ -10,8 +10,7 @@ const { mockRevokeMcpApiKeyByBearerToken } = vi.hoisted(() => ({ })) vi.mock('@/lib/mcp/auth', () => ({ - revokeMcpApiKeyByBearerToken: (...args: unknown[]) => - mockRevokeMcpApiKeyByBearerToken(...args), + revokeMcpApiKeyByBearerToken: (...args: unknown[]) => mockRevokeMcpApiKeyByBearerToken(...args), })) describe('MCP auth revoke route', () => { From b8850d5e29ef05f9cd56ff5e9ce97d7d98b0f3ad Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 01:09:36 -0600 Subject: [PATCH 014/284] fix(mcp): accept case-insensitive bearer auth Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/auth/mcp/revoke/route.test.ts | 16 ++++++++++++++++ .../app/api/auth/mcp/revoke/route.ts | 5 +++-- .../app/api/copilot/mcp/route.test.ts | 13 +++++++++++++ apps/tradinggoose/app/api/copilot/mcp/route.ts | 5 +++-- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/tradinggoose/app/api/auth/mcp/revoke/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/revoke/route.test.ts index 6d35dd4d0..e24fca868 100644 --- a/apps/tradinggoose/app/api/auth/mcp/revoke/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/revoke/route.test.ts @@ -36,6 +36,22 @@ describe('MCP auth revoke route', () => { expect(mockRevokeMcpApiKeyByBearerToken).toHaveBeenCalledWith('sk-tradinggoose-old') }) + it('accepts a case-insensitive bearer auth scheme', async () => { + const { POST } = await import('./route') + + const response = await POST( + new NextRequest('https://studio.example.test/api/auth/mcp/revoke', { + method: 'POST', + headers: { + authorization: 'bearer sk-tradinggoose-old', + }, + }) + ) + + expect(response.status).toBe(200) + expect(mockRevokeMcpApiKeyByBearerToken).toHaveBeenCalledWith('sk-tradinggoose-old') + }) + it('rejects missing bearer auth', async () => { const { POST } = await import('./route') diff --git a/apps/tradinggoose/app/api/auth/mcp/revoke/route.ts b/apps/tradinggoose/app/api/auth/mcp/revoke/route.ts index 8808f7a62..8cea701a6 100644 --- a/apps/tradinggoose/app/api/auth/mcp/revoke/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/revoke/route.ts @@ -5,11 +5,12 @@ export const dynamic = 'force-dynamic' function getBearerToken(request: NextRequest) { const authorization = request.headers.get('authorization') - if (!authorization?.startsWith('Bearer ')) { + const match = authorization?.match(/^Bearer\s+(.+)$/i) + if (!match) { return null } - const token = authorization.slice('Bearer '.length).trim() + const token = match[1].trim() return token || null } diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index 9e67fd804..bc1bd2c56 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -116,6 +116,19 @@ describe('Copilot MCP route', () => { ) }) + it('accepts a case-insensitive bearer auth scheme', async () => { + const { POST } = await import('./route') + + const response = await POST( + createMcpRequest({ jsonrpc: '2.0', id: 1, method: 'initialize' }, 'bearer sk-lowercase') + ) + + expect(response.status).toBe(200) + expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('sk-lowercase', { + keyTypes: ['personal'], + }) + }) + it('lists only executable server copilot tools', async () => { const { POST } = await import('./route') diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index f4df784a9..c33adbe2d 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -55,11 +55,12 @@ function mcpJsonResponse(body: unknown, init?: ResponseInit) { function getBearerToken(request: NextRequest) { const authorization = request.headers.get('authorization') - if (!authorization?.startsWith('Bearer ')) { + const match = authorization?.match(/^Bearer\s+(.+)$/i) + if (!match) { return null } - const token = authorization.slice('Bearer '.length).trim() + const token = match[1].trim() return token || null } From 9b88e90cb1f4ecae77c11dbaf098fcb2589f50c8 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 01:10:05 -0600 Subject: [PATCH 015/284] fix(mcp): harden device login token handling Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/auth/mcp/poll/route.test.ts | 6 +- .../app/api/auth/mcp/poll/route.ts | 3 +- .../app/api/auth/mcp/start/route.test.ts | 2 + .../app/mcp/[[...command]]/route.test.ts | 18 +- apps/tradinggoose/lib/mcp/auth.ts | 192 ++++++++++++------ apps/tradinggoose/lib/mcp/install-script.ts | 23 ++- 6 files changed, 176 insertions(+), 68 deletions(-) diff --git a/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts index ca7880f3c..244d88705 100644 --- a/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts @@ -23,13 +23,13 @@ describe('MCP login poll route', () => { }) }) - it('polls the device login by code', async () => { + it('polls the device login by code and verification key', async () => { const { POST } = await import('./route') const response = await POST( new NextRequest('https://studio.example.test/api/auth/mcp/poll', { method: 'POST', - body: JSON.stringify({ code: 'login-code' }), + body: JSON.stringify({ code: 'login-code', verificationKey: 'verification-key' }), }) ) @@ -39,7 +39,7 @@ describe('MCP login poll route', () => { apiKey: 'sk-tradinggoose-token', expiresAt: '2026-06-19T12:00:00.000Z', }) - expect(mockPollMcpDeviceLogin).toHaveBeenCalledWith('login-code') + expect(mockPollMcpDeviceLogin).toHaveBeenCalledWith('login-code', 'verification-key') }) it('rejects malformed poll requests', async () => { diff --git a/apps/tradinggoose/app/api/auth/mcp/poll/route.ts b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts index 2e5c91fb4..e9d965359 100644 --- a/apps/tradinggoose/app/api/auth/mcp/poll/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts @@ -6,6 +6,7 @@ export const dynamic = 'force-dynamic' const PollRequestSchema = z.object({ code: z.string().min(1), + verificationKey: z.string().min(1), }) export async function POST(request: NextRequest) { @@ -14,6 +15,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid MCP login poll request' }, { status: 400 }) } - const result = await pollMcpDeviceLogin(parsed.data.code) + const result = await pollMcpDeviceLogin(parsed.data.code, parsed.data.verificationKey) return NextResponse.json(result) } diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts index 8b6004ab1..aa7184bf2 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts @@ -18,6 +18,7 @@ describe('MCP login start route', () => { vi.clearAllMocks() mockStartMcpDeviceLogin.mockResolvedValue({ code: 'login-code', + verificationKey: 'verification-key', expiresAt: '2026-06-19T12:00:00.000Z', intervalSeconds: 2, }) @@ -35,6 +36,7 @@ describe('MCP login start route', () => { expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ code: 'login-code', + verificationKey: 'verification-key', expiresAt: '2026-06-19T12:00:00.000Z', intervalSeconds: 2, authorizeUrl: 'https://studio.example.test/mcp/authorize?code=login-code', diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts index 013482cea..3b8683414 100644 --- a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts @@ -40,13 +40,17 @@ describe('MCP install route', () => { expect(script).toContain('irm /mcp/login | iex') expect(script).toContain("baseUrl + '/api/auth/mcp/start'") expect(script).toContain("baseUrl + '/api/auth/mcp/poll'") + expect(script).toContain('const verificationKey = String(startJson?.verificationKey ||') + expect(script).toContain("postJson(baseUrl + '/api/auth/mcp/poll', { code, verificationKey })") expect(script).toContain("baseUrl + '/api/auth/mcp/revoke'") expect(script).toContain("baseUrl + '/api/copilot/mcp'") expect(script).toContain("Authorization: Bearer ' + token") expect(script).toContain('setup Authenticate, rotate local MCP auth, and write config.') expect(script).toContain('read-tokens') + expect(script).toContain('await revokeTokens(existingTokens, token)') + expect(script).not.toContain('revokeExistingTokens') expect(script).toContain('node - "$BASE_URL" "$COMMAND" "$TARGETS"') - expect(script).toContain("runConfigWriter([target, mcpUrl, token])") + expect(script).toContain('runConfigWriter([target, mcpUrl, token])') expect(script).toContain("const mcpServerName = 'TradingGoose'") expect(script).toContain("const codexBearerTokenEnvVar = 'TRADINGGOOSE_BEARER_TOKEN'") expect(script).toContain("'bearer_token_env_var = ' + JSON.stringify(codexBearerTokenEnvVar)") @@ -57,6 +61,18 @@ describe('MCP install route', () => { expect(script).toContain("path.join(os.homedir(), '.config', 'opencode', 'opencode.json')") expect(script).not.toContain('workspaceId') expect(script).not.toContain('entityId') + + const printedTokenIndex = script.indexOf("console.log('Authorization: Bearer ' + token)") + const firstRevokeIndex = script.indexOf('await revokeTokens(existingTokens, token)') + const configWriteIndex = script.indexOf( + 'const configPath = runConfigWriter([target, mcpUrl, token])' + ) + const setupRevokeIndex = script.indexOf( + 'await revokeTokens(existingTokens, token)', + configWriteIndex + ) + expect(firstRevokeIndex).toBeGreaterThan(printedTokenIndex) + expect(setupRevokeIndex).toBeGreaterThan(configWriteIndex) }) it('serves target-specific setup scripts from the URL path', async () => { diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 65147acc5..bc0cf9c61 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -3,7 +3,7 @@ import { db } from '@tradinggoose/db' import { apiKey, verification } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' -import { authenticateApiKeyFromHeader, createPersonalApiKey } from '@/lib/api-key/service' +import { authenticateApiKeyFromHeader, createApiKeyMaterial } from '@/lib/api-key/service' const DEVICE_LOGIN_TTL_MS = 10 * 60 * 1000 const DEVICE_LOGIN_PREFIX = 'mcp:' @@ -12,12 +12,15 @@ const POLL_INTERVAL_SECONDS = 2 type PendingDeviceLogin = { status: 'pending' createdAt: string - userApprovalTokenHash?: string + verificationKeyHash: string + approvalToken?: string + approvalUserId?: string } type ApprovedDeviceLogin = { status: 'approved' createdAt: string + verificationKeyHash: string approvedAt: string userId: string keyId: string @@ -29,6 +32,7 @@ type DeviceLoginState = PendingDeviceLogin | ApprovedDeviceLogin export type McpDeviceLoginPollResult = | { status: 'pending'; intervalSeconds: number; expiresAt: string } | { status: 'approved'; apiKey: string; expiresAt: string } + | { status: 'invalid' } | { status: 'expired' } export type McpDeviceLoginApprovalResult = @@ -38,6 +42,7 @@ export type McpDeviceLoginApprovalResult = export type McpDeviceLoginStartResult = { code: string + verificationKey: string expiresAt: string intervalSeconds: number } @@ -47,23 +52,29 @@ export type McpApiKeyRevocationResult = { } function getDeviceLoginIdentifier(code: string) { - const digest = createHash('sha256').update(code).digest('hex') - return `${DEVICE_LOGIN_PREFIX}${digest}` + return `${DEVICE_LOGIN_PREFIX}${hashValue(code)}` } -function getUserApprovalTokenHash(code: string, approvalToken: string, userId: string) { - return createHash('sha256').update(`${code}:${approvalToken}:${userId}`).digest('hex') +function hashValue(value: string) { + return createHash('sha256').update(value).digest('hex') } function parseDeviceLoginState(value: string): DeviceLoginState | null { try { const parsed = JSON.parse(value) as Record - if (parsed.status === 'pending' && typeof parsed.createdAt === 'string') { + if ( + parsed.status === 'pending' && + typeof parsed.createdAt === 'string' && + typeof parsed.verificationKeyHash === 'string' && + (parsed.approvalToken === undefined || typeof parsed.approvalToken === 'string') && + (parsed.approvalUserId === undefined || typeof parsed.approvalUserId === 'string') + ) { return parsed as PendingDeviceLogin } if ( parsed.status === 'approved' && typeof parsed.createdAt === 'string' && + typeof parsed.verificationKeyHash === 'string' && typeof parsed.approvedAt === 'string' && typeof parsed.userId === 'string' && typeof parsed.keyId === 'string' && @@ -108,6 +119,7 @@ async function readDeviceLogin(code: string) { export async function startMcpDeviceLogin(): Promise { const code = randomBytes(32).toString('base64url') + const verificationKey = randomBytes(32).toString('base64url') const now = new Date() const expiresAt = new Date(now.getTime() + DEVICE_LOGIN_TTL_MS) @@ -117,6 +129,7 @@ export async function startMcpDeviceLogin(): Promise value: JSON.stringify({ status: 'pending', createdAt: now.toISOString(), + verificationKeyHash: hashValue(verificationKey), } satisfies PendingDeviceLogin), expiresAt, createdAt: now, @@ -125,51 +138,77 @@ export async function startMcpDeviceLogin(): Promise return { code, + verificationKey, expiresAt: expiresAt.toISOString(), intervalSeconds: POLL_INTERVAL_SECONDS, } } export async function createMcpDeviceLoginApprovalChallenge(code: string, userId: string) { - const login = await readDeviceLogin(code) - if (!login) { - return { status: 'expired' } - } + while (true) { + const login = await readDeviceLogin(code) + if (!login) { + return { status: 'expired' } + } - if (login.state.status === 'approved') { - return { - status: 'approved', - expiresAt: login.expiresAt.toISOString(), + if (login.state.status !== 'pending') { + return { + status: 'approved', + expiresAt: login.expiresAt.toISOString(), + } } - } - const now = new Date() - const approvalToken = randomBytes(32).toString('base64url') - await db - .update(verification) - .set({ - value: JSON.stringify({ - ...login.state, - userApprovalTokenHash: getUserApprovalTokenHash(code, approvalToken, userId), - } satisfies PendingDeviceLogin), - updatedAt: now, - }) - .where(eq(verification.id, login.id)) + if (login.state.approvalUserId === userId && login.state.approvalToken) { + return { + status: 'pending', + approvalToken: login.state.approvalToken, + expiresAt: login.expiresAt.toISOString(), + } + } - return { - status: 'pending', - approvalToken, - expiresAt: login.expiresAt.toISOString(), + const approvalToken = randomBytes(32).toString('base64url') + const nextState = { + ...login.state, + approvalToken, + approvalUserId: userId, + } satisfies PendingDeviceLogin + + const now = new Date() + const [updated] = await db + .update(verification) + .set({ + value: JSON.stringify(nextState), + updatedAt: now, + }) + .where( + and(eq(verification.id, login.id), eq(verification.value, JSON.stringify(login.state))) + ) + .returning({ id: verification.id }) + + if (updated) { + return { + status: 'pending', + approvalToken, + expiresAt: login.expiresAt.toISOString(), + } + } } } -export async function pollMcpDeviceLogin(code: string): Promise { +export async function pollMcpDeviceLogin( + code: string, + verificationKey: string +): Promise { const login = await readDeviceLogin(code) if (!login) { return { status: 'expired' } } - if (login.state.status === 'pending') { + if (login.state.verificationKeyHash !== hashValue(verificationKey)) { + return { status: 'invalid' } + } + + if (login.state.status !== 'approved') { return { status: 'pending', intervalSeconds: POLL_INTERVAL_SECONDS, @@ -206,35 +245,65 @@ export async function approveMcpDeviceLogin({ } } - if (login.state.userApprovalTokenHash !== getUserApprovalTokenHash(code, approvalToken, userId)) { + if (login.state.approvalToken !== approvalToken || login.state.approvalUserId !== userId) { return { status: 'invalid' } } const now = new Date() - const createdKey = await createPersonalApiKey({ + const keyId = nanoid() + const keyName = `TradingGoose MCP ${now.toISOString()}` + const createdKey = await createApiKeyMaterial(true) + if (!createdKey.encryptedKey) { + throw new Error('Failed to encrypt MCP API key for storage') + } + const encryptedKey = createdKey.encryptedKey + const approvedAt = now.toISOString() + const approvedState = { + status: 'approved', + createdAt: login.state.createdAt, + verificationKeyHash: login.state.verificationKeyHash, + approvedAt, userId, - name: `TradingGoose MCP ${now.toISOString()}`, - createdAt: now, - }) + keyId, + apiKey: createdKey.key, + } satisfies ApprovedDeviceLogin + + const approved = await db.transaction(async (tx) => { + const [claimed] = await tx + .update(verification) + .set({ + value: JSON.stringify(approvedState), + updatedAt: now, + }) + .where( + and(eq(verification.id, login.id), eq(verification.value, JSON.stringify(login.state))) + ) + .returning({ expiresAt: verification.expiresAt }) + + if (!claimed) { + return null + } - await db - .update(verification) - .set({ - value: JSON.stringify({ - status: 'approved', - createdAt: login.state.createdAt, - approvedAt: now.toISOString(), - userId, - keyId: createdKey.id, - apiKey: createdKey.key, - } satisfies ApprovedDeviceLogin), + await tx.insert(apiKey).values({ + id: keyId, + userId, + workspaceId: null, + name: keyName, + key: encryptedKey, + type: 'personal', + createdAt: now, updatedAt: now, }) - .where(eq(verification.id, login.id)) + return claimed + }) + + if (!approved) { + return { status: 'invalid' } + } return { status: 'approved', - expiresAt: login.expiresAt.toISOString(), + expiresAt: approved.expiresAt.toISOString(), } } @@ -252,14 +321,23 @@ export async function cancelMcpDeviceLogin({ return { status: 'expired' } } - if ( - login.state.status !== 'pending' || - login.state.userApprovalTokenHash !== getUserApprovalTokenHash(code, approvalToken, userId) - ) { + if (login.state.status !== 'pending') { + return { status: 'invalid' } + } + + if (login.state.approvalToken !== approvalToken || login.state.approvalUserId !== userId) { + return { status: 'invalid' } + } + + const [deleted] = await db + .delete(verification) + .where(and(eq(verification.id, login.id), eq(verification.value, JSON.stringify(login.state)))) + .returning({ id: verification.id }) + + if (!deleted) { return { status: 'invalid' } } - await db.delete(verification).where(eq(verification.id, login.id)) return { status: 'cancelled' } } diff --git a/apps/tradinggoose/lib/mcp/install-script.ts b/apps/tradinggoose/lib/mcp/install-script.ts index 05448bfd8..70fc217d9 100644 --- a/apps/tradinggoose/lib/mcp/install-script.ts +++ b/apps/tradinggoose/lib/mcp/install-script.ts @@ -82,22 +82,31 @@ function runConfigWriter(args) { return result.stdout.trim() } -async function revokeExistingTokens() { - const tokens = runConfigWriter(['read-tokens']).split(/\r?\n/).filter(Boolean) +function readExistingTokens() { + return runConfigWriter(['read-tokens']).split(/\r?\n/).filter(Boolean) +} + +async function revokeTokens(tokens, currentToken) { for (const token of tokens) { - await postJson(baseUrl + '/api/auth/mcp/revoke', null, token) + if (token !== currentToken) { + await postJson(baseUrl + '/api/auth/mcp/revoke', null, token) + } } } async function authenticate() { const startJson = await postJson(baseUrl + '/api/auth/mcp/start') const code = String(startJson?.code || '') + const verificationKey = String(startJson?.verificationKey || '') const authorizeUrl = String(startJson?.authorizeUrl || '') const intervalSeconds = Math.max(1, Number(startJson?.intervalSeconds) || 2) if (!code) { fail('Studio did not return a login code') } + if (!verificationKey) { + fail('Studio did not return a login verification key') + } if (!authorizeUrl) { fail('Studio did not return an authorization URL') } @@ -108,7 +117,7 @@ async function authenticate() { const deadline = Date.now() + 600000 while (Date.now() < deadline) { - const pollJson = await postJson(baseUrl + '/api/auth/mcp/poll', { code }) + const pollJson = await postJson(baseUrl + '/api/auth/mcp/poll', { code, verificationKey }) const status = String(pollJson?.status || 'pending') if (status === 'approved') { @@ -137,7 +146,7 @@ async function main() { requireFetch() if (command === 'login') { - await revokeExistingTokens() + const existingTokens = readExistingTokens() const token = await authenticate() console.log('MCP endpoint:') console.log(mcpUrl) @@ -147,6 +156,7 @@ async function main() { console.log('') console.log('Use this MCP auth header:') console.log('Authorization: Bearer ' + token) + await revokeTokens(existingTokens, token) return } @@ -155,13 +165,14 @@ async function main() { fail('setup requires a selected target') } - await revokeExistingTokens() + const existingTokens = readExistingTokens() const token = await authenticate() console.log('Using MCP endpoint: ' + mcpUrl) for (const target of targets) { const configPath = runConfigWriter([target, mcpUrl, token]) console.log('Configured ' + target + ': ' + configPath) } + await revokeTokens(existingTokens, token) return } From 197c4f66ee7ed6c162ec9afc180ded7c2b425a4e Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 01:11:44 -0600 Subject: [PATCH 016/284] style(copilot): format workflow tool Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../copilot/tools/server/entities/workflow.ts | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index c21f57e45..50ec821f5 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -6,10 +6,7 @@ import { z } from 'zod' import { getStableVibrantColor } from '@/lib/colors' import { WORKFLOW_VARIABLE_DOCUMENT_FORMAT } from '@/lib/copilot/entity-documents' import { verifyWorkflowAccess } from '@/lib/copilot/review-sessions/permissions' -import { - ENTITY_KIND_WORKFLOW, - type ReviewAccessMode, -} from '@/lib/copilot/review-sessions/types' +import { ENTITY_KIND_WORKFLOW, type ReviewAccessMode } from '@/lib/copilot/review-sessions/types' import type { BaseServerTool, ServerToolExecutionContext, @@ -39,10 +36,7 @@ import { readWorkflowSnapshot, type WorkflowSnapshot, } from '@/lib/yjs/workflow-session' -import { - isWorkflowVariableType, - type WorkflowVariableType, -} from '@/lib/workflows/value-types' +import { isWorkflowVariableType, type WorkflowVariableType } from '@/lib/workflows/value-types' import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow' import { editWorkflowBlockServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow-block' @@ -171,7 +165,8 @@ function buildWorkflowSummary(workflowState: WorkflowSnapshot): WorkflowSummary return { blockId, blockType: block.type, - blockName: normalizeWorkflowName(typeof block.name === 'string' ? block.name : undefined) ?? blockId, + blockName: + normalizeWorkflowName(typeof block.name === 'string' ? block.name : undefined) ?? blockId, ...(typeof block.enabled === 'boolean' ? { enabled: block.enabled } : {}), ...(typeof block.data?.parentId === 'string' ? { parentId: block.data.parentId } : {}), subBlockIds: Object.keys(block.subBlocks ?? {}).sort(), @@ -397,11 +392,8 @@ export const readWorkflowServerTool: BaseServerTool<{ entityId: string }, any> = name: 'read_workflow', async execute(args, context) { const workflowId = requireCopilotEntityId(args, { toolName: 'read_workflow' }) - const { entityName, workspaceId, workflowState, variables } = await loadWorkflowSnapshotForCopilot( - workflowId, - context, - 'read' - ) + const { entityName, workspaceId, workflowState, variables } = + await loadWorkflowSnapshotForCopilot(workflowId, context, 'read') const entityDocument = serializeWorkflowToTgMermaid(workflowState) return { From c716609aab5ca9e3e2604acb88864ff67e4d26ca Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 01:12:09 -0600 Subject: [PATCH 017/284] refactor(copilot): reuse shared workflow context helpers Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../copilot/tools/server/entities/workflow.ts | 35 +------------------ 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index 50ec821f5..c49970153 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -17,7 +17,6 @@ import { } from '@/lib/copilot/tools/server/base-tool' import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target' import { generateCreativeWorkflowName } from '@/lib/naming' -import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { VariableManager } from '@/lib/variables/variable-manager' import { TG_MERMAID_DOCUMENT_FORMAT, @@ -39,6 +38,7 @@ import { import { isWorkflowVariableType, type WorkflowVariableType } from '@/lib/workflows/value-types' import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow' import { editWorkflowBlockServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow-block' +import { requireUserId, verifyWorkspaceContext } from './shared' type WorkflowSummary = { blocks: Array<{ @@ -90,24 +90,6 @@ const WorkflowVariableDocumentSchema = z }) .strict() -function requireUserId(context?: ServerToolExecutionContext): string { - const userId = context?.userId?.trim() - if (!userId) { - throw new Error('Authenticated user is required to execute copilot workflow tools') - } - return userId -} - -function requireWorkspaceId(context?: ServerToolExecutionContext): string { - const workspaceId = context?.workspaceId?.trim() - if (!workspaceId) { - throw new Error( - 'No active workspace found in execution context. Ensure workspaceId is included in tool provenance.' - ) - } - return workspaceId -} - function normalizeWorkflowName(value?: string | null): string | undefined { const normalized = value?.trim() return normalized ? normalized : undefined @@ -182,21 +164,6 @@ function buildWorkflowSummary(workflowState: WorkflowSnapshot): WorkflowSummary } } -async function verifyWorkspaceContext( - context: ServerToolExecutionContext | undefined, - accessMode: 'read' | 'write' -): Promise<{ userId: string; workspaceId: string }> { - const userId = requireUserId(context) - const workspaceId = requireWorkspaceId(context) - const access = await checkWorkspaceAccess(workspaceId, userId) - - if (!access.exists || !access.hasAccess || (accessMode === 'write' && !access.canWrite)) { - throw new Error('Access denied: You do not have permission to use this workspace') - } - - return { userId, workspaceId } -} - async function verifyWorkflowContext( workflowId: string, context: ServerToolExecutionContext | undefined, From f39d7ca51866efbd3c85b03ce95b5686323dcede Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 15:35:24 -0600 Subject: [PATCH 018/284] feat(mcp): confirm device login tokens before storing Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/auth/mcp/poll/route.test.ts | 27 ++- .../app/api/auth/mcp/poll/route.ts | 7 +- .../app/mcp/[[...command]]/route.test.ts | 25 +- apps/tradinggoose/lib/mcp/auth.ts | 228 ++++++++++++------ apps/tradinggoose/lib/mcp/install-script.ts | 31 ++- 5 files changed, 222 insertions(+), 96 deletions(-) diff --git a/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts index 244d88705..44965c780 100644 --- a/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts @@ -39,7 +39,32 @@ describe('MCP login poll route', () => { apiKey: 'sk-tradinggoose-token', expiresAt: '2026-06-19T12:00:00.000Z', }) - expect(mockPollMcpDeviceLogin).toHaveBeenCalledWith('login-code', 'verification-key') + expect(mockPollMcpDeviceLogin).toHaveBeenCalledWith('login-code', 'verification-key', { + confirm: false, + apiKey: undefined, + }) + }) + + it('confirms a delivered device login token', async () => { + const { POST } = await import('./route') + + const response = await POST( + new NextRequest('https://studio.example.test/api/auth/mcp/poll', { + method: 'POST', + body: JSON.stringify({ + code: 'login-code', + verificationKey: 'verification-key', + confirm: true, + apiKey: 'sk-tradinggoose-token', + }), + }) + ) + + expect(response.status).toBe(200) + expect(mockPollMcpDeviceLogin).toHaveBeenCalledWith('login-code', 'verification-key', { + confirm: true, + apiKey: 'sk-tradinggoose-token', + }) }) it('rejects malformed poll requests', async () => { diff --git a/apps/tradinggoose/app/api/auth/mcp/poll/route.ts b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts index e9d965359..41611eb1a 100644 --- a/apps/tradinggoose/app/api/auth/mcp/poll/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts @@ -7,6 +7,8 @@ export const dynamic = 'force-dynamic' const PollRequestSchema = z.object({ code: z.string().min(1), verificationKey: z.string().min(1), + confirm: z.boolean().optional(), + apiKey: z.string().optional(), }) export async function POST(request: NextRequest) { @@ -15,6 +17,9 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid MCP login poll request' }, { status: 400 }) } - const result = await pollMcpDeviceLogin(parsed.data.code, parsed.data.verificationKey) + const result = await pollMcpDeviceLogin(parsed.data.code, parsed.data.verificationKey, { + confirm: parsed.data.confirm === true, + apiKey: parsed.data.apiKey, + }) return NextResponse.json(result) } diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts index 3b8683414..fc1e581b8 100644 --- a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts @@ -41,16 +41,18 @@ describe('MCP install route', () => { expect(script).toContain("baseUrl + '/api/auth/mcp/start'") expect(script).toContain("baseUrl + '/api/auth/mcp/poll'") expect(script).toContain('const verificationKey = String(startJson?.verificationKey ||') - expect(script).toContain("postJson(baseUrl + '/api/auth/mcp/poll', { code, verificationKey })") + expect(script).toContain('return { code, verificationKey, token }') + expect(script).toContain('confirm: true') + expect(script).toContain('apiKey: login.token') expect(script).toContain("baseUrl + '/api/auth/mcp/revoke'") expect(script).toContain("baseUrl + '/api/copilot/mcp'") - expect(script).toContain("Authorization: Bearer ' + token") + expect(script).toContain("Authorization: Bearer ' + login.token") expect(script).toContain('setup Authenticate, rotate local MCP auth, and write config.') expect(script).toContain('read-tokens') - expect(script).toContain('await revokeTokens(existingTokens, token)') + expect(script).toContain('await revokeTokens(existingTokens, login.token)') expect(script).not.toContain('revokeExistingTokens') expect(script).toContain('node - "$BASE_URL" "$COMMAND" "$TARGETS"') - expect(script).toContain('runConfigWriter([target, mcpUrl, token])') + expect(script).toContain('runConfigWriter([target, mcpUrl, login.token])') expect(script).toContain("const mcpServerName = 'TradingGoose'") expect(script).toContain("const codexBearerTokenEnvVar = 'TRADINGGOOSE_BEARER_TOKEN'") expect(script).toContain("'bearer_token_env_var = ' + JSON.stringify(codexBearerTokenEnvVar)") @@ -62,16 +64,21 @@ describe('MCP install route', () => { expect(script).not.toContain('workspaceId') expect(script).not.toContain('entityId') - const printedTokenIndex = script.indexOf("console.log('Authorization: Bearer ' + token)") - const firstRevokeIndex = script.indexOf('await revokeTokens(existingTokens, token)') + const printedTokenIndex = script.indexOf("console.log('Authorization: Bearer ' + login.token)") + const firstConfirmIndex = script.indexOf('await confirmLogin(login)') + const firstRevokeIndex = script.indexOf('await revokeTokens(existingTokens, login.token)') const configWriteIndex = script.indexOf( - 'const configPath = runConfigWriter([target, mcpUrl, token])' + 'const configPath = runConfigWriter([target, mcpUrl, login.token])' ) + const setupConfirmIndex = script.indexOf('await confirmLogin(login)', configWriteIndex) const setupRevokeIndex = script.indexOf( - 'await revokeTokens(existingTokens, token)', + 'await revokeTokens(existingTokens, login.token)', configWriteIndex ) - expect(firstRevokeIndex).toBeGreaterThan(printedTokenIndex) + expect(firstConfirmIndex).toBeGreaterThan(printedTokenIndex) + expect(firstRevokeIndex).toBeGreaterThan(firstConfirmIndex) + expect(setupConfirmIndex).toBeGreaterThan(configWriteIndex) + expect(setupRevokeIndex).toBeGreaterThan(setupConfirmIndex) expect(setupRevokeIndex).toBeGreaterThan(configWriteIndex) }) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index bc0cf9c61..24c84b046 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -3,7 +3,11 @@ import { db } from '@tradinggoose/db' import { apiKey, verification } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' -import { authenticateApiKeyFromHeader, createApiKeyMaterial } from '@/lib/api-key/service' +import { + authenticateApiKeyFromHeader, + createApiKeyMaterial, + encryptApiKey, +} from '@/lib/api-key/service' const DEVICE_LOGIN_TTL_MS = 10 * 60 * 1000 const DEVICE_LOGIN_PREFIX = 'mcp:' @@ -23,15 +27,26 @@ type ApprovedDeviceLogin = { verificationKeyHash: string approvedAt: string userId: string + keyId?: string + apiKeyHash?: string +} + +type IssuedDeviceLogin = ApprovedDeviceLogin & { keyId: string - apiKey: string + apiKeyHash: string } type DeviceLoginState = PendingDeviceLogin | ApprovedDeviceLogin +type DeviceLogin = { + id: string + state: DeviceLoginState + expiresAt: Date +} export type McpDeviceLoginPollResult = | { status: 'pending'; intervalSeconds: number; expiresAt: string } | { status: 'approved'; apiKey: string; expiresAt: string } + | { status: 'confirmed'; expiresAt: string } | { status: 'invalid' } | { status: 'expired' } @@ -59,6 +74,18 @@ function hashValue(value: string) { return createHash('sha256').update(value).digest('hex') } +function isIssuedDeviceLogin(state: DeviceLoginState): state is IssuedDeviceLogin { + return ( + state.status === 'approved' && + typeof state.keyId === 'string' && + typeof state.apiKeyHash === 'string' + ) +} + +function deviceLoginMatches(login: DeviceLogin, state = login.state) { + return and(eq(verification.id, login.id), eq(verification.value, JSON.stringify(state))) +} + function parseDeviceLoginState(value: string): DeviceLoginState | null { try { const parsed = JSON.parse(value) as Record @@ -71,14 +98,16 @@ function parseDeviceLoginState(value: string): DeviceLoginState | null { ) { return parsed as PendingDeviceLogin } + const approvedHasNoKey = parsed.keyId === undefined && parsed.apiKeyHash === undefined + const approvedHasIssuedKey = + typeof parsed.keyId === 'string' && typeof parsed.apiKeyHash === 'string' if ( parsed.status === 'approved' && typeof parsed.createdAt === 'string' && typeof parsed.verificationKeyHash === 'string' && typeof parsed.approvedAt === 'string' && typeof parsed.userId === 'string' && - typeof parsed.keyId === 'string' && - typeof parsed.apiKey === 'string' + (approvedHasNoKey || approvedHasIssuedKey) ) { return parsed as ApprovedDeviceLogin } @@ -117,6 +146,81 @@ async function readDeviceLogin(code: string) { } } +async function updateDeviceLoginState( + login: DeviceLogin, + nextState: DeviceLoginState +): Promise { + const [updated] = await db + .update(verification) + .set({ + value: JSON.stringify(nextState), + updatedAt: new Date(), + }) + .where(deviceLoginMatches(login)) + .returning({ id: verification.id }) + + return Boolean(updated) +} + +async function issueMcpDeviceLoginKey( + login: DeviceLogin, + approvedState: ApprovedDeviceLogin +): Promise { + const keyId = nanoid() + const createdKey = await createApiKeyMaterial(false) + + const nextState = { + ...approvedState, + keyId, + apiKeyHash: hashValue(createdKey.key), + } satisfies IssuedDeviceLogin + + return (await updateDeviceLoginState(login, nextState)) + ? { status: 'approved', apiKey: createdKey.key, expiresAt: login.expiresAt.toISOString() } + : null +} + +async function confirmMcpDeviceLoginKey( + login: DeviceLogin, + issuedState: IssuedDeviceLogin, + plainKey: string +): Promise { + if (issuedState.apiKeyHash !== hashValue(plainKey)) { + return false + } + + const now = new Date() + const encryptedKey = (await encryptApiKey(plainKey)).encrypted + const confirmed = await db.transaction(async (tx) => { + const [deleted] = await tx + .delete(verification) + .where(deviceLoginMatches(login, issuedState)) + .returning({ id: verification.id }) + + if (!deleted) { + return null + } + + const [createdKey] = await tx + .insert(apiKey) + .values({ + id: issuedState.keyId, + userId: issuedState.userId, + workspaceId: null, + name: `TradingGoose MCP ${now.toISOString()}`, + key: encryptedKey, + type: 'personal', + createdAt: now, + updatedAt: now, + }) + .returning({ id: apiKey.id }) + + return createdKey + }) + + return Boolean(confirmed) +} + export async function startMcpDeviceLogin(): Promise { const code = randomBytes(32).toString('base64url') const verificationKey = randomBytes(32).toString('base64url') @@ -173,19 +277,7 @@ export async function createMcpDeviceLoginApprovalChallenge(code: string, userId approvalUserId: userId, } satisfies PendingDeviceLogin - const now = new Date() - const [updated] = await db - .update(verification) - .set({ - value: JSON.stringify(nextState), - updatedAt: now, - }) - .where( - and(eq(verification.id, login.id), eq(verification.value, JSON.stringify(login.state))) - ) - .returning({ id: verification.id }) - - if (updated) { + if (await updateDeviceLoginState(login, nextState)) { return { status: 'pending', approvalToken, @@ -197,30 +289,50 @@ export async function createMcpDeviceLoginApprovalChallenge(code: string, userId export async function pollMcpDeviceLogin( code: string, - verificationKey: string + verificationKey: string, + options: { confirm?: boolean; apiKey?: string } = {} ): Promise { - const login = await readDeviceLogin(code) - if (!login) { - return { status: 'expired' } - } + while (true) { + const login = await readDeviceLogin(code) + if (!login) { + return { status: 'expired' } + } - if (login.state.verificationKeyHash !== hashValue(verificationKey)) { - return { status: 'invalid' } - } + if (login.state.verificationKeyHash !== hashValue(verificationKey)) { + return { status: 'invalid' } + } - if (login.state.status !== 'approved') { - return { - status: 'pending', - intervalSeconds: POLL_INTERVAL_SECONDS, - expiresAt: login.expiresAt.toISOString(), + if (login.state.status !== 'approved') { + return { + status: 'pending', + intervalSeconds: POLL_INTERVAL_SECONDS, + expiresAt: login.expiresAt.toISOString(), + } } - } - await db.delete(verification).where(eq(verification.id, login.id)) - return { - status: 'approved', - apiKey: login.state.apiKey, - expiresAt: login.expiresAt.toISOString(), + const approvedState = login.state + + if (options.confirm) { + if (!isIssuedDeviceLogin(approvedState) || !options.apiKey) { + return { status: 'invalid' } + } + + if (!(await confirmMcpDeviceLoginKey(login, approvedState, options.apiKey))) { + continue + } + + return { + status: 'confirmed', + expiresAt: login.expiresAt.toISOString(), + } + } + + const issued = await issueMcpDeviceLoginKey(login, approvedState) + if (!issued) { + continue + } + + return issued } } @@ -250,13 +362,6 @@ export async function approveMcpDeviceLogin({ } const now = new Date() - const keyId = nanoid() - const keyName = `TradingGoose MCP ${now.toISOString()}` - const createdKey = await createApiKeyMaterial(true) - if (!createdKey.encryptedKey) { - throw new Error('Failed to encrypt MCP API key for storage') - } - const encryptedKey = createdKey.encryptedKey const approvedAt = now.toISOString() const approvedState = { status: 'approved', @@ -264,46 +369,15 @@ export async function approveMcpDeviceLogin({ verificationKeyHash: login.state.verificationKeyHash, approvedAt, userId, - keyId, - apiKey: createdKey.key, } satisfies ApprovedDeviceLogin - const approved = await db.transaction(async (tx) => { - const [claimed] = await tx - .update(verification) - .set({ - value: JSON.stringify(approvedState), - updatedAt: now, - }) - .where( - and(eq(verification.id, login.id), eq(verification.value, JSON.stringify(login.state))) - ) - .returning({ expiresAt: verification.expiresAt }) - - if (!claimed) { - return null - } - - await tx.insert(apiKey).values({ - id: keyId, - userId, - workspaceId: null, - name: keyName, - key: encryptedKey, - type: 'personal', - createdAt: now, - updatedAt: now, - }) - return claimed - }) - - if (!approved) { + if (!(await updateDeviceLoginState(login, approvedState))) { return { status: 'invalid' } } return { status: 'approved', - expiresAt: approved.expiresAt.toISOString(), + expiresAt: login.expiresAt.toISOString(), } } @@ -331,7 +405,7 @@ export async function cancelMcpDeviceLogin({ const [deleted] = await db .delete(verification) - .where(and(eq(verification.id, login.id), eq(verification.value, JSON.stringify(login.state)))) + .where(deviceLoginMatches(login)) .returning({ id: verification.id }) if (!deleted) { diff --git a/apps/tradinggoose/lib/mcp/install-script.ts b/apps/tradinggoose/lib/mcp/install-script.ts index 70fc217d9..161b3ad6c 100644 --- a/apps/tradinggoose/lib/mcp/install-script.ts +++ b/apps/tradinggoose/lib/mcp/install-script.ts @@ -125,7 +125,7 @@ async function authenticate() { if (!token) { fail('Studio approved login without returning a token') } - return token + return { code, verificationKey, token } } if (status === 'expired') { @@ -142,21 +142,35 @@ async function authenticate() { fail('Timed out waiting for browser approval') } +async function confirmLogin(login) { + const confirmJson = await postJson(baseUrl + '/api/auth/mcp/poll', { + code: login.code, + verificationKey: login.verificationKey, + confirm: true, + apiKey: login.token, + }) + const status = String(confirmJson?.status || '') + if (status !== 'confirmed') { + fail('Studio could not confirm the delivered MCP token') + } +} + async function main() { requireFetch() if (command === 'login') { const existingTokens = readExistingTokens() - const token = await authenticate() + const login = await authenticate() console.log('MCP endpoint:') console.log(mcpUrl) console.log('') console.log('Bearer token:') - console.log(token) + console.log(login.token) console.log('') console.log('Use this MCP auth header:') - console.log('Authorization: Bearer ' + token) - await revokeTokens(existingTokens, token) + console.log('Authorization: Bearer ' + login.token) + await confirmLogin(login) + await revokeTokens(existingTokens, login.token) return } @@ -166,13 +180,14 @@ async function main() { } const existingTokens = readExistingTokens() - const token = await authenticate() + const login = await authenticate() console.log('Using MCP endpoint: ' + mcpUrl) for (const target of targets) { - const configPath = runConfigWriter([target, mcpUrl, token]) + const configPath = runConfigWriter([target, mcpUrl, login.token]) console.log('Configured ' + target + ': ' + configPath) } - await revokeTokens(existingTokens, token) + await confirmLogin(login) + await revokeTokens(existingTokens, login.token) return } From d0af45821407b96e89759ebe748e7a20011633ff Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 15:36:14 -0600 Subject: [PATCH 019/284] fix(yjs): persist saved entities as canonical state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/yjs/server/bootstrap-review-target.ts | 26 ++++++----- apps/tradinggoose/socket-server/index.test.ts | 42 ++++++++++++++++++ .../tradinggoose/socket-server/routes/http.ts | 12 +++-- .../socket-server/yjs/persistence.ts | 44 ++++++++++++++++--- .../socket-server/yjs/ws-handler.ts | 16 ++++--- 5 files changed, 111 insertions(+), 29 deletions(-) diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 33adbedb4..537755e94 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -109,19 +109,21 @@ async function getExistingYjsState(sessionId: string): Promise { - const [{ getDocument, setPersistence }, { getState, storeState }] = await Promise.all([ - import('@/socket-server/yjs/upstream-utils'), - import('@/socket-server/yjs/persistence'), - ]) - - setPersistence(sessionId, { getState, storeState }) +async function getBootstrapDoc(sessionId: string, canonical = false): Promise { + const [{ getDocument, setPersistence }, { getState, storeCanonicalState, storeState }] = + await Promise.all([ + import('@/socket-server/yjs/upstream-utils'), + import('@/socket-server/yjs/persistence'), + ]) + + setPersistence(sessionId, { getState, storeState: canonical ? storeCanonicalState : storeState }) return getDocument(sessionId) } -async function persistDoc(sessionId: string, doc: Y.Doc): Promise { - const { storeState } = await import('@/socket-server/yjs/persistence') - await storeState(sessionId, Y.encodeStateAsUpdate(doc)) +async function persistDoc(sessionId: string, doc: Y.Doc, canonical = false): Promise { + const { storeCanonicalState, storeState } = await import('@/socket-server/yjs/persistence') + const state = Y.encodeStateAsUpdate(doc) + await (canonical ? storeCanonicalState(sessionId, state) : storeState(sessionId, state)) } async function resolveExistingReviewTarget( @@ -245,7 +247,7 @@ async function bootstrapSavedEntityTarget( } const canonical = await loadCanonicalEntitySeed(descriptor) - const doc = await getBootstrapDoc(descriptor.entityId) + const doc = await getBootstrapDoc(descriptor.entityId, true) seedEntitySession(doc, { entityKind: descriptor.entityKind, @@ -256,7 +258,7 @@ async function bootstrapSavedEntityTarget( doc.getMap('metadata').set('reseededFromCanonical', true) }, 'bootstrap') - await persistDoc(descriptor.entityId, doc) + await persistDoc(descriptor.entityId, doc, true) return { descriptor: { diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 181838a8b..375194d41 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -8,6 +8,7 @@ import { io as createClient } from 'socket.io-client' import * as Y from 'yjs' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { createLogger } from '@/lib/logs/console/logger' +import { getEntityFields } from '@/lib/yjs/entity-session' import { extractPersistedStateFromDoc, setVariables, @@ -342,6 +343,47 @@ describe('Socket Server Index Integration', () => { } }) + it('should apply saved entity state as canonical Yjs state', async () => { + const response = await sendHttpRequestWithOptions( + PORT, + '/internal/yjs/entities/skill-1/apply-state', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-secret': INTERNAL_SECRET, + }, + body: JSON.stringify({ + entityKind: 'skill', + fields: { + name: 'Risk Skill', + description: 'Position sizing rules', + content: 'Keep risk below one percent.', + }, + }), + } + ) + + expect(response.statusCode).toBe(200) + expect(await getExistingDocument('skill-1')).toBeNull() + + const persisted = await getState('skill-1') + expect(persisted).toBeTruthy() + + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, persisted!) + expect(getEntityFields(doc, 'skill')).toEqual({ + name: 'Risk Skill', + description: 'Position sizing rules', + content: 'Keep risk below one percent.', + }) + expect(doc.getMap('metadata').get('reseededFromCanonical')).toBeUndefined() + } finally { + doc.destroy() + } + }) + it('should return the internal Yjs workflow snapshot through the generic session route', async () => { const { getRuntimeStateFromDoc, getRuntimeStateFromUpdate } = await import( '@/lib/yjs/server/bootstrap-review-target' diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index e30f5a45e..a238018b9 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -19,7 +19,12 @@ import { type WorkflowSnapshot, } from '@/lib/yjs/workflow-session' import { getMonitorRuntimeLockHealth } from '@/socket-server/monitor-runtime-lock' -import { deleteSession, getState, storeState } from '@/socket-server/yjs/persistence' +import { + deleteSession, + getState, + storeCanonicalState, + storeState, +} from '@/socket-server/yjs/persistence' import { getExistingDocument, removeDocument } from '@/socket-server/yjs/upstream-utils' interface Logger { @@ -231,7 +236,7 @@ function replaceWorkflowDocState( function clearSessionReseededFromCanonical(doc: Y.Doc): void { doc.transact(() => { doc.getMap('metadata').delete('reseededFromCanonical') - }, YJS_ORIGINS.SAVE) + }, YJS_ORIGINS.SYSTEM) } async function handleInternalYjsWorkflowApplyRequest( @@ -282,7 +287,8 @@ async function handleInternalYjsEntityApplyRequest( entityKind: body.entityKind, payload: body.fields, }) - await storeState(entityId, Y.encodeStateAsUpdate(doc)) + clearSessionReseededFromCanonical(doc) + await storeCanonicalState(entityId, Y.encodeStateAsUpdate(doc)) } finally { if (!liveDoc) doc.destroy() } diff --git a/apps/tradinggoose/socket-server/yjs/persistence.ts b/apps/tradinggoose/socket-server/yjs/persistence.ts index fa061bddb..60e41fb11 100644 --- a/apps/tradinggoose/socket-server/yjs/persistence.ts +++ b/apps/tradinggoose/socket-server/yjs/persistence.ts @@ -3,6 +3,7 @@ import { getRedisClient, getRedisStorageMode } from '@/lib/redis' interface YjsSessionBlob { state: Buffer updatedAt: number + expiresAt: number | null } const TTL_MS = 7 * 24 * 60 * 60 * 1000 @@ -21,8 +22,8 @@ function updatedAtKey(sessionId: string): string { return `${REDIS_KEY_PREFIX}${sessionId}:updatedAt` } -function isExpired(updatedAt: number | null): boolean { - return updatedAt == null || Date.now() - updatedAt > TTL_MS +function isExpired(blob: YjsSessionBlob): boolean { + return blob.expiresAt !== null && blob.expiresAt <= Date.now() } async function readRedisUpdatedAt(sessionId: string): Promise { @@ -55,7 +56,7 @@ function readLocalBlob(sessionId: string): YjsSessionBlob | null { return null } - if (isExpired(blob.updatedAt)) { + if (isExpired(blob)) { localStore.delete(sessionId) return null } @@ -105,7 +106,8 @@ export async function storeState(sessionId: string, state: Uint8Array): Promise< // after calling storeState, so sharing the underlying ArrayBuffer is safe. const buf = Buffer.from(state.buffer, state.byteOffset, state.byteLength) - await redis.multi() + await redis + .multi() .set(stateKey(sessionId), buf) .pexpire(stateKey(sessionId), TTL_MS) .set(updatedAtKey(sessionId), String(touchedAt)) @@ -121,12 +123,34 @@ export async function storeState(sessionId: string, state: Uint8Array): Promise< localStore.set(sessionId, { state: Buffer.from(state), updatedAt: touchedAt, + expiresAt: touchedAt + TTL_MS, }) // Evict oldest entries if over the limit while (localStore.size > MAX_LOCAL_ENTRIES) { - const oldest = localStore.keys().next().value + const oldest = Array.from(localStore.entries()).find(([, blob]) => blob.expiresAt !== null)?.[0] if (oldest) localStore.delete(oldest) + else break + } +} + +export async function storeCanonicalState(sessionId: string, state: Uint8Array): Promise { + await storeState(sessionId, state) + + const mode = getRedisStorageMode() + if (mode === 'redis') { + const redis = getRedisClient() + if (!redis) { + return + } + + await redis.multi().persist(stateKey(sessionId)).persist(updatedAtKey(sessionId)).exec() + return + } + + const blob = localStore.get(sessionId) + if (blob) { + blob.expiresAt = null } } @@ -168,7 +192,13 @@ export async function getLastTouchedAt(sessionId: string): Promise TTL_MS))) { await cleanupExpiredRedisSession(sessionId) return null } @@ -196,7 +226,7 @@ if (getRedisStorageMode() !== 'redis') { ttlSweepInterval = setInterval(() => { const now = Date.now() for (const [key, blob] of localStore) { - if (now - blob.updatedAt > TTL_MS) { + if (blob.expiresAt !== null && blob.expiresAt <= now) { localStore.delete(key) } } diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.ts index 93c5b46df..8b7eba12e 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.ts @@ -1,9 +1,7 @@ import type { IncomingMessage } from 'http' import type { Duplex } from 'stream' import type { WebSocket, WebSocketServer } from 'ws' -import { - buildReviewTargetDescriptorFromEnvelope, -} from '@/lib/copilot/review-sessions/identity' +import { buildReviewTargetDescriptorFromEnvelope } from '@/lib/copilot/review-sessions/identity' import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' import { createLogger } from '@/lib/logs/console/logger' import { @@ -11,7 +9,7 @@ import { getRuntimeStateFromUpdate, } from '@/lib/yjs/server/bootstrap-review-target' import { authenticateYjsConnection, YjsAuthError } from './auth' -import { getState, storeState } from './persistence' +import { getState, storeCanonicalState, storeState } from './persistence' import { getExistingDocument, setPersistence, setupWSConnection } from './upstream-utils' const logger = createLogger('YjsWsHandler') @@ -39,8 +37,11 @@ export function handleYjsUpgrade( const yjsSessionId = decodeURIComponent(match[1]) void authenticateAndPrepareUpgrade(yjsSessionId, url) - .then(({ userId, resolvedSessionId }) => { - setPersistence(resolvedSessionId, { getState, storeState }) + .then(({ userId, resolvedSessionId, canonical }) => { + setPersistence(resolvedSessionId, { + getState, + storeState: canonical ? storeCanonicalState : storeState, + }) const yjsReq = request as YjsIncomingMessage yjsReq.yjsSessionId = resolvedSessionId @@ -65,7 +66,7 @@ export function handleYjsUpgrade( async function authenticateAndPrepareUpgrade( pathSessionId: string, url: URL -): Promise<{ userId: string; resolvedSessionId: string }> { +): Promise<{ userId: string; resolvedSessionId: string; canonical: boolean }> { const accessMode = parseAccessMode(url) const { userId, envelope } = await authenticateYjsConnection(url) @@ -113,6 +114,7 @@ async function authenticateAndPrepareUpgrade( return { userId, resolvedSessionId: pathSessionId, + canonical: descriptor.entityKind !== 'workflow' && descriptor.entityId !== null, } } From c6bdfd592dc96a14e0fecb087ba0345109063d7f Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 15:36:36 -0600 Subject: [PATCH 020/284] style: normalize hook and test formatting Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .husky/pre-commit | 2 +- apps/tradinggoose/socket-server/index.test.ts | 16 ++++++---------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index f54fc9cd5..ea5a55b6f 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -bunx lint-staged \ No newline at end of file +bunx lint-staged diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 375194d41..e237e858d 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -600,16 +600,12 @@ describe('Socket Server Index Integration', () => { ) await storeState('workflow-2', Y.encodeStateAsUpdate(liveDoc!)) - const response = await sendHttpRequestWithOptions( - PORT, - '/internal/yjs/sessions/workflow-2', - { - method: 'DELETE', - headers: { - 'x-internal-secret': INTERNAL_SECRET, - }, - } - ) + const response = await sendHttpRequestWithOptions(PORT, '/internal/yjs/sessions/workflow-2', { + method: 'DELETE', + headers: { + 'x-internal-secret': INTERNAL_SECRET, + }, + }) expect(response.statusCode).toBe(200) expect(await getExistingDocument('workflow-2')).toBeNull() From a5d59573bf673f02ea32b3118e8d91c2eade2605 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 16:24:50 -0600 Subject: [PATCH 021/284] fix(mcp): configure Codex auth with headers Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/mcp/[[...command]]/route.test.ts | 23 ++-- apps/tradinggoose/lib/mcp/install-script.ts | 4 +- .../mcp/local-config-writer-script.test.ts | 39 ++----- .../lib/mcp/local-config-writer-script.ts | 106 ++---------------- 4 files changed, 36 insertions(+), 136 deletions(-) diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts index fc1e581b8..4a11a466e 100644 --- a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts @@ -54,9 +54,11 @@ describe('MCP install route', () => { expect(script).toContain('node - "$BASE_URL" "$COMMAND" "$TARGETS"') expect(script).toContain('runConfigWriter([target, mcpUrl, login.token])') expect(script).toContain("const mcpServerName = 'TradingGoose'") - expect(script).toContain("const codexBearerTokenEnvVar = 'TRADINGGOOSE_BEARER_TOKEN'") - expect(script).toContain("'bearer_token_env_var = ' + JSON.stringify(codexBearerTokenEnvVar)") - expect(script).toContain("spawnSync('setx', [codexBearerTokenEnvVar, token]") + expect(script).toContain("'[mcp_servers.' + mcpServerName + '.http_headers]'") + expect(script).toContain("'Authorization = ' + JSON.stringify('Bearer ' + token)") + expect(script).not.toContain('TRADINGGOOSE_BEARER_TOKEN') + expect(script).not.toContain('bearer_token_env_var') + expect(script).not.toContain("spawnSync('setx'") expect(script).toContain("path.join(os.homedir(), '.codex', 'config.toml')") expect(script).toContain("path.join(os.homedir(), '.cursor', 'mcp.json')") expect(script).toContain("path.join(os.homedir(), '.claude.json')") @@ -67,17 +69,18 @@ describe('MCP install route', () => { const printedTokenIndex = script.indexOf("console.log('Authorization: Bearer ' + login.token)") const firstConfirmIndex = script.indexOf('await confirmLogin(login)') const firstRevokeIndex = script.indexOf('await revokeTokens(existingTokens, login.token)') + const setupIndex = script.indexOf("if (command === 'setup')") + const setupConfirmIndex = script.indexOf('await confirmLogin(login)', setupIndex) const configWriteIndex = script.indexOf( 'const configPath = runConfigWriter([target, mcpUrl, login.token])' ) - const setupConfirmIndex = script.indexOf('await confirmLogin(login)', configWriteIndex) const setupRevokeIndex = script.indexOf( 'await revokeTokens(existingTokens, login.token)', configWriteIndex ) - expect(firstConfirmIndex).toBeGreaterThan(printedTokenIndex) + expect(printedTokenIndex).toBeGreaterThan(firstConfirmIndex) expect(firstRevokeIndex).toBeGreaterThan(firstConfirmIndex) - expect(setupConfirmIndex).toBeGreaterThan(configWriteIndex) + expect(configWriteIndex).toBeGreaterThan(setupConfirmIndex) expect(setupRevokeIndex).toBeGreaterThan(setupConfirmIndex) expect(setupRevokeIndex).toBeGreaterThan(configWriteIndex) }) @@ -106,9 +109,11 @@ describe('MCP install route', () => { expect(script).toContain("baseUrl + '/api/auth/mcp/start'") expect(script).toContain("runConfigWriter(['read-tokens'])") expect(script).toContain("const mcpServerName = 'TradingGoose'") - expect(script).toContain("const codexBearerTokenEnvVar = 'TRADINGGOOSE_BEARER_TOKEN'") - expect(script).toContain("'bearer_token_env_var = ' + JSON.stringify(codexBearerTokenEnvVar)") - expect(script).toContain("spawnSync('setx', [codexBearerTokenEnvVar, token]") + expect(script).toContain("'[mcp_servers.' + mcpServerName + '.http_headers]'") + expect(script).toContain("'Authorization = ' + JSON.stringify('Bearer ' + token)") + expect(script).not.toContain('TRADINGGOOSE_BEARER_TOKEN') + expect(script).not.toContain('bearer_token_env_var') + expect(script).not.toContain("spawnSync('setx'") expect(script).not.toContain('#!/bin/sh') }) diff --git a/apps/tradinggoose/lib/mcp/install-script.ts b/apps/tradinggoose/lib/mcp/install-script.ts index 161b3ad6c..167c27f70 100644 --- a/apps/tradinggoose/lib/mcp/install-script.ts +++ b/apps/tradinggoose/lib/mcp/install-script.ts @@ -161,6 +161,7 @@ async function main() { if (command === 'login') { const existingTokens = readExistingTokens() const login = await authenticate() + await confirmLogin(login) console.log('MCP endpoint:') console.log(mcpUrl) console.log('') @@ -169,7 +170,6 @@ async function main() { console.log('') console.log('Use this MCP auth header:') console.log('Authorization: Bearer ' + login.token) - await confirmLogin(login) await revokeTokens(existingTokens, login.token) return } @@ -181,12 +181,12 @@ async function main() { const existingTokens = readExistingTokens() const login = await authenticate() + await confirmLogin(login) console.log('Using MCP endpoint: ' + mcpUrl) for (const target of targets) { const configPath = runConfigWriter([target, mcpUrl, login.token]) console.log('Configured ' + target + ': ' + configPath) } - await confirmLogin(login) await revokeTokens(existingTokens, login.token) return } diff --git a/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts b/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts index 27e9a9aa7..48579b687 100644 --- a/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts +++ b/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts @@ -9,9 +9,7 @@ import { join } from 'path' import { describe, expect, it } from 'vitest' import { MCP_LOCAL_CONFIG_WRITER_SCRIPT } from './local-config-writer-script' -type TestEnv = Record - -function runWriter(home: string, args: string[], env: TestEnv = {}) { +function runWriter(home: string, args: string[]) { const scriptPath = join(home, 'writer.js') writeFileSync(scriptPath, MCP_LOCAL_CONFIG_WRITER_SCRIPT, 'utf8') const result = spawnSync('node', [scriptPath, ...args], { @@ -21,7 +19,6 @@ function runWriter(home: string, args: string[], env: TestEnv = {}) { ...process.env, HOME: home, USERPROFILE: home, - ...env, }, input: MCP_LOCAL_CONFIG_WRITER_SCRIPT, timeout: 5000, @@ -36,7 +33,7 @@ function shellQuote(value: string) { return `'${value.replaceAll("'", "'\\''")}'` } -function runWriterCapture(home: string, args: string[], env: TestEnv = {}) { +function runWriterCapture(home: string, args: string[]) { const scriptPath = join(home, 'writer.js') const outputPath = join(home, 'writer.out') writeFileSync(scriptPath, MCP_LOCAL_CONFIG_WRITER_SCRIPT, 'utf8') @@ -48,7 +45,6 @@ function runWriterCapture(home: string, args: string[], env: TestEnv = {}) { ...process.env, HOME: home, USERPROFILE: home, - ...env, }, timeout: 5000, }) @@ -59,32 +55,22 @@ function runWriterCapture(home: string, args: string[], env: TestEnv = {}) { } describe('MCP local config writer script', () => { - it('writes Codex config with a TradingGoose bearer token environment variable', () => { + it('writes Codex config with TradingGoose HTTP headers', () => { const home = mkdtempSync(join(tmpdir(), 'tg-mcp-codex-')) - runWriter(home, ['codex', 'http://localhost:3000/api/copilot/mcp', 'mcp-token'], { - SHELL: '/bin/zsh', - }) + runWriter(home, ['codex', 'http://localhost:3000/api/copilot/mcp', 'mcp-token']) const configPath = join(home, '.codex', 'config.toml') expect(readFileSync(configPath, 'utf8')).toBe( [ '[mcp_servers.TradingGoose]', 'url = "http://localhost:3000/api/copilot/mcp"', - 'bearer_token_env_var = "TRADINGGOOSE_BEARER_TOKEN"', + '', + '[mcp_servers.TradingGoose.http_headers]', + 'Authorization = "Bearer mcp-token"', '', ].join('\n') ) - expect(readFileSync(join(home, '.codex', 'tradinggoose-mcp.env'), 'utf8')).toBe( - "export TRADINGGOOSE_BEARER_TOKEN='mcp-token'\n" - ) - expect(readFileSync(join(home, '.zshrc'), 'utf8')).toBe( - `[ -f '${join(home, '.codex', 'tradinggoose-mcp.env')}' ] && . '${join( - home, - '.codex', - 'tradinggoose-mcp.env' - )}'\n` - ) }) it('replaces the canonical Codex config while preserving other servers', () => { @@ -108,18 +94,17 @@ describe('MCP local config writer script', () => { const config = readFileSync(configPath, 'utf8') expect(config.match(/\[mcp_servers\.TradingGoose\]/g)).toHaveLength(1) - expect(config).toContain('bearer_token_env_var = "TRADINGGOOSE_BEARER_TOKEN"') + expect(config).toContain('[mcp_servers.TradingGoose.http_headers]') + expect(config).toContain('Authorization = "Bearer new-token"') expect(config).toContain('[mcp_servers.other]') - expect(config).not.toContain('Authorization = "Bearer') + expect(config).not.toContain('bearer_token_env_var') }) - it('reads Codex bearer token from durable local state after setup', () => { + it('reads Codex bearer token from the configured HTTP headers', () => { const home = mkdtempSync(join(tmpdir(), 'tg-mcp-codex-token-')) runWriter(home, ['codex', 'http://localhost:3000/api/copilot/mcp', 'existing-token']) - const stdout = runWriterCapture(home, ['read-tokens'], { - TRADINGGOOSE_BEARER_TOKEN: undefined, - }) + const stdout = runWriterCapture(home, ['read-tokens']) expect(stdout.trim()).toBe('existing-token') }) diff --git a/apps/tradinggoose/lib/mcp/local-config-writer-script.ts b/apps/tradinggoose/lib/mcp/local-config-writer-script.ts index 11d26853c..e709488f7 100644 --- a/apps/tradinggoose/lib/mcp/local-config-writer-script.ts +++ b/apps/tradinggoose/lib/mcp/local-config-writer-script.ts @@ -5,10 +5,8 @@ const path = require('path') const target = process.argv[2] const mcpUrl = process.argv[3] const token = process.argv[4] -const authHeaders = { Authorization: 'Bearer ' + token } const allTargets = ['codex', 'cursor', 'claude', 'opencode'] const mcpServerName = 'TradingGoose' -const codexBearerTokenEnvVar = 'TRADINGGOOSE_BEARER_TOKEN' function resolvePathFor(candidate) { switch (candidate) { @@ -38,7 +36,9 @@ function writeCodexConfig(filePath) { const block = [ '[mcp_servers.' + mcpServerName + ']', 'url = ' + JSON.stringify(mcpUrl), - 'bearer_token_env_var = ' + JSON.stringify(codexBearerTokenEnvVar), + '', + '[mcp_servers.' + mcpServerName + '.http_headers]', + 'Authorization = ' + JSON.stringify('Bearer ' + token), '', ].join('\n') const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '' @@ -50,55 +50,6 @@ function writeCodexConfig(filePath) { ) } -function resolveCodexBearerTokenFilePath() { - return path.join(os.homedir(), '.codex', 'tradinggoose-mcp.env') -} - -function shellSingleQuote(value) { - return "'" + value.replaceAll("'", "'\\''") + "'" -} - -function writeCodexBearerTokenFile() { - const filePath = resolveCodexBearerTokenFilePath() - ensureParent(filePath) - fs.writeFileSync( - filePath, - 'export ' + codexBearerTokenEnvVar + '=' + shellSingleQuote(token) + '\n', - 'utf8' - ) - fs.chmodSync(filePath, 0o600) - return filePath -} - -function readCodexBearerTokenFile() { - const filePath = resolveCodexBearerTokenFilePath() - const match = (fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '').match( - new RegExp("^export " + codexBearerTokenEnvVar + "='(.+)'$", 'm') - ) - return match ? match[1].replaceAll("'\\''", "'") : null -} - -function resolveShellProfilePath() { - const shellName = path.basename(process.env.SHELL || '') - const fileName = shellName === 'zsh' ? '.zshrc' : shellName === 'bash' ? '.bashrc' : '.profile' - return path.join(os.homedir(), fileName) -} - -function sourceCodexBearerTokenFileFromShellProfile(filePath) { - const profilePath = resolveShellProfilePath() - const sourceLine = '[ -f ' + shellSingleQuote(filePath) + ' ] && . ' + shellSingleQuote(filePath) - const current = fs.existsSync(profilePath) ? fs.readFileSync(profilePath, 'utf8') : '' - if (current.split(/\r?\n/).includes(sourceLine)) { - return - } - - fs.writeFileSync( - profilePath, - current.trim() ? current.replace(/\s*$/, '') + '\n' + sourceLine + '\n' : sourceLine + '\n', - 'utf8' - ) -} - function removeTomlMcpServerBlock(current) { const sectionHeader = '[mcp_servers.' + mcpServerName + ']' const subPrefix = '[mcp_servers.' + mcpServerName + '.' @@ -164,16 +115,12 @@ function readCodexToken(filePath) { return null } const text = fs.readFileSync(filePath, 'utf8') - const section = findTomlMcpServerSection(text) - if (!section) { - return null - } - const envVar = section.match(/\nbearer_token_env_var\s*=\s*["']([^"']+)["']/) - return envVar ? readEnvironmentVariable(envVar[1]) : null + const section = findTomlSection(text, '[mcp_servers.' + mcpServerName + '.http_headers]') + const authHeader = section?.match(/(?:^|\n)Authorization\s*=\s*["']([^"']+)["']/) + return authHeader ? bearerTokenFromHeader(authHeader[1]) : null } -function findTomlMcpServerSection(text) { - const sectionHeader = '[mcp_servers.' + mcpServerName + ']' +function findTomlSection(text, sectionHeader) { const startIndex = text.indexOf(sectionHeader) if (startIndex === -1) { return null @@ -183,43 +130,6 @@ function findTomlMcpServerSection(text) { return nextHeaderIndex === -1 ? rest : rest.slice(0, nextHeaderIndex) } -function persistCodexBearerToken() { - if (process.platform === 'win32') { - const { spawnSync } = require('child_process') - const result = spawnSync('setx', [codexBearerTokenEnvVar, token], { stdio: 'ignore' }) - if (result.status !== 0) { - throw new Error('Failed to persist ' + codexBearerTokenEnvVar) - } - return - } - - const tokenFilePath = writeCodexBearerTokenFile() - sourceCodexBearerTokenFileFromShellProfile(tokenFilePath) -} - -function readEnvironmentVariable(name) { - if (process.env[name]) { - return process.env[name] - } - if (process.platform !== 'win32') { - return name === codexBearerTokenEnvVar ? readCodexBearerTokenFile() : null - } - - const { spawnSync } = require('child_process') - const result = spawnSync( - 'powershell.exe', - [ - '-NoProfile', - '-NonInteractive', - '-Command', - '$Value = [Environment]::GetEnvironmentVariable($args[0], [EnvironmentVariableTarget]::User); if ($Value) { [Console]::Out.Write($Value) }', - name, - ], - { encoding: 'utf8' } - ) - return result.status === 0 && result.stdout ? result.stdout : null -} - function readJsonToken(filePath, section) { const config = readJson(filePath) return bearerTokenFromHeader(config?.[section]?.[mcpServerName]?.headers?.Authorization) @@ -253,9 +163,9 @@ if (target === 'read-tokens') { } const filePath = resolvePath() +const authHeaders = { Authorization: 'Bearer ' + token } switch (target) { case 'codex': - persistCodexBearerToken() writeCodexConfig(filePath) break case 'cursor': From 4f8af32eeafe0fe5e2a4e5d66fdac90402a66f74 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 16:25:07 -0600 Subject: [PATCH 022/284] refactor(yjs): require saved entity yjs state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/yjs/entity-state.ts | 8 +- .../lib/yjs/server/bootstrap-review-target.ts | 171 ++---------------- .../lib/yjs/server/entity-loaders.ts | 62 ------- 3 files changed, 17 insertions(+), 224 deletions(-) diff --git a/apps/tradinggoose/lib/yjs/entity-state.ts b/apps/tradinggoose/lib/yjs/entity-state.ts index e7f2e3286..9df98729b 100644 --- a/apps/tradinggoose/lib/yjs/entity-state.ts +++ b/apps/tradinggoose/lib/yjs/entity-state.ts @@ -160,7 +160,7 @@ export async function readSavedEntityFieldsFromYjs( entityKind: SavedEntityKind, entityId: string, workspaceId: string -): Promise | null> { +): Promise> { try { const descriptor = buildSavedEntityYjsDescriptor(entityKind, entityId, workspaceId) const snapshot = await getYjsSnapshot( @@ -169,7 +169,7 @@ export async function readSavedEntityFieldsFromYjs( ) if (!snapshot.snapshotBase64) { - return null + throw new Error(`Saved ${entityKind} Yjs state is empty for ${entityId}`) } const doc = new Y.Doc() @@ -181,7 +181,7 @@ export async function readSavedEntityFieldsFromYjs( } } catch (error) { if (error instanceof SocketServerBridgeError && error.status === 404) { - return null + throw new Error(`Saved ${entityKind} Yjs state is missing for ${entityId}`) } throw error } @@ -196,7 +196,7 @@ export async function applySavedEntityYjsStateToRow( } const fields = await readSavedEntityFieldsFromYjs(entityKind, row.id, row.workspaceId) - return fields ? applySavedEntityFieldsToRow(entityKind, row, fields) : row + return applySavedEntityFieldsToRow(entityKind, row, fields) } export async function applySavedEntityYjsStateToRows( diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 537755e94..7839fed8f 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -13,14 +13,6 @@ import type { ReviewTargetRuntimeState, } from '@/lib/copilot/review-sessions/types' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' -import { seedEntitySession } from '@/lib/yjs/entity-session' -import { - loadCustomTool, - loadIndicator, - loadKnowledgeBase, - loadMcpServer, - loadSkill, -} from '@/lib/yjs/server/entity-loaders' import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' import { @@ -109,21 +101,20 @@ async function getExistingYjsState(sessionId: string): Promise { - const [{ getDocument, setPersistence }, { getState, storeCanonicalState, storeState }] = - await Promise.all([ - import('@/socket-server/yjs/upstream-utils'), - import('@/socket-server/yjs/persistence'), - ]) +async function getBootstrapDoc(sessionId: string): Promise { + const [{ getDocument, setPersistence }, { getState, storeState }] = await Promise.all([ + import('@/socket-server/yjs/upstream-utils'), + import('@/socket-server/yjs/persistence'), + ]) - setPersistence(sessionId, { getState, storeState: canonical ? storeCanonicalState : storeState }) + setPersistence(sessionId, { getState, storeState }) return getDocument(sessionId) } -async function persistDoc(sessionId: string, doc: Y.Doc, canonical = false): Promise { - const { storeCanonicalState, storeState } = await import('@/socket-server/yjs/persistence') +async function persistDoc(sessionId: string, doc: Y.Doc): Promise { + const { storeState } = await import('@/socket-server/yjs/persistence') const state = Y.encodeStateAsUpdate(doc) - await (canonical ? storeCanonicalState(sessionId, state) : storeState(sessionId, state)) + await storeState(sessionId, state) } async function resolveExistingReviewTarget( @@ -142,8 +133,9 @@ async function resolveExistingReviewTarget( /** * Ensures a review target has an active Yjs document. If an active blob already - * exists it is reused; saved targets are reseeded from canonical data on loss; - * unsaved drafts return the explicit expired state. + * exists it is reused; workflows can be bootstrapped from normalized workflow + * tables; saved non-workflow entities require canonical Yjs state and unsaved + * drafts return the explicit expired state. */ export async function bootstrapReviewTarget( descriptor: ReviewTargetDescriptor @@ -158,7 +150,7 @@ export async function bootstrapReviewTarget( } if (descriptor.entityId) { - return bootstrapSavedEntityTarget(descriptor) + throw new ReviewTargetBootstrapError(404, 'Saved entity Yjs state is missing') } return { @@ -234,140 +226,3 @@ async function bootstrapWorkflowTarget( runtime: ACTIVE_RESEEDED_RUNTIME, } } - -async function bootstrapSavedEntityTarget( - descriptor: ReviewTargetDescriptor -): Promise { - if (!descriptor.entityId) { - throw new ReviewTargetBootstrapError(409, 'Saved entity target is missing entityId') - } - - if (!descriptor.workspaceId) { - throw new ReviewTargetBootstrapError(409, 'Saved entity target is missing workspaceId') - } - - const canonical = await loadCanonicalEntitySeed(descriptor) - const doc = await getBootstrapDoc(descriptor.entityId, true) - - seedEntitySession(doc, { - entityKind: descriptor.entityKind, - payload: canonical.payload, - }) - - doc.transact(() => { - doc.getMap('metadata').set('reseededFromCanonical', true) - }, 'bootstrap') - - await persistDoc(descriptor.entityId, doc, true) - - return { - descriptor: { - ...descriptor, - workspaceId: canonical.workspaceId, - entityId: descriptor.entityId, - reviewSessionId: null, - yjsSessionId: descriptor.entityId, - }, - runtime: ACTIVE_RESEEDED_RUNTIME, - } -} - -async function loadCanonicalEntitySeed(descriptor: ReviewTargetDescriptor): Promise<{ - workspaceId: string - payload: Record -}> { - switch (descriptor.entityKind) { - case 'skill': { - const row = await loadSkill(descriptor.entityId!, descriptor.workspaceId!) - if (!row) { - throw new ReviewTargetBootstrapError(404, 'Skill target no longer exists') - } - - return { - workspaceId: row.workspaceId, - payload: { - name: row.name, - description: row.description, - content: row.content, - }, - } - } - case 'custom_tool': { - const row = await loadCustomTool(descriptor.entityId!, descriptor.workspaceId!) - if (!row) { - throw new ReviewTargetBootstrapError(404, 'Custom tool target no longer exists') - } - - return { - workspaceId: row.workspaceId, - payload: { - title: row.title, - schemaText: - typeof row.schema === 'string' ? row.schema : JSON.stringify(row.schema ?? {}, null, 2), - codeText: row.code, - }, - } - } - case 'indicator': { - const row = await loadIndicator(descriptor.entityId!, descriptor.workspaceId!) - if (!row) { - throw new ReviewTargetBootstrapError(404, 'Indicator target no longer exists') - } - - return { - workspaceId: row.workspaceId, - payload: { - name: row.name, - color: row.color, - pineCode: row.pineCode, - inputMeta: row.inputMeta, - }, - } - } - case 'knowledge_base': { - const row = await loadKnowledgeBase(descriptor.entityId!, descriptor.workspaceId!) - if (!row) { - throw new ReviewTargetBootstrapError(404, 'Knowledge base target no longer exists') - } - - return { - workspaceId: row.workspaceId, - payload: { - name: row.name, - description: row.description ?? '', - chunkingConfig: row.chunkingConfig, - }, - } - } - case 'mcp_server': { - const row = await loadMcpServer(descriptor.entityId!, descriptor.workspaceId!) - if (!row) { - throw new ReviewTargetBootstrapError(404, 'MCP server target no longer exists') - } - - return { - workspaceId: row.workspaceId, - payload: { - name: row.name, - description: row.description ?? '', - transport: row.transport, - url: row.url ?? '', - headers: - row.headers && typeof row.headers === 'object' && !Array.isArray(row.headers) - ? row.headers - : {}, - command: row.command ?? '', - args: Array.isArray(row.args) ? row.args : [], - env: row.env && typeof row.env === 'object' && !Array.isArray(row.env) ? row.env : {}, - timeout: row.timeout ?? 30000, - retries: row.retries ?? 3, - enabled: row.enabled ?? true, - }, - } - } - case 'workflow': - throw new ReviewTargetBootstrapError(409, 'Workflow targets must use workflow bootstrap') - default: - throw new ReviewTargetBootstrapError(409, 'Unsupported review target') - } -} diff --git a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts index 4198d4810..8b48522e9 100644 --- a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts +++ b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts @@ -56,65 +56,3 @@ export async function resolveEntityWorkspaceId( } } } - -export async function loadSkill(entityId: string, workspaceId: string) { - const [row] = await db - .select() - .from(skill) - .where(and(eq(skill.id, entityId), eq(skill.workspaceId, workspaceId))) - .limit(1) - - return row ?? null -} - -export async function loadCustomTool(entityId: string, workspaceId: string) { - const [row] = await db - .select() - .from(customTools) - .where(and(eq(customTools.id, entityId), eq(customTools.workspaceId, workspaceId))) - .limit(1) - - return row ?? null -} - -export async function loadIndicator(entityId: string, workspaceId: string) { - const [row] = await db - .select() - .from(pineIndicators) - .where(and(eq(pineIndicators.id, entityId), eq(pineIndicators.workspaceId, workspaceId))) - .limit(1) - - return row ?? null -} - -export async function loadKnowledgeBase(entityId: string, workspaceId: string) { - const [row] = await db - .select() - .from(knowledgeBase) - .where( - and( - eq(knowledgeBase.id, entityId), - eq(knowledgeBase.workspaceId, workspaceId), - isNull(knowledgeBase.deletedAt) - ) - ) - .limit(1) - - return row ?? null -} - -export async function loadMcpServer(entityId: string, workspaceId: string) { - const [row] = await db - .select() - .from(mcpServers) - .where( - and( - eq(mcpServers.id, entityId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) - ) - .limit(1) - - return row ?? null -} From 627b5edd385b01fc94dc5941cb527b5802a36930 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 16:43:41 -0600 Subject: [PATCH 023/284] refactor(yjs): centralize saved entity state seeding Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/custom-tools/operations.ts | 15 +++++++-------- .../lib/indicators/custom/operations.ts | 15 +++++++-------- apps/tradinggoose/lib/skills/operations.ts | 10 ++++------ apps/tradinggoose/lib/yjs/entity-state.ts | 12 ++++++++++++ 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index cc65cc8d0..0885a61bf 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -8,8 +8,10 @@ import { } from '@/lib/custom-tools/import-export' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityYjsStateToRows, savedEntityRowToFields } from '@/lib/yjs/entity-state' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { + applySavedEntityYjsStateToRows, + seedSavedEntityYjsStateFromRows, +} from '@/lib/yjs/entity-state' const logger = createLogger('CustomToolsOperations') @@ -112,12 +114,9 @@ export async function upsertCustomTools({ .orderBy(desc(customTools.createdAt)) }) - await Promise.all( - result - .filter((row) => affectedIds.includes(row.id)) - .map((row) => - applySavedEntityState('custom_tool', row.id, savedEntityRowToFields('custom_tool', row)) - ) + await seedSavedEntityYjsStateFromRows( + 'custom_tool', + result.filter((row) => affectedIds.includes(row.id)) ) return applySavedEntityYjsStateToRows('custom_tool', result) diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index a6ac38913..3abd640f7 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -9,8 +9,10 @@ import { import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityYjsStateToRows, savedEntityRowToFields } from '@/lib/yjs/entity-state' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { + applySavedEntityYjsStateToRows, + seedSavedEntityYjsStateFromRows, +} from '@/lib/yjs/entity-state' const logger = createLogger('IndicatorsOperations') @@ -111,12 +113,9 @@ export async function upsertIndicators({ .orderBy(desc(pineIndicators.createdAt)) }) - await Promise.all( - result - .filter((row) => affectedIds.includes(row.id)) - .map((row) => - applySavedEntityState('indicator', row.id, savedEntityRowToFields('indicator', row)) - ) + await seedSavedEntityYjsStateFromRows( + 'indicator', + result.filter((row) => affectedIds.includes(row.id)) ) return applySavedEntityYjsStateToRows('indicator', result) diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index 4c10edcb0..352776bae 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -11,9 +11,8 @@ import { import { generateRequestId } from '@/lib/utils' import { applySavedEntityYjsStateToRows, - savedEntityRowToFields, + seedSavedEntityYjsStateFromRows, } from '@/lib/yjs/entity-state' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('SkillsOperations') @@ -160,10 +159,9 @@ export async function upsertSkills({ .orderBy(desc(skill.createdAt)) }) - await Promise.all( - result - .filter((row) => affectedIds.includes(row.id)) - .map((row) => applySavedEntityState('skill', row.id, savedEntityRowToFields('skill', row))) + await seedSavedEntityYjsStateFromRows( + 'skill', + result.filter((row) => affectedIds.includes(row.id)) ) return applySavedEntityYjsStateToRows('skill', result) diff --git a/apps/tradinggoose/lib/yjs/entity-state.ts b/apps/tradinggoose/lib/yjs/entity-state.ts index 9df98729b..8fa8219fd 100644 --- a/apps/tradinggoose/lib/yjs/entity-state.ts +++ b/apps/tradinggoose/lib/yjs/entity-state.ts @@ -5,6 +5,7 @@ import { } from '@/lib/copilot/review-sessions/identity' import type { ReviewEntityKind, ReviewTargetDescriptor } from '@/lib/copilot/review-sessions/types' import { getEntityFields } from '@/lib/yjs/entity-session' +import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' export type SavedEntityKind = Exclude @@ -205,3 +206,14 @@ export async function applySavedEntityYjsStateToRows( ): Promise { return Promise.all(rows.map((row) => applySavedEntityYjsStateToRow(entityKind, row))) } + +export async function seedSavedEntityYjsStateFromRows( + entityKind: SavedEntityKind, + rows: T[] +): Promise { + await Promise.all( + rows.map((row) => + applySavedEntityState(entityKind, row.id, savedEntityRowToFields(entityKind, row)) + ) + ) +} From c0c0d03f5142224f97bbf8cd3dd3fcf2a6cad68f Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 16:44:18 -0600 Subject: [PATCH 024/284] fix(imports): seed saved entity yjs state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/custom-tools/operations.ts | 6 +++++- .../lib/indicators/custom/operations.ts | 6 +++++- .../tradinggoose/lib/skills/operations.test.ts | 18 +++++++++++++++++- apps/tradinggoose/lib/skills/operations.ts | 6 +++++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index 0885a61bf..1a8047b3c 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -128,7 +128,7 @@ export async function importCustomTools({ userId, requestId = generateRequestId(), }: ImportCustomToolsParams) { - return await db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const existingTools = await tx .select({ title: customTools.title, @@ -168,4 +168,8 @@ export async function importCustomTools({ renamedCount, } }) + + await seedSavedEntityYjsStateFromRows('custom_tool', result.tools) + + return result } diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index 3abd640f7..419bbee83 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -127,7 +127,7 @@ export async function importIndicators({ userId, requestId = generateRequestId(), }: ImportIndicatorsParams) { - return await db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const existingIndicators = await tx .select({ name: pineIndicators.name }) .from(pineIndicators) @@ -173,4 +173,8 @@ export async function importIndicators({ renamedCount, } }) + + await seedSavedEntityYjsStateFromRows('indicator', result.indicators) + + return result } diff --git a/apps/tradinggoose/lib/skills/operations.test.ts b/apps/tradinggoose/lib/skills/operations.test.ts index 2fdf2a0dc..662c8b966 100644 --- a/apps/tradinggoose/lib/skills/operations.test.ts +++ b/apps/tradinggoose/lib/skills/operations.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockTransaction, mockNanoid } = vi.hoisted(() => ({ +const { mockTransaction, mockNanoid, mockApplySavedEntityState } = vi.hoisted(() => ({ mockTransaction: vi.fn(), mockNanoid: vi.fn(), + mockApplySavedEntityState: vi.fn(), })) vi.mock('@tradinggoose/db', () => ({ @@ -30,6 +31,10 @@ vi.mock('nanoid', () => ({ nanoid: (...args: unknown[]) => mockNanoid(...args), })) +vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ + applySavedEntityState: (...args: unknown[]) => mockApplySavedEntityState(...args), +})) + import { importSkills } from '@/lib/skills/operations' const createQueryChain = (result: unknown) => { @@ -143,5 +148,16 @@ describe('skills import operations', () => { ]) expect(result.importedCount).toBe(2) expect(result.renamedCount).toBe(1) + expect(mockApplySavedEntityState).toHaveBeenCalledTimes(2) + expect(mockApplySavedEntityState).toHaveBeenCalledWith('skill', 'skill-b', { + name: 'Execution Plan (imported) 1', + description: 'Create the execution plan.', + content: 'Follow the checklist.', + }) + expect(mockApplySavedEntityState).toHaveBeenCalledWith('skill', 'skill-a', { + name: 'Market Research', + description: 'Research the market.', + content: 'Review catalysts.', + }) }) }) diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index 352776bae..1606be40a 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -173,7 +173,7 @@ export async function importSkills({ userId, requestId = generateRequestId(), }: ImportSkillsParams) { - return await db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const existingNames = await tx .select({ name: skill.name }) .from(skill) @@ -226,4 +226,8 @@ export async function importSkills({ renamedCount, } }) + + await seedSavedEntityYjsStateFromRows('skill', result.skills) + + return result } From d6bb009e59f35280557201a5149eec070b0b62af Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 17:37:15 -0600 Subject: [PATCH 025/284] fix(mcp): reuse valid install tokens Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/auth/mcp/revoke/route.test.ts | 67 ------------------- .../app/api/auth/mcp/revoke/route.ts | 25 ------- .../app/mcp/[[...command]]/route.test.ts | 30 ++++----- apps/tradinggoose/lib/mcp/auth.ts | 28 +------- apps/tradinggoose/lib/mcp/install-script.ts | 62 +++++++++++------ 5 files changed, 54 insertions(+), 158 deletions(-) delete mode 100644 apps/tradinggoose/app/api/auth/mcp/revoke/route.test.ts delete mode 100644 apps/tradinggoose/app/api/auth/mcp/revoke/route.ts diff --git a/apps/tradinggoose/app/api/auth/mcp/revoke/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/revoke/route.test.ts deleted file mode 100644 index e24fca868..000000000 --- a/apps/tradinggoose/app/api/auth/mcp/revoke/route.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * @vitest-environment node - */ - -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockRevokeMcpApiKeyByBearerToken } = vi.hoisted(() => ({ - mockRevokeMcpApiKeyByBearerToken: vi.fn(), -})) - -vi.mock('@/lib/mcp/auth', () => ({ - revokeMcpApiKeyByBearerToken: (...args: unknown[]) => mockRevokeMcpApiKeyByBearerToken(...args), -})) - -describe('MCP auth revoke route', () => { - beforeEach(() => { - vi.clearAllMocks() - mockRevokeMcpApiKeyByBearerToken.mockResolvedValue({ revoked: true }) - }) - - it('revokes the bearer API key', async () => { - const { POST } = await import('./route') - - const response = await POST( - new NextRequest('https://studio.example.test/api/auth/mcp/revoke', { - method: 'POST', - headers: { - authorization: 'Bearer sk-tradinggoose-old', - }, - }) - ) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ revoked: true }) - expect(mockRevokeMcpApiKeyByBearerToken).toHaveBeenCalledWith('sk-tradinggoose-old') - }) - - it('accepts a case-insensitive bearer auth scheme', async () => { - const { POST } = await import('./route') - - const response = await POST( - new NextRequest('https://studio.example.test/api/auth/mcp/revoke', { - method: 'POST', - headers: { - authorization: 'bearer sk-tradinggoose-old', - }, - }) - ) - - expect(response.status).toBe(200) - expect(mockRevokeMcpApiKeyByBearerToken).toHaveBeenCalledWith('sk-tradinggoose-old') - }) - - it('rejects missing bearer auth', async () => { - const { POST } = await import('./route') - - const response = await POST( - new NextRequest('https://studio.example.test/api/auth/mcp/revoke', { - method: 'POST', - }) - ) - - expect(response.status).toBe(400) - expect(mockRevokeMcpApiKeyByBearerToken).not.toHaveBeenCalled() - }) -}) diff --git a/apps/tradinggoose/app/api/auth/mcp/revoke/route.ts b/apps/tradinggoose/app/api/auth/mcp/revoke/route.ts deleted file mode 100644 index 8cea701a6..000000000 --- a/apps/tradinggoose/app/api/auth/mcp/revoke/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { revokeMcpApiKeyByBearerToken } from '@/lib/mcp/auth' - -export const dynamic = 'force-dynamic' - -function getBearerToken(request: NextRequest) { - const authorization = request.headers.get('authorization') - const match = authorization?.match(/^Bearer\s+(.+)$/i) - if (!match) { - return null - } - - const token = match[1].trim() - return token || null -} - -export async function POST(request: NextRequest) { - const token = getBearerToken(request) - if (!token) { - return NextResponse.json({ error: 'Bearer token required' }, { status: 400 }) - } - - const result = await revokeMcpApiKeyByBearerToken(token) - return NextResponse.json(result) -} diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts index 4a11a466e..c85027f3f 100644 --- a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts @@ -44,15 +44,15 @@ describe('MCP install route', () => { expect(script).toContain('return { code, verificationKey, token }') expect(script).toContain('confirm: true') expect(script).toContain('apiKey: login.token') - expect(script).toContain("baseUrl + '/api/auth/mcp/revoke'") expect(script).toContain("baseUrl + '/api/copilot/mcp'") - expect(script).toContain("Authorization: Bearer ' + login.token") - expect(script).toContain('setup Authenticate, rotate local MCP auth, and write config.') + expect(script).toContain("method: 'ping'") + expect(script).toContain('async function isTokenValid(token)') + expect(script).toContain('async function resolveAuthToken()') + expect(script).toContain("Authorization: Bearer ' + token") + expect(script).toContain('setup Write MCP config, authenticating when needed.') expect(script).toContain('read-tokens') - expect(script).toContain('await revokeTokens(existingTokens, login.token)') - expect(script).not.toContain('revokeExistingTokens') expect(script).toContain('node - "$BASE_URL" "$COMMAND" "$TARGETS"') - expect(script).toContain('runConfigWriter([target, mcpUrl, login.token])') + expect(script).toContain('runConfigWriter([target, mcpUrl, token])') expect(script).toContain("const mcpServerName = 'TradingGoose'") expect(script).toContain("'[mcp_servers.' + mcpServerName + '.http_headers]'") expect(script).toContain("'Authorization = ' + JSON.stringify('Bearer ' + token)") @@ -66,23 +66,16 @@ describe('MCP install route', () => { expect(script).not.toContain('workspaceId') expect(script).not.toContain('entityId') - const printedTokenIndex = script.indexOf("console.log('Authorization: Bearer ' + login.token)") + const printedTokenIndex = script.indexOf("console.log('Authorization: Bearer ' + token)") const firstConfirmIndex = script.indexOf('await confirmLogin(login)') - const firstRevokeIndex = script.indexOf('await revokeTokens(existingTokens, login.token)') + const firstReturnTokenIndex = script.indexOf('return login.token') const setupIndex = script.indexOf("if (command === 'setup')") - const setupConfirmIndex = script.indexOf('await confirmLogin(login)', setupIndex) const configWriteIndex = script.indexOf( - 'const configPath = runConfigWriter([target, mcpUrl, login.token])' - ) - const setupRevokeIndex = script.indexOf( - 'await revokeTokens(existingTokens, login.token)', - configWriteIndex + 'const configPath = runConfigWriter([target, mcpUrl, token])' ) expect(printedTokenIndex).toBeGreaterThan(firstConfirmIndex) - expect(firstRevokeIndex).toBeGreaterThan(firstConfirmIndex) - expect(configWriteIndex).toBeGreaterThan(setupConfirmIndex) - expect(setupRevokeIndex).toBeGreaterThan(setupConfirmIndex) - expect(setupRevokeIndex).toBeGreaterThan(configWriteIndex) + expect(firstReturnTokenIndex).toBeGreaterThan(firstConfirmIndex) + expect(configWriteIndex).toBeGreaterThan(setupIndex) }) it('serves target-specific setup scripts from the URL path', async () => { @@ -108,6 +101,7 @@ describe('MCP install route', () => { expect(script).toContain("$NodeScript | & node - $BaseUrl $Command ($Targets -join ' ')") expect(script).toContain("baseUrl + '/api/auth/mcp/start'") expect(script).toContain("runConfigWriter(['read-tokens'])") + expect(script).toContain("method: 'ping'") expect(script).toContain("const mcpServerName = 'TradingGoose'") expect(script).toContain("'[mcp_servers.' + mcpServerName + '.http_headers]'") expect(script).toContain("'Authorization = ' + JSON.stringify('Bearer ' + token)") diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 24c84b046..f1c276589 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -3,11 +3,7 @@ import { db } from '@tradinggoose/db' import { apiKey, verification } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' -import { - authenticateApiKeyFromHeader, - createApiKeyMaterial, - encryptApiKey, -} from '@/lib/api-key/service' +import { createApiKeyMaterial, encryptApiKey } from '@/lib/api-key/service' const DEVICE_LOGIN_TTL_MS = 10 * 60 * 1000 const DEVICE_LOGIN_PREFIX = 'mcp:' @@ -62,10 +58,6 @@ export type McpDeviceLoginStartResult = { intervalSeconds: number } -export type McpApiKeyRevocationResult = { - revoked: boolean -} - function getDeviceLoginIdentifier(code: string) { return `${DEVICE_LOGIN_PREFIX}${hashValue(code)}` } @@ -414,21 +406,3 @@ export async function cancelMcpDeviceLogin({ return { status: 'cancelled' } } - -export async function revokeMcpApiKeyByBearerToken( - token: string -): Promise { - const auth = await authenticateApiKeyFromHeader(token, { keyTypes: ['personal'] }) - if (!auth.success || !auth.keyId || !auth.userId) { - return { revoked: false } - } - - const deleted = await db - .delete(apiKey) - .where( - and(eq(apiKey.id, auth.keyId), eq(apiKey.userId, auth.userId), eq(apiKey.type, 'personal')) - ) - .returning({ id: apiKey.id }) - - return { revoked: deleted.length > 0 } -} diff --git a/apps/tradinggoose/lib/mcp/install-script.ts b/apps/tradinggoose/lib/mcp/install-script.ts index 167c27f70..43485b0a0 100644 --- a/apps/tradinggoose/lib/mcp/install-script.ts +++ b/apps/tradinggoose/lib/mcp/install-script.ts @@ -42,7 +42,7 @@ function fail(message) { function requireFetch() { if (typeof fetch !== 'function') { - fail('node 18 or newer is required to rotate MCP auth.') + fail('node 18 or newer is required to configure MCP auth.') } } @@ -86,12 +86,38 @@ function readExistingTokens() { return runConfigWriter(['read-tokens']).split(/\r?\n/).filter(Boolean) } -async function revokeTokens(tokens, currentToken) { +async function isTokenValid(token) { + const response = await fetch(mcpUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer ' + token, + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 'auth-check', method: 'ping' }), + }) + + return response.ok +} + +async function readValidExistingToken() { + const tokens = readExistingTokens() for (const token of tokens) { - if (token !== currentToken) { - await postJson(baseUrl + '/api/auth/mcp/revoke', null, token) + if (await isTokenValid(token)) { + return token } } + return null +} + +async function resolveAuthToken() { + const existingToken = await readValidExistingToken() + if (existingToken) { + return existingToken + } + + const login = await authenticate() + await confirmLogin(login) + return login.token } async function authenticate() { @@ -159,18 +185,15 @@ async function main() { requireFetch() if (command === 'login') { - const existingTokens = readExistingTokens() - const login = await authenticate() - await confirmLogin(login) + const token = await resolveAuthToken() console.log('MCP endpoint:') console.log(mcpUrl) console.log('') console.log('Bearer token:') - console.log(login.token) + console.log(token) console.log('') console.log('Use this MCP auth header:') - console.log('Authorization: Bearer ' + login.token) - await revokeTokens(existingTokens, login.token) + console.log('Authorization: Bearer ' + token) return } @@ -179,15 +202,12 @@ async function main() { fail('setup requires a selected target') } - const existingTokens = readExistingTokens() - const login = await authenticate() - await confirmLogin(login) + const token = await resolveAuthToken() console.log('Using MCP endpoint: ' + mcpUrl) for (const target of targets) { - const configPath = runConfigWriter([target, mcpUrl, login.token]) + const configPath = runConfigWriter([target, mcpUrl, token]) console.log('Configured ' + target + ': ' + configPath) } - await revokeTokens(existingTokens, login.token) return } @@ -228,8 +248,8 @@ PowerShell: irm /mcp/login | iex Commands: - login Rotate local MCP auth and print a bearer token. - setup Authenticate, rotate local MCP auth, and write config. + login Print a valid local MCP bearer token, authenticating when needed. + setup Write MCP config, authenticating when needed. Options: -h, --help Show this help. @@ -284,7 +304,7 @@ choose_targets() { } run_installer() { - command -v node >/dev/null 2>&1 || fail "node is required to rotate MCP auth and write config." + command -v node >/dev/null 2>&1 || fail "node is required to configure MCP auth and write config." node - "$BASE_URL" "$COMMAND" "$TARGETS" <<'NODE' ${MCP_LOCAL_INSTALLER_SCRIPT} NODE @@ -347,8 +367,8 @@ POSIX shell: curl -fsSL /mcp/login | sh Commands: - login Rotate local MCP auth and print a bearer token. - setup Authenticate, rotate local MCP auth, and write config. + login Print a valid local MCP bearer token, authenticating when needed. + setup Write MCP config, authenticating when needed. Options: -h, --help Show this help. @@ -396,7 +416,7 @@ function Choose-Targets { function Run-Installer { if (-not (Get-Command node -ErrorAction SilentlyContinue)) { - Fail 'node is required to rotate MCP auth and write config.' + Fail 'node is required to configure MCP auth and write config.' } $NodeScript = @' From cd9dffd4c4bf4617f6c5ebf58e8071b85d8e188e Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 17:37:30 -0600 Subject: [PATCH 026/284] fix(workflows): make Yjs the canonical editable state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../api/workflows/[id]/autolayout/route.ts | 6 +- .../app/api/workflows/[id]/deploy/route.ts | 2 +- .../[version]/revert/route.test.ts | 26 +-- .../deployments/[version]/revert/route.ts | 35 ++-- .../workflows/[id]/duplicate/route.test.ts | 43 +++-- .../app/api/workflows/[id]/duplicate/route.ts | 48 +++--- .../app/api/workflows/[id]/route.test.ts | 13 +- .../app/api/workflows/[id]/route.ts | 43 +++-- .../api/workflows/[id]/state/route.test.ts | 57 ++++--- .../app/api/workflows/[id]/state/route.ts | 51 +++--- .../api/workflows/[id]/status/route.test.ts | 4 +- .../app/api/workflows/[id]/status/route.ts | 3 +- .../app/api/workflows/route.test.ts | 100 +++++++++-- apps/tradinggoose/app/api/workflows/route.ts | 82 ++++----- .../api/workflows/yaml/export/route.test.ts | 4 +- .../app/api/workflows/yaml/export/route.ts | 2 +- .../app/api/workspaces/route.test.ts | 14 +- apps/tradinggoose/hooks/queries/workflows.ts | 16 -- .../lib/workflows/db-helpers.test.ts | 158 ++---------------- apps/tradinggoose/lib/workflows/db-helpers.ts | 102 +---------- .../lib/workflows/execution-runner.test.ts | 30 +++- .../lib/workflows/execution-runner.ts | 20 ++- apps/tradinggoose/lib/workspaces/service.ts | 32 ++-- .../lib/yjs/server/apply-workflow-state.ts | 26 --- .../lib/yjs/server/bootstrap-review-target.ts | 103 +----------- .../tradinggoose/socket-server/routes/http.ts | 2 +- .../socket-server/yjs/ws-handler.test.ts | 5 +- .../socket-server/yjs/ws-handler.ts | 2 +- .../components/control-bar/auto-layout.ts | 6 +- 29 files changed, 391 insertions(+), 644 deletions(-) diff --git a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts index 45217e348..f903f3674 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applyAutoLayout } from '@/lib/workflows/autolayout' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' +import { loadWorkflowState } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' export const dynamic = 'force-dynamic' @@ -60,8 +60,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ edges: layoutOptions.edges, } } else { - logger.info(`[${requestId}] Loading blocks from database`) - currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId) + logger.info(`[${requestId}] Loading blocks from Yjs`) + currentWorkflowData = await loadWorkflowState(workflowId) } if (!currentWorkflowData) { diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts index 851a54a5c..ea0b85486 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts @@ -99,7 +99,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .limit(1) if (active?.state) { - const currentState = await loadWorkflowState(id, workflowData.lastSynced) + const currentState = await loadWorkflowState(id) if (currentState) { needsRedeployment = hasWorkflowChanged(currentState, active.state as any) } diff --git a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts index a9e244307..f012a458c 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts @@ -10,7 +10,7 @@ describe('Revert To Deployment Version API Route', () => { const mockValidateWorkflowPermissions = vi.fn() const mockSaveWorkflowToNormalizedTables = vi.fn() - const mockTryApplyWorkflowState = vi.fn() + const mockApplyWorkflowState = vi.fn() const mockDbSelectLimit = vi.fn() const mockDbUpdateWhere = vi.fn() @@ -24,9 +24,8 @@ describe('Revert To Deployment Version API Route', () => { callOrder.push('save') return { success: true } }) - mockTryApplyWorkflowState.mockImplementation(async () => { + mockApplyWorkflowState.mockImplementation(async () => { callOrder.push('apply') - return { success: true } }) mockDbSelectLimit.mockResolvedValue([ { @@ -107,11 +106,13 @@ describe('Revert To Deployment Version API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ + ensureUniqueBlockIds: vi.fn(async (_workflowId: string, state: any) => state), + ensureUniqueEdgeIds: vi.fn(async (_workflowId: string, state: any) => state), saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, })) vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ - tryApplyWorkflowState: mockTryApplyWorkflowState, + applyWorkflowState: mockApplyWorkflowState, })) vi.doMock('@/lib/yjs/workflow-session', () => ({ @@ -144,7 +145,7 @@ describe('Revert To Deployment Version API Route', () => { vi.clearAllMocks() }) - it('publishes the reverted Yjs state only after the durable writes complete', async () => { + it('publishes the reverted Yjs state before materializing derived tables', async () => { const { POST } = await import('@/app/api/workflows/[id]/deployments/[version]/revert/route') const request = new NextRequest( 'http://localhost:3000/api/workflows/workflow-1/deployments/active/revert' @@ -155,8 +156,8 @@ describe('Revert To Deployment Version API Route', () => { }) expect(response.status).toBe(200) - expect(callOrder).toEqual(['save', 'db-update', 'apply']) - expect(mockTryApplyWorkflowState).toHaveBeenCalledWith( + expect(callOrder).toEqual(['apply', 'save', 'db-update']) + expect(mockApplyWorkflowState).toHaveBeenCalledWith( 'workflow-1', expect.objectContaining({ blocks: expect.any(Object), @@ -173,8 +174,11 @@ describe('Revert To Deployment Version API Route', () => { ) }) - it('does not publish the reverted Yjs state when the workflow row update fails', async () => { - mockDbUpdateWhere.mockRejectedValueOnce(new Error('database unavailable')) + it('reports workflow-row update failures after Yjs and materialization complete', async () => { + mockDbUpdateWhere.mockImplementationOnce(async () => { + callOrder.push('db-update') + throw new Error('database unavailable') + }) const { POST } = await import('@/app/api/workflows/[id]/deployments/[version]/revert/route') const request = new NextRequest( @@ -186,7 +190,7 @@ describe('Revert To Deployment Version API Route', () => { }) expect(response.status).toBe(500) - expect(callOrder).toEqual(['save']) - expect(mockTryApplyWorkflowState).not.toHaveBeenCalled() + expect(callOrder).toEqual(['apply', 'save', 'db-update']) + expect(mockApplyWorkflowState).toHaveBeenCalledOnce() }) }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts index b249490aa..c6d79c6ce 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -3,9 +3,13 @@ import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' +import { + ensureUniqueBlockIds, + ensureUniqueEdgeIds, + saveWorkflowToNormalizedTables, +} from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' -import { tryApplyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' import { pauseMonitorsMissingDeployedTrigger } from '@/app/api/monitors/shared' @@ -73,7 +77,7 @@ export async function POST( const now = new Date() const revertVariables = deployedState.variables || undefined - const saveResult = await saveWorkflowToNormalizedTables(id, { + const revertedState = { blocks: deployedState.blocks, edges: deployedState.edges, loops: deployedState.loops || {}, @@ -81,21 +85,10 @@ export async function POST( lastSaved: Date.now(), isDeployed: true, deployedAt: new Date(), - }) - - if (!saveResult.success) { - return createErrorResponse(saveResult.error || 'Failed to save deployed state', 500) } - const persistedRevertedState = saveResult.normalizedState ?? { - blocks: deployedState.blocks, - edges: deployedState.edges, - loops: deployedState.loops || {}, - parallels: deployedState.parallels || {}, - lastSaved: Date.now(), - isDeployed: true, - deployedAt: new Date(), - } + const stateWithUniqueBlockIds = await ensureUniqueBlockIds(id, revertedState) + const persistedRevertedState = await ensureUniqueEdgeIds(id, stateWithUniqueBlockIds) const revertSnapshot = createWorkflowSnapshot({ blocks: persistedRevertedState.blocks, edges: persistedRevertedState.edges, @@ -106,6 +99,13 @@ export async function POST( deployedAt: now.toISOString(), }) + await applyWorkflowState(id, revertSnapshot, revertVariables) + + const saveResult = await saveWorkflowToNormalizedTables(id, persistedRevertedState) + if (!saveResult.success) { + return createErrorResponse(saveResult.error || 'Failed to materialize deployed state', 500) + } + await db .update(workflow) .set({ @@ -115,9 +115,6 @@ export async function POST( }) .where(eq(workflow.id, id)) - // Publish the reverted state to Yjs only after the durable writes succeed. - await tryApplyWorkflowState(id, revertSnapshot, revertVariables) - await pauseMonitorsMissingDeployedTrigger(id) await notifyMonitorsReconcile({ requestId, logger }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts index 1c7a3cea0..c8eccfbb0 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts @@ -9,9 +9,10 @@ describe('Workflow Duplicate API Route', () => { let remapVariableIdsMock: ReturnType let regenerateWorkflowStateIdsMock: ReturnType let saveWorkflowToNormalizedTablesMock: ReturnType - let tryApplyWorkflowStateMock: ReturnType + let applyWorkflowStateMock: ReturnType let insertValuesMock: ReturnType let deleteWhereMock: ReturnType + const callOrder: string[] = [] const sourceWorkflowRow = { id: 'workflow-id', @@ -42,6 +43,7 @@ describe('Workflow Duplicate API Route', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() + callOrder.length = 0 loadWorkflowStateMock = vi.fn() remapVariableIdsMock = vi.fn((variables, newWorkflowId: string) => @@ -57,8 +59,13 @@ describe('Workflow Duplicate API Route', () => { ) ) regenerateWorkflowStateIdsMock = vi.fn((state) => JSON.parse(JSON.stringify(state))) - saveWorkflowToNormalizedTablesMock = vi.fn().mockResolvedValue({ success: true }) - tryApplyWorkflowStateMock = vi.fn().mockResolvedValue({ success: true }) + saveWorkflowToNormalizedTablesMock = vi.fn().mockImplementation(async (_workflowId, state) => { + callOrder.push('save') + return { success: true, normalizedState: state } + }) + applyWorkflowStateMock = vi.fn().mockImplementation(async () => { + callOrder.push('apply') + }) insertValuesMock = vi.fn().mockResolvedValue(undefined) deleteWhereMock = vi.fn().mockResolvedValue(undefined) @@ -122,6 +129,8 @@ describe('Workflow Duplicate API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ + ensureUniqueBlockIds: vi.fn(async (_workflowId: string, state: any) => state), + ensureUniqueEdgeIds: vi.fn(async (_workflowId: string, state: any) => state), loadWorkflowState: loadWorkflowStateMock, remapVariableIds: remapVariableIdsMock, regenerateWorkflowStateIds: regenerateWorkflowStateIdsMock, @@ -129,7 +138,7 @@ describe('Workflow Duplicate API Route', () => { })) vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ - tryApplyWorkflowState: tryApplyWorkflowStateMock, + applyWorkflowState: applyWorkflowStateMock, })) vi.doMock('@/lib/yjs/workflow-session', () => ({ @@ -181,12 +190,13 @@ describe('Workflow Duplicate API Route', () => { expect(response.status).toBe(201) expect(insertValuesMock).toHaveBeenCalledOnce() expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() - expect(tryApplyWorkflowStateMock).toHaveBeenCalledOnce() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + expect(callOrder).toEqual(['apply', 'save']) const insertedWorkflow = insertValuesMock.mock.calls[0][0] - const appliedWorkflowId = tryApplyWorkflowStateMock.mock.calls[0][0] - const appliedSnapshot = tryApplyWorkflowStateMock.mock.calls[0][1] - const appliedVariables = tryApplyWorkflowStateMock.mock.calls[0][2] + const appliedWorkflowId = applyWorkflowStateMock.mock.calls[0][0] + const appliedSnapshot = applyWorkflowStateMock.mock.calls[0][1] + const appliedVariables = applyWorkflowStateMock.mock.calls[0][2] const savedState = saveWorkflowToNormalizedTablesMock.mock.calls[0][1] expect(insertedWorkflow.id).toBe(appliedWorkflowId) @@ -215,7 +225,7 @@ describe('Workflow Duplicate API Route', () => { expect((Object.values(appliedVariables)[0] as { id: string }).id).not.toBe('live-var') }) - it('keeps the duplicate when canonical persistence succeeds but Yjs sync fails', async () => { + it('rolls back the duplicate when Yjs apply fails', async () => { loadWorkflowStateMock.mockResolvedValue({ blocks: {}, edges: [], @@ -223,12 +233,9 @@ describe('Workflow Duplicate API Route', () => { parallels: {}, variables: {}, lastSaved: Date.now(), - source: 'normalized', - }) - tryApplyWorkflowStateMock.mockResolvedValueOnce({ - success: false, - error: new Error('socket bridge unavailable'), + source: 'yjs', }) + applyWorkflowStateMock.mockRejectedValueOnce(new Error('socket bridge unavailable')) const { POST } = await import('@/app/api/workflows/[id]/duplicate/route') const response = await POST( @@ -238,10 +245,10 @@ describe('Workflow Duplicate API Route', () => { } ) - expect(response.status).toBe(201) - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() - expect(tryApplyWorkflowStateMock).toHaveBeenCalledOnce() - expect(deleteWhereMock).not.toHaveBeenCalled() + expect(response.status).toBe(500) + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + expect(saveWorkflowToNormalizedTablesMock).not.toHaveBeenCalled() + expect(deleteWhereMock).toHaveBeenCalledOnce() }) it('rejects duplication without workspace scope', async () => { diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts index b61846eb3..ad068a876 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -10,12 +10,14 @@ import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' import { normalizeVariables } from '@/lib/workflows/variable-utils' import { + ensureUniqueBlockIds, + ensureUniqueEdgeIds, loadWorkflowState, regenerateWorkflowStateIds, remapVariableIds, saveWorkflowToNormalizedTables, } from '@/lib/workflows/db-helpers' -import { tryApplyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' import type { Variable } from '@/stores/variables/types' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -30,26 +32,17 @@ const DuplicateRequestSchema = z.object({ }) async function loadSourceWorkflowArtifacts( - sourceWorkflowId: string, - sourceVariables: unknown + sourceWorkflowId: string ): Promise<{ workflowState: WorkflowState variables: Record - source: 'yjs' | 'normalized' + source: 'yjs' }> { const stateWithSource = await loadWorkflowState(sourceWorkflowId) if (!stateWithSource) { throw new Error('Failed to load source workflow state') } - // When the state came from Yjs the variables are already embedded in the - // snapshot. For the normalized-table path, prefer the caller-supplied - // source variables (from the workflow row). - const variables = - stateWithSource.source === 'yjs' - ? normalizeVariables(stateWithSource.variables) - : normalizeVariables(sourceVariables) - return { workflowState: { blocks: stateWithSource.blocks, @@ -59,7 +52,7 @@ async function loadSourceWorkflowArtifacts( lastSaved: stateWithSource.lastSaved ?? Date.now(), isDeployed: false, }, - variables, + variables: normalizeVariables(stateWithSource.variables), source: stateWithSource.source, } } @@ -117,7 +110,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: ) } - const sourceArtifacts = await loadSourceWorkflowArtifacts(sourceWorkflowId, source.variables) + const sourceArtifacts = await loadSourceWorkflowArtifacts(sourceWorkflowId) const newWorkflowId = crypto.randomUUID() const now = new Date() @@ -147,15 +140,15 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: try { const lastSaved = now.toISOString() + const stateWithUniqueBlockIds = await ensureUniqueBlockIds( + newWorkflowId, + duplicatedWorkflowState + ) + const persistedDuplicatedState = await ensureUniqueEdgeIds( + newWorkflowId, + stateWithUniqueBlockIds + ) - // Persist canonical workflow state before best-effort Yjs sync so the duplicate - // survives bridge outages and never depends on socket-server availability. - const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, duplicatedWorkflowState) - if (!saveResult.success) { - throw new Error(saveResult.error || 'Failed to save duplicated workflow state') - } - - const persistedDuplicatedState = saveResult.normalizedState ?? duplicatedWorkflowState const duplicatedSnapshot = createWorkflowSnapshot({ blocks: persistedDuplicatedState.blocks, edges: persistedDuplicatedState.edges, @@ -165,17 +158,16 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: isDeployed: false, }) - const yjsApplyResult = await tryApplyWorkflowState( + await applyWorkflowState( newWorkflowId, duplicatedSnapshot, duplicatedVariables, name ) - if (!yjsApplyResult.success) { - logger.warn( - `[${requestId}] Duplicated workflow ${newWorkflowId} without Yjs sync; canonical state was persisted`, - { sourceWorkflowId, newWorkflowId, error: yjsApplyResult.error } - ) + + const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, persistedDuplicatedState) + if (!saveResult.success) { + throw new Error(saveResult.error || 'Failed to materialize duplicated workflow state') } } catch (duplicationError) { await db.delete(workflow).where(eq(workflow.id, newWorkflowId)) diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index bb5f160f4..81b53b4f4 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -141,7 +141,7 @@ describe('Workflow By ID API Route', () => { edges: [], loops: {}, parallels: {}, - source: 'normalized', + source: 'yjs', } vi.doMock('@/lib/auth', () => ({ @@ -194,7 +194,7 @@ describe('Workflow By ID API Route', () => { edges: [], loops: {}, parallels: {}, - source: 'normalized', + source: 'yjs', } vi.doMock('@/lib/auth', () => ({ @@ -313,7 +313,7 @@ describe('Workflow By ID API Route', () => { expect(data.data.state.edges).toEqual(mockWorkflowState.edges) }) - it('should return an empty state when no normalized data exists yet', async () => { + it('should return 409 when canonical Yjs state is missing', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -342,12 +342,9 @@ describe('Workflow By ID API Route', () => { const { GET } = await import('@/app/api/workflows/[id]/route') const response = await GET(req, { params }) - expect(response.status).toBe(200) + expect(response.status).toBe(409) const data = await response.json() - expect(data.data.state.blocks).toEqual({}) - expect(data.data.state.edges).toEqual([]) - expect(data.data.state.loops).toEqual({}) - expect(data.data.state.parallels).toEqual({}) + expect(data.error).toBe('Workflow state is missing') }) }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index cc0088b93..f99cb887e 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -27,7 +27,7 @@ const UpdateWorkflowSchema = z /** * GET /api/workflows/[id] * Fetch a single workflow by ID - * Uses the authoritative Yjs-first workflow state loader. + * Uses the authoritative Yjs workflow state loader. */ export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() @@ -124,31 +124,28 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from authoritative state`) - const workflowState = await loadWorkflowState(workflowId, workflowData.lastSynced) + const workflowState = await loadWorkflowState(workflowId) if (!workflowState) { - logger.warn( - `[${requestId}] Workflow ${workflowId} has no stored state, returning empty state` - ) - } else { - logger.debug(`[${requestId}] Found ${workflowState.source} workflow state for ${workflowId}:`, { - blocksCount: Object.keys(workflowState.blocks).length, - edgesCount: workflowState.edges.length, - loopsCount: Object.keys(workflowState.loops).length, - parallelsCount: Object.keys(workflowState.parallels).length, - loops: workflowState.loops, - }) + logger.warn(`[${requestId}] Workflow ${workflowId} is missing canonical Yjs state`) + return NextResponse.json({ error: 'Workflow state is missing' }, { status: 409 }) } - const resolvedState = workflowState - ? createWorkflowSnapshot({ - direction: workflowState.direction, - blocks: workflowState.blocks, - edges: workflowState.edges, - loops: workflowState.loops, - parallels: workflowState.parallels, - }) - : createWorkflowSnapshot() + logger.debug(`[${requestId}] Found ${workflowState.source} workflow state for ${workflowId}:`, { + blocksCount: Object.keys(workflowState.blocks).length, + edgesCount: workflowState.edges.length, + loopsCount: Object.keys(workflowState.loops).length, + parallelsCount: Object.keys(workflowState.parallels).length, + loops: workflowState.loops, + }) + + const resolvedState = createWorkflowSnapshot({ + direction: workflowState.direction, + blocks: workflowState.blocks, + edges: workflowState.edges, + loops: workflowState.loops, + parallels: workflowState.parallels, + }) let resolvedBlocks = resolvedState.blocks if (!isInternalCall && resolvedState.blocks) { @@ -173,7 +170,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ lastSaved: Date.now(), isDeployed: workflowData.isDeployed || false, deployedAt: workflowData.deployedAt, - variables: workflowState?.variables ?? {}, + variables: workflowState.variables, }, } diff --git a/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts index 5de1bb481..4efee7de7 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('Workflow State API Route', () => { let loadWorkflowStateFromYjsMock: ReturnType let saveWorkflowToNormalizedTablesMock: ReturnType - let tryApplyWorkflowStateMock: ReturnType + let applyWorkflowStateMock: ReturnType let updateSetMock: ReturnType const createRequest = (body: Record) => @@ -42,7 +42,7 @@ describe('Workflow State API Route', () => { loadWorkflowStateFromYjsMock = vi.fn().mockResolvedValue(null) saveWorkflowToNormalizedTablesMock = vi.fn().mockResolvedValue({ success: true }) - tryApplyWorkflowStateMock = vi.fn().mockResolvedValue({ success: true }) + applyWorkflowStateMock = vi.fn().mockResolvedValue(undefined) updateSetMock = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined), }) @@ -112,6 +112,8 @@ describe('Workflow State API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ + ensureUniqueBlockIds: vi.fn(async (_workflowId: string, state: any) => state), + ensureUniqueEdgeIds: vi.fn(async (_workflowId: string, state: any) => state), loadWorkflowStateFromYjs: loadWorkflowStateFromYjsMock, saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, toISOStringOrUndefined: vi.fn((value: string | number | Date | null | undefined) => @@ -127,7 +129,7 @@ describe('Workflow State API Route', () => { })) vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ - tryApplyWorkflowState: tryApplyWorkflowStateMock, + applyWorkflowState: applyWorkflowStateMock, })) }) @@ -159,7 +161,7 @@ describe('Workflow State API Route', () => { }) expect(response.status).toBe(200) - expect(tryApplyWorkflowStateMock).toHaveBeenCalledWith( + expect(applyWorkflowStateMock).toHaveBeenCalledWith( 'workflow-id', expect.any(Object), { @@ -182,22 +184,19 @@ describe('Workflow State API Route', () => { ) }) - it('does not republish workflow-row variables when no Yjs state is available in-process', async () => { + it('rejects saves without request or Yjs variables', async () => { const { PUT } = await import('@/app/api/workflows/[id]/state/route') const response = await PUT(createRequest(validStateBody), { params: Promise.resolve({ id: 'workflow-id' }), }) - expect(response.status).toBe(200) - expect(tryApplyWorkflowStateMock).not.toHaveBeenCalled() - expect(updateSetMock).toHaveBeenCalledWith( - expect.not.objectContaining({ - variables: expect.anything(), - }) - ) + expect(response.status).toBe(409) + expect(applyWorkflowStateMock).not.toHaveBeenCalled() + expect(saveWorkflowToNormalizedTablesMock).not.toHaveBeenCalled() + expect(updateSetMock).not.toHaveBeenCalled() }) - it('continues saving when authoritative Yjs variable lookup fails', async () => { + it('rejects saves when authoritative Yjs variable lookup fails', async () => { loadWorkflowStateFromYjsMock.mockRejectedValueOnce(new Error('socket bridge unavailable')) const { PUT } = await import('@/app/api/workflows/[id]/state/route') @@ -205,31 +204,31 @@ describe('Workflow State API Route', () => { params: Promise.resolve({ id: 'workflow-id' }), }) - expect(response.status).toBe(200) - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledWith( - 'workflow-id', - expect.any(Object) - ) - expect(tryApplyWorkflowStateMock).not.toHaveBeenCalled() - expect(updateSetMock).toHaveBeenCalledWith( - expect.not.objectContaining({ - variables: expect.anything(), - }) - ) + expect(response.status).toBe(409) + expect(applyWorkflowStateMock).not.toHaveBeenCalled() + expect(saveWorkflowToNormalizedTablesMock).not.toHaveBeenCalled() + expect(updateSetMock).not.toHaveBeenCalled() }) - it('does not apply Yjs state when the canonical save fails', async () => { + it('returns an error when derived table materialization fails after Yjs apply', async () => { saveWorkflowToNormalizedTablesMock.mockResolvedValueOnce({ success: false, error: 'validation failed', }) const { PUT } = await import('@/app/api/workflows/[id]/state/route') - const response = await PUT(createRequest(validStateBody), { - params: Promise.resolve({ id: 'workflow-id' }), - }) + const response = await PUT( + createRequest({ + ...validStateBody, + variables: {}, + }), + { + params: Promise.resolve({ id: 'workflow-id' }), + } + ) expect(response.status).toBe(500) - expect(tryApplyWorkflowStateMock).not.toHaveBeenCalled() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() }) }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/state/route.ts b/apps/tradinggoose/app/api/workflows/[id]/state/route.ts index 1219f2d28..1a9d94c2b 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/state/route.ts @@ -7,13 +7,15 @@ import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { extractAndPersistCustomTools } from '@/lib/workflows/custom-tools-persistence' import { + ensureUniqueBlockIds, + ensureUniqueEdgeIds, loadWorkflowStateFromYjs, saveWorkflowToNormalizedTables, toISOStringOrUndefined, } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' -import { tryApplyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' const logger = createLogger('WorkflowStateAPI') @@ -122,7 +124,7 @@ type ResolvedVariables = { /** * PUT /api/workflows/[id]/state - * Save complete workflow state to normalized database tables + * Save complete workflow state to Yjs and materialize derived database tables. */ export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() @@ -212,31 +214,30 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } } - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState as any) - - if (!saveResult.success) { - logger.error(`[${requestId}] Failed to save workflow ${workflowId} state:`, saveResult.error) + if (resolvedVariables.source === 'unavailable') { return NextResponse.json( - { error: 'Failed to save workflow state', details: saveResult.error }, - { status: 500 } + { error: 'Failed to save workflow state', details: 'Current workflow variables are unavailable' }, + { status: 409 } ) } - const persistedWorkflowState = saveResult.normalizedState ?? workflowState - - // Apply the validated state to Yjs only when we can also preserve the - // current variables snapshot. Otherwise this process might publish a - // partial doc and wipe newer variables owned by the separate socket server. - if (resolvedVariables.source !== 'unavailable') { - await tryApplyWorkflowState( - workflowId, - persistedWorkflowState as WorkflowSnapshot, - resolvedVariables.value, - workflowData.name - ) - } else { - logger.warn( - `[${requestId}] Skipping Yjs workflow apply because no authoritative Yjs variables were available for ${workflowId}` + const stateWithUniqueBlockIds = await ensureUniqueBlockIds(workflowId, workflowState as any) + const persistedWorkflowState = await ensureUniqueEdgeIds(workflowId, stateWithUniqueBlockIds) + + await applyWorkflowState( + workflowId, + persistedWorkflowState as WorkflowSnapshot, + resolvedVariables.value, + workflowData.name + ) + + const saveResult = await saveWorkflowToNormalizedTables(workflowId, persistedWorkflowState as any) + + if (!saveResult.success) { + logger.error(`[${requestId}] Failed to materialize workflow ${workflowId} state:`, saveResult.error) + return NextResponse.json( + { error: 'Failed to materialize workflow state', details: saveResult.error }, + { status: 500 } ) } @@ -266,9 +267,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ .set({ lastSynced: syncedAt, updatedAt: syncedAt, - ...(resolvedVariables.source !== 'unavailable' - ? { variables: resolvedVariables.value ?? {} } - : {}), + variables: resolvedVariables.value ?? {}, }) .where(eq(workflow.id, workflowId)) diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts index fcfaa4240..58f3fd2aa 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts @@ -121,7 +121,7 @@ describe('Workflow Status API Route', () => { value: 'us-west-2', }, }, - source: 'normalized', + source: 'yjs', }) mockLimit.mockResolvedValue([ @@ -177,7 +177,7 @@ describe('Workflow Status API Route', () => { value: 'us-west-2', }, }, - source: 'normalized', + source: 'yjs', }) mockLimit.mockResolvedValue([ diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts index 38e4345ff..79d1f89bc 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts @@ -26,8 +26,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ let needsRedeployment = false if (validation.workflow.isDeployed) { - // Load current state (Yjs-first, fall back to normalized tables) and - // the active deployment version in parallel. + // Load current editable state from Yjs and the active deployment version in parallel. const [currentState, [active]] = await Promise.all([ loadWorkflowState(id), db diff --git a/apps/tradinggoose/app/api/workflows/route.test.ts b/apps/tradinggoose/app/api/workflows/route.test.ts index d10cb019b..6a5e83759 100644 --- a/apps/tradinggoose/app/api/workflows/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/route.test.ts @@ -8,8 +8,9 @@ describe('Workflow API Route', () => { const insertValuesMock = vi.fn() const deleteWhereMock = vi.fn() const saveWorkflowToNormalizedTablesMock = vi.fn() - const tryApplyWorkflowStateMock = vi.fn() + const applyWorkflowStateMock = vi.fn() const randomUUIDMock = vi.fn() + const callOrder: string[] = [] const createRequest = (body: Record) => new NextRequest('http://localhost:3000/api/workflows', { @@ -23,11 +24,17 @@ describe('Workflow API Route', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() + callOrder.length = 0 insertValuesMock.mockResolvedValue(undefined) deleteWhereMock.mockResolvedValue(undefined) - saveWorkflowToNormalizedTablesMock.mockResolvedValue({ success: true }) - tryApplyWorkflowStateMock.mockResolvedValue({ success: true }) + saveWorkflowToNormalizedTablesMock.mockImplementation(async (_workflowId, state) => { + callOrder.push('save') + return { success: true, normalizedState: state } + }) + applyWorkflowStateMock.mockImplementation(async () => { + callOrder.push('apply') + }) randomUUIDMock.mockReset() randomUUIDMock.mockReturnValueOnce('workflow-123').mockReturnValueOnce('variable-123') vi.stubGlobal('crypto', { @@ -87,6 +94,8 @@ describe('Workflow API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ + ensureUniqueBlockIds: vi.fn(async (_workflowId: string, state: any) => state), + ensureUniqueEdgeIds: vi.fn(async (_workflowId: string, state: any) => state), remapVariableIds: vi.fn((variables: Record, workflowId: string) => Object.fromEntries( Object.entries(variables).map(([key, variable]) => [ @@ -103,7 +112,7 @@ describe('Workflow API Route', () => { })) vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ - tryApplyWorkflowState: tryApplyWorkflowStateMock, + applyWorkflowState: applyWorkflowStateMock, })) vi.doMock('@/lib/yjs/workflow-session', () => ({ @@ -119,7 +128,7 @@ describe('Workflow API Route', () => { vi.unstubAllGlobals() }) - it('persists initial workflow state canonically before seeding Yjs', async () => { + it('seeds Yjs before materializing initial workflow state', async () => { const initialWorkflowState = { blocks: { 'block-1': { @@ -159,7 +168,8 @@ describe('Workflow API Route', () => { expect(response.status).toBe(200) expect(insertValuesMock).toHaveBeenCalledOnce() expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() - expect(tryApplyWorkflowStateMock).toHaveBeenCalledOnce() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + expect(callOrder).toEqual(['apply', 'save']) const insertedWorkflow = insertValuesMock.mock.calls[0][0] const canonicalState = saveWorkflowToNormalizedTablesMock.mock.calls[0][1] @@ -184,7 +194,7 @@ describe('Workflow API Route', () => { }) ) expect(canonicalState.lastSaved).toEqual(expect.any(Number)) - expect(tryApplyWorkflowStateMock).toHaveBeenCalledWith( + expect(applyWorkflowStateMock).toHaveBeenCalledWith( insertedWorkflow.id, expect.objectContaining({ blocks: initialWorkflowState.blocks, @@ -194,10 +204,13 @@ describe('Workflow API Route', () => { ) }) - it('rolls back the workflow row when canonical initial-state persistence fails', async () => { - saveWorkflowToNormalizedTablesMock.mockResolvedValueOnce({ - success: false, - error: 'save failed', + it('rolls back the workflow row when initial-state materialization fails', async () => { + saveWorkflowToNormalizedTablesMock.mockImplementationOnce(async () => { + callOrder.push('save') + return { + success: false, + error: 'save failed', + } }) const { POST } = await import('@/app/api/workflows/route') @@ -218,7 +231,70 @@ describe('Workflow API Route', () => { expect(response.status).toBe(500) expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() expect(deleteWhereMock).toHaveBeenCalledOnce() - expect(tryApplyWorkflowStateMock).not.toHaveBeenCalled() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + expect(callOrder).toEqual(['apply', 'save']) + }) + + it('rolls back the workflow row when Yjs seeding fails', async () => { + applyWorkflowStateMock.mockRejectedValueOnce(new Error('socket unavailable')) + + const { POST } = await import('@/app/api/workflows/route') + const response = await POST( + createRequest({ + name: 'Workflow Copy', + workspaceId: 'workspace-1', + initialWorkflowState: { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + }, + }) + ) + + expect(response.status).toBe(500) + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + expect(saveWorkflowToNormalizedTablesMock).not.toHaveBeenCalled() + expect(deleteWhereMock).toHaveBeenCalledOnce() + }) + + it('seeds and materializes default workflow state when no initial state is provided', async () => { + const { POST } = await import('@/app/api/workflows/route') + const response = await POST( + createRequest({ + name: 'Blank Workflow', + workspaceId: 'workspace-1', + }) + ) + + expect(response.status).toBe(200) + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() + expect(callOrder).toEqual(['apply', 'save']) + + const insertedWorkflow = insertValuesMock.mock.calls[0][0] + expect(insertedWorkflow.variables).toEqual({}) + expect(applyWorkflowStateMock).toHaveBeenCalledWith( + insertedWorkflow.id, + expect.objectContaining({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + }), + {}, + 'Blank Workflow' + ) + expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledWith( + insertedWorkflow.id, + expect.objectContaining({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + }) + ) }) it('rejects workflow creation without workspace scope', async () => { diff --git a/apps/tradinggoose/app/api/workflows/route.ts b/apps/tradinggoose/app/api/workflows/route.ts index ca3b2ca30..be62d091a 100644 --- a/apps/tradinggoose/app/api/workflows/route.ts +++ b/apps/tradinggoose/app/api/workflows/route.ts @@ -8,9 +8,15 @@ import { getStableVibrantColor } from '@/lib/colors' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' -import { remapVariableIds, saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' +import { + ensureUniqueBlockIds, + ensureUniqueEdgeIds, + remapVariableIds, + saveWorkflowToNormalizedTables, +} from '@/lib/workflows/db-helpers' +import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { normalizeVariables } from '@/lib/workflows/variable-utils' -import { tryApplyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -34,20 +40,19 @@ function getInitialWorkflowState( ): { canonicalState: WorkflowState variables: Record -} | null { - if (!isPlainObject(initialWorkflowState)) { - return null - } +} { + const source = isPlainObject(initialWorkflowState) + ? initialWorkflowState + : buildDefaultWorkflowArtifacts().workflowState + const sourceRecord = source as Record - const blocks = isPlainObject(initialWorkflowState.blocks) ? initialWorkflowState.blocks : {} - const edges = Array.isArray(initialWorkflowState.edges) ? initialWorkflowState.edges : [] - const loops = isPlainObject(initialWorkflowState.loops) ? initialWorkflowState.loops : {} - const parallels = isPlainObject(initialWorkflowState.parallels) - ? initialWorkflowState.parallels - : {} - const variables = isPlainObject(initialWorkflowState.variables) - ? initialWorkflowState.variables + const blocks = isPlainObject(sourceRecord.blocks) ? sourceRecord.blocks : {} + const edges = Array.isArray(sourceRecord.edges) ? sourceRecord.edges : [] + const loops = isPlainObject(sourceRecord.loops) ? sourceRecord.loops : {} + const parallels = isPlainObject(sourceRecord.parallels) + ? sourceRecord.parallels : {} + const variables = isPlainObject(sourceRecord.variables) ? sourceRecord.variables : {} return { canonicalState: { @@ -157,7 +162,7 @@ export async function POST(req: NextRequest) { const now = new Date() const initialState = getInitialWorkflowState(initialWorkflowState, now) const remappedVariables = remapVariableIds( - normalizeVariables(initialState?.variables), + normalizeVariables(initialState.variables), workflowId ) const resolvedColor = getStableVibrantColor(workflowId) @@ -195,34 +200,31 @@ export async function POST(req: NextRequest) { marketplaceData: null, }) - let persistedInitialState = initialState?.canonicalState ?? null - if (initialState) { - const saveResult = await saveWorkflowToNormalizedTables(workflowId, initialState.canonicalState) - if (!saveResult.success) { - await db.delete(workflow).where(eq(workflow.id, workflowId)) - throw new Error(saveResult.error || 'Failed to persist initial workflow state') - } - persistedInitialState = saveResult.normalizedState ?? initialState.canonicalState - } + try { + const initialStateWithUniqueIds = await ensureUniqueEdgeIds( + workflowId, + await ensureUniqueBlockIds(workflowId, initialState.canonicalState) + ) - // Seed the Yjs doc for the new workflow - const defaultWorkflowSnapshot = createWorkflowSnapshot({ - blocks: persistedInitialState?.blocks, - edges: persistedInitialState?.edges, - loops: persistedInitialState?.loops, - parallels: persistedInitialState?.parallels, - lastSaved: now.toISOString(), - isDeployed: false, - }) + const defaultWorkflowSnapshot = createWorkflowSnapshot({ + blocks: initialStateWithUniqueIds.blocks, + edges: initialStateWithUniqueIds.edges, + loops: initialStateWithUniqueIds.loops, + parallels: initialStateWithUniqueIds.parallels, + lastSaved: now.toISOString(), + isDeployed: false, + }) - const yjsSeedResult = await tryApplyWorkflowState( - workflowId, - defaultWorkflowSnapshot, - remappedVariables, - name - ) - if (yjsSeedResult.success) { + await applyWorkflowState(workflowId, defaultWorkflowSnapshot, remappedVariables, name) logger.info(`[${requestId}] Seeded Yjs doc for new workflow ${workflowId}`) + + const saveResult = await saveWorkflowToNormalizedTables(workflowId, initialStateWithUniqueIds) + if (!saveResult.success) { + throw new Error(saveResult.error || 'Failed to materialize initial workflow state') + } + } catch (error) { + await db.delete(workflow).where(eq(workflow.id, workflowId)) + throw error } logger.info(`[${requestId}] Successfully created workflow ${workflowId}`) diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts index df0e602c5..b5a20f469 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts @@ -193,7 +193,7 @@ describe('Workflow YAML Export API Route', () => { } ) - it('falls back to canonical saved state and workflow-row variables when no live doc exists', async () => { + it('exports the canonical saved Yjs state when no live doc exists', async () => { loadWorkflowStateMock.mockResolvedValue({ blocks: { 'db-block': { @@ -221,7 +221,7 @@ describe('Workflow YAML Export API Route', () => { }, }, lastSaved: Date.now(), - source: 'normalized', + source: 'yjs', }) const { GET } = await import('@/app/api/workflows/yaml/export/route') diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts index 9907f1033..665d943f4 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts @@ -37,7 +37,7 @@ export async function GET(request: NextRequest) { const userId = session.user.id - // Fetch the workflow from database + // Fetch workflow metadata for access checks. const workflowData = await db .select() .from(workflow) diff --git a/apps/tradinggoose/app/api/workspaces/route.test.ts b/apps/tradinggoose/app/api/workspaces/route.test.ts index 1e282e324..fd024dee7 100644 --- a/apps/tradinggoose/app/api/workspaces/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/route.test.ts @@ -18,7 +18,7 @@ describe('Workspaces API Route', () => { const updateSetMock = vi.fn() const updateMock = vi.fn() const mockSaveWorkflowToNormalizedTables = vi.fn() - const mockTryApplyWorkflowState = vi.fn() + const mockApplyWorkflowState = vi.fn() let userWorkspaces: Array<{ workspace: Record permissionType: 'admin' | 'write' | 'read' | null @@ -38,7 +38,7 @@ describe('Workspaces API Route', () => { updateSetMock.mockReturnValue({ where: updateWhereMock }) updateMock.mockReturnValue({ set: updateSetMock }) mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) - mockTryApplyWorkflowState.mockResolvedValue({ success: true }) + mockApplyWorkflowState.mockResolvedValue(undefined) vi.doMock('@tradinggoose/db', () => ({ db: { @@ -109,11 +109,13 @@ describe('Workspaces API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ + ensureUniqueBlockIds: vi.fn(async (_workflowId: string, state: any) => state), + ensureUniqueEdgeIds: vi.fn(async (_workflowId: string, state: any) => state), saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, })) vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ - tryApplyWorkflowState: mockTryApplyWorkflowState, + applyWorkflowState: mockApplyWorkflowState, })) vi.doMock('@/lib/yjs/workflow-session', () => ({ @@ -266,11 +268,7 @@ describe('Workspaces API Route', () => { ], [ 'Yjs seeding fails', - () => - mockTryApplyWorkflowState.mockResolvedValue({ - success: false, - error: new Error('socket unavailable'), - }), + () => mockApplyWorkflowState.mockRejectedValue(new Error('socket unavailable')), ], ])('removes a newly created workspace when default workflow %s', async (_case, fail) => { fail() diff --git a/apps/tradinggoose/hooks/queries/workflows.ts b/apps/tradinggoose/hooks/queries/workflows.ts index 3783d61c5..1e423b36e 100644 --- a/apps/tradinggoose/hooks/queries/workflows.ts +++ b/apps/tradinggoose/hooks/queries/workflows.ts @@ -1,7 +1,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { createLogger } from '@/lib/logs/console/logger' import { generateCreativeWorkflowName } from '@/lib/naming' -import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('WorkflowQueries') @@ -52,21 +51,6 @@ export function useCreateWorkflow() { logger.info(`Successfully created workflow ${workflowId}`) - const { workflowState } = buildDefaultWorkflowArtifacts() - - const stateResponse = await fetch(`/api/workflows/${workflowId}/state`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(workflowState), - }) - - if (!stateResponse.ok) { - const text = await stateResponse.text() - logger.error('Failed to persist default Start block:', text) - } else { - logger.info('Successfully persisted default Start block') - } - return { id: workflowId, name: createdWorkflow.name, diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index 40e0d3f31..73836f151 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -1009,19 +1009,6 @@ describe('Database Helpers', () => { } mockDb.transaction.mockImplementation(async (callback) => callback(tx)) - mockDb.select.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - variables: yjsVariables, - lastSynced: workflowLastSaved, - }, - ]), - }), - }), - }) - const result = await dbHelpers.deployWorkflow({ workflowId: mockWorkflowId, deployedBy: 'deployer-1', @@ -1039,7 +1026,7 @@ describe('Database Helpers', () => { entityId: mockWorkflowId, }) ) - expect(mockDb.select).toHaveBeenCalledTimes(1) + expect(mockDb.select).not.toHaveBeenCalled() expect(result.currentState).toMatchObject({ blocks: yjsState.blocks, edges: yjsState.edges, @@ -1130,7 +1117,7 @@ describe('Database Helpers', () => { }) describe('loadWorkflowState', () => { - it('returns the Yjs state without a workflow-row query when lastSynced is provided', async () => { + it('returns the Yjs state without a workflow-row query', async () => { const doc = new Y.Doc() const yjsState = { blocks: { @@ -1164,10 +1151,7 @@ describe('Database Helpers', () => { buildWorkflowSnapshotResponse(Y.encodeStateAsUpdate(doc)) ) - const result = await dbHelpers.loadWorkflowState( - mockWorkflowId, - new Date('2026-04-06T00:00:00.000Z') - ) + const result = await dbHelpers.loadWorkflowState(mockWorkflowId) expect(result).toMatchObject({ blocks: yjsState.blocks, @@ -1180,7 +1164,7 @@ describe('Database Helpers', () => { expect(mockDb.select).not.toHaveBeenCalled() }) - it('queries the workflow row for staleness when lastSynced is omitted and the Yjs snapshot is fresh', async () => { + it('does not compare Yjs snapshots against workflow row timestamps', async () => { const doc = new Y.Doc() const yjsState = { blocks: { @@ -1213,26 +1197,6 @@ describe('Database Helpers', () => { mockGetYjsSnapshot.mockResolvedValue( buildWorkflowSnapshotResponse(Y.encodeStateAsUpdate(doc)) ) - mockDb.select.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - variables: { - 'var-db': { - id: 'var-db', - workflowId: mockWorkflowId, - name: 'dbVar', - type: 'plain', - value: 'db value', - }, - }, - lastSynced: new Date('2026-04-06T00:00:00.000Z'), - }, - ]), - }), - }), - }) const result = await dbHelpers.loadWorkflowState(mockWorkflowId) @@ -1244,78 +1208,21 @@ describe('Database Helpers', () => { variables: yjsVariables, source: 'yjs', }) - expect(mockDb.select).toHaveBeenCalledTimes(1) + expect(mockDb.select).not.toHaveBeenCalled() }) - it('loads normalized tables when the Yjs bridge errors', async () => { + it('returns null when the Yjs bridge errors', async () => { mockGetYjsSnapshot.mockRejectedValueOnce( new Error('socket server unavailable') ) - let callCount = 0 - mockDb.select.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) { - return Promise.resolve(mockBlocksFromDb) - } - if (callCount === 2) { - return Promise.resolve(mockEdgesFromDb) - } - if (callCount === 3) { - return Promise.resolve(mockSubflowsFromDb) - } - if (callCount === 4) { - return { - limit: vi.fn().mockResolvedValue([ - { - variables: { - 'var-db': { - id: 'var-db', - workflowId: mockWorkflowId, - name: 'dbVar', - type: 'plain', - value: 'db value', - }, - }, - }, - ]), - } - } - return Promise.resolve([]) - }), - }), - })) - const result = await dbHelpers.loadWorkflowState(mockWorkflowId) - expect(result).toMatchObject({ - blocks: expect.objectContaining({ - 'block-1': expect.objectContaining({ - id: 'block-1', - type: 'input_trigger', - }), - }), - edges: mockEdgesFromDb.map((edge) => - expect.objectContaining({ - id: edge.id, - source: edge.sourceBlockId, - target: edge.targetBlockId, - }) - ), - variables: { - 'var-db': expect.objectContaining({ - id: 'var-db', - name: 'dbVar', - value: 'db value', - }), - }, - source: 'normalized', - }) + expect(result).toBeNull() + expect(mockDb.select).not.toHaveBeenCalled() }) - it('loads normalized tables when the stored Yjs snapshot is older than workflow lastSynced', async () => { + it('uses the stored Yjs snapshot without normalized-table fallback', async () => { const doc = new Y.Doc() setWorkflowState( doc, @@ -1342,55 +1249,18 @@ describe('Database Helpers', () => { buildWorkflowSnapshotResponse(Y.encodeStateAsUpdate(doc)) ) - let callCount = 0 - mockDb.select.mockImplementation(() => ({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockImplementation(() => { - callCount++ - if (callCount === 1) { - return { - limit: vi.fn().mockResolvedValue([ - { - variables: { - 'var-db': { - id: 'var-db', - workflowId: mockWorkflowId, - name: 'dbVar', - type: 'plain', - value: 'db value', - }, - }, - lastSynced: new Date('2026-04-06T00:05:00.000Z'), - }, - ]), - } - } - if (callCount === 2) { - return Promise.resolve(mockBlocksFromDb) - } - if (callCount === 3) { - return Promise.resolve(mockEdgesFromDb) - } - if (callCount === 4) { - return Promise.resolve(mockSubflowsFromDb) - } - return Promise.resolve([]) - }), - }), - })) - const result = await dbHelpers.loadWorkflowState(mockWorkflowId) expect(result).toMatchObject({ blocks: expect.objectContaining({ - 'block-1': expect.objectContaining({ - id: 'block-1', - type: 'input_trigger', + 'block-yjs': expect.objectContaining({ + id: 'block-yjs', + type: 'api', }), }), - source: 'normalized', + source: 'yjs', }) - expect(result?.blocks).not.toHaveProperty('block-yjs') + expect(mockDb.select).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 77e80b67f..aff73378c 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -19,8 +19,6 @@ import { import { createLogger } from '@/lib/logs/console/logger' import { resolveStoredDateValue } from '@/lib/time-format' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' -import { normalizeVariables } from '@/lib/workflows/variable-utils' -import { inferMermaidDirectionFromWorkflowState } from '@/lib/workflows/workflow-direction' import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' import { extractPersistedStateFromDoc } from '@/lib/yjs/workflow-session' import type { Variable } from '@/stores/variables/types' @@ -95,9 +93,6 @@ export type PersistedWorkflowState = { * server Yjs session through the generic Yjs snapshot transport. The socket * server resolves a live workflow doc first and otherwise falls back to its * persisted Yjs blob. - * - * Returns `null` when neither source has data for the given workflow, - * signalling the caller to fall back to the normalized DB tables. */ export async function loadWorkflowStateFromYjs( workflowId: string @@ -137,108 +132,25 @@ export async function loadWorkflowStateFromYjs( } export type WorkflowStateWithSource = PersistedWorkflowState & { - source: 'yjs' | 'normalized' + source: 'yjs' } /** - * Loads the current workflow state from Yjs (live doc or persisted session), - * then from the normalized DB tables + workflow row variables. - * - * Callers that already have the workflow row can pass `lastSynced` to avoid - * an extra staleness-check query on the common fresh-Yjs path. - * - * Returns `null` when neither source has data for the given workflow. - * - * The Yjs lookup is intentionally awaited before the DB query. Yjs is the - * authoritative source when a live session or persisted session exists, and - * running both in parallel would waste a DB round-trip in the common case - * while risking returning stale normalized-table data if the concurrent - * result were used by mistake. + * Loads the current editable workflow state from Yjs. */ export async function loadWorkflowState( - workflowId: string, - lastSynced?: Date + workflowId: string ): Promise { - const providedWorkflowLastSynced = resolveStoredDateValue(lastSynced) - let workflowRowPromise: - | Promise< - | { - variables: unknown - lastSynced: unknown - } - | undefined - > - | undefined - - const loadWorkflowRow = () => { - if (!workflowRowPromise) { - workflowRowPromise = db - .select({ variables: workflow.variables, lastSynced: workflow.lastSynced }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - .then((rows) => rows[0]) - } - - return workflowRowPromise - } - try { const yjsState = await loadWorkflowStateFromYjs(workflowId) if (yjsState) { - const workflowLastSynced = - providedWorkflowLastSynced ?? resolveStoredDateValue((await loadWorkflowRow())?.lastSynced) - const yjsLastSaved = resolveStoredDateValue(yjsState.lastSaved) - - if ( - !workflowLastSynced || - (yjsLastSaved && yjsLastSaved.getTime() >= workflowLastSynced.getTime()) - ) { - return { ...yjsState, source: 'yjs' } - } - - logger.warn( - `Ignoring stale Yjs workflow state for ${workflowId} because normalized state is newer`, - { - workflowId, - workflowLastSynced: workflowLastSynced.toISOString(), - yjsLastSaved: yjsLastSaved?.toISOString(), - } - ) + return { ...yjsState, source: 'yjs' } } } catch (error) { - logger.warn( - `Failed to load authoritative Yjs state for workflow ${workflowId}; loading normalized state`, - error - ) + logger.warn(`Failed to load Yjs state for workflow ${workflowId}`, error) } - // Load normalized tables and workflow variables in parallel - const [normalizedData, resolvedWorkflowRow] = await Promise.all([ - loadWorkflowFromNormalizedTables(workflowId), - loadWorkflowRow(), - ]) - - if (!normalizedData) { - return null - } - - return { - direction: - normalizedData.blocks && Object.keys(normalizedData.blocks).length > 0 - ? inferMermaidDirectionFromWorkflowState({ - blocks: normalizedData.blocks, - edges: normalizedData.edges, - }) - : undefined, - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, - variables: normalizeVariables(resolvedWorkflowRow?.variables), - lastSaved: Date.now(), - source: 'normalized', - } + return null } /** @@ -663,7 +575,7 @@ export async function loadDeployedWorkflowState( /** * Load workflow state from normalized tables - * Returns null if no normalized data exists. + * Returns null if materialized workflow rows are absent. */ export async function loadWorkflowFromNormalizedTables( workflowId: string diff --git a/apps/tradinggoose/lib/workflows/execution-runner.test.ts b/apps/tradinggoose/lib/workflows/execution-runner.test.ts index 6610a0f81..909334aff 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.test.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.test.ts @@ -66,7 +66,7 @@ vi.mock('@/lib/utils-server', () => ({ vi.mock('@/lib/workflows/db-helpers', () => ({ loadDeployedWorkflowState: vi.fn(), - loadWorkflowFromNormalizedTables: vi.fn(), + loadWorkflowState: vi.fn(), })) vi.mock('@/lib/workflows/triggers', () => ({ @@ -401,4 +401,32 @@ describe('loadWorkflowExecutionBlueprint', () => { expect(loadDeployedWorkflowState).not.toHaveBeenCalled() }) + + it('loads Yjs workflow state for live execution when no snapshot is supplied', async () => { + const { loadDeployedWorkflowState, loadWorkflowState } = await import( + '@/lib/workflows/db-helpers' + ) + vi.mocked(loadWorkflowState).mockResolvedValueOnce({ + blocks: { trigger: { subBlocks: {} } }, + edges: [{ source: 'trigger', target: 'worker' }], + loops: {}, + parallels: {}, + variables: { risk: { value: 1 } }, + lastSaved: Date.now(), + source: 'yjs', + }) + + const result = await loadWorkflowExecutionBlueprint({ + workflowId: 'workflow-1', + executionTarget: 'live', + workflowContext: { + workspaceId: 'workspace-1', + }, + }) + + expect(result.workflowData.blocks).toEqual({ trigger: { subBlocks: {} } }) + expect(result.workflowContext.variables).toEqual({ risk: { value: 1 } }) + expect(loadDeployedWorkflowState).not.toHaveBeenCalled() + expect(mocks.dbSelect).not.toHaveBeenCalled() + }) }) diff --git a/apps/tradinggoose/lib/workflows/execution-runner.ts b/apps/tradinggoose/lib/workflows/execution-runner.ts index 4ecdd3b6a..1014281df 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.ts @@ -8,10 +8,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { decryptSecret } from '@/lib/utils-server' -import { - loadDeployedWorkflowState, - loadWorkflowFromNormalizedTables, -} from '@/lib/workflows/db-helpers' +import { loadDeployedWorkflowState, loadWorkflowState } from '@/lib/workflows/db-helpers' import { TriggerUtils } from '@/lib/workflows/triggers' import { updateWorkflowRunCounts } from '@/lib/workflows/utils' import { normalizeVariables } from '@/lib/workflows/variable-utils' @@ -260,13 +257,24 @@ export async function loadWorkflowExecutionBlueprint(params: { workflowData?: WorkflowExecutionBlueprint['workflowData'] }): Promise { const executionTarget = params.executionTarget ?? 'deployed' + const liveWorkflowState = + executionTarget === 'live' && !params.workflowData + ? await loadWorkflowState(params.workflowId) + : null const workflowContext = await resolveRequiredWorkflowExecutionContext( params.workflowId, - params.workflowContext + executionTarget === 'live' && + liveWorkflowState && + params.workflowContext?.variables === undefined + ? { + ...params.workflowContext, + variables: liveWorkflowState.variables, + } + : params.workflowContext ) const workflowData = executionTarget === 'live' - ? (params.workflowData ?? (await loadWorkflowFromNormalizedTables(params.workflowId))) + ? (params.workflowData ?? liveWorkflowState) : await loadDeployedWorkflowState(params.workflowId) if (!workflowData) { diff --git a/apps/tradinggoose/lib/workspaces/service.ts b/apps/tradinggoose/lib/workspaces/service.ts index aef7c5a75..70877982f 100644 --- a/apps/tradinggoose/lib/workspaces/service.ts +++ b/apps/tradinggoose/lib/workspaces/service.ts @@ -2,10 +2,14 @@ import { db } from '@tradinggoose/db' import { permissions, workflow, workspace } from '@tradinggoose/db/schema' import { and, desc, eq, isNull } from 'drizzle-orm' import { buildWorkspaceAccessScope } from '@/lib/permissions/utils' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' +import { + ensureUniqueBlockIds, + ensureUniqueEdgeIds, + saveWorkflowToNormalizedTables, +} from '@/lib/workflows/db-helpers' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { toWorkspaceApiRecord } from '@/lib/workspaces/billing-owner' -import { tryApplyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' type WorkspaceRecord = typeof workspace.$inferSelect @@ -101,28 +105,26 @@ export async function createWorkspace(userId: string, name: string) { const lastSaved = now.toISOString() try { - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState) - if (!saveResult.success) { - throw new Error(saveResult.error || 'Failed to persist default workflow state') - } + const stateWithUniqueBlockIds = await ensureUniqueBlockIds(workflowId, workflowState) + const persistedWorkflowState = await ensureUniqueEdgeIds(workflowId, stateWithUniqueBlockIds) - const seedResult = await tryApplyWorkflowState( + await applyWorkflowState( workflowId, createWorkflowSnapshot({ - blocks: saveResult.normalizedState?.blocks ?? workflowState.blocks, - edges: saveResult.normalizedState?.edges ?? workflowState.edges, - loops: saveResult.normalizedState?.loops ?? workflowState.loops, - parallels: saveResult.normalizedState?.parallels ?? workflowState.parallels, + blocks: persistedWorkflowState.blocks, + edges: persistedWorkflowState.edges, + loops: persistedWorkflowState.loops, + parallels: persistedWorkflowState.parallels, lastSaved, isDeployed: false, }), undefined, 'default-agent' ) - if (!seedResult.success) { - throw seedResult.error instanceof Error - ? seedResult.error - : new Error('Failed to seed default workflow state') + + const saveResult = await saveWorkflowToNormalizedTables(workflowId, persistedWorkflowState) + if (!saveResult.success) { + throw new Error(saveResult.error || 'Failed to materialize default workflow state') } } catch (error) { await db.transaction(async (tx) => { diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index cf3a81986..9c4effd5d 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -1,9 +1,6 @@ import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' -import { createLogger } from '@/lib/logs/console/logger' import { applyWorkflowStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' -const logger = createLogger('ApplyWorkflowState') - /** * Applies a complete workflow state replacement to the Yjs doc for a workflow. * This is the server-only bridge used by POST /api/workflows, duplicate, template-use, @@ -20,26 +17,3 @@ export async function applyWorkflowState( ): Promise { await applyWorkflowStateInSocketServer(workflowId, workflowState, variables, entityName) } - -/** - * Non-fatal wrapper around `applyWorkflowState`. Catches any error, logs a - * warning, and returns a result object so callers don't need their own - * try/catch for what is typically a "best-effort" Yjs sync. - */ -export async function tryApplyWorkflowState( - workflowId: string, - workflowState: WorkflowSnapshot, - variables?: Record, - entityName?: string -): Promise<{ success: boolean; error?: unknown }> { - try { - await applyWorkflowState(workflowId, workflowState, variables, entityName) - return { success: true } - } catch (error) { - logger.warn('Failed to apply workflow state to Yjs doc (non-fatal)', { - workflowId, - error, - }) - return { success: false, error } - } -} diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 7839fed8f..01ca7dda6 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -1,6 +1,3 @@ -import { db } from '@tradinggoose/db' -import { workflow } from '@tradinggoose/db/schema' -import { eq } from 'drizzle-orm' import * as Y from 'yjs' import { buildYjsTransportEnvelope, @@ -12,14 +9,7 @@ import type { ReviewTargetDescriptor, ReviewTargetRuntimeState, } from '@/lib/copilot/review-sessions/types' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' -import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' -import { - getMetadataMap as readWorkflowMetadataMap, - setVariables, - setWorkflowState, -} from '@/lib/yjs/workflow-session' import { getState as getPersistedYjsState } from '@/socket-server/yjs/persistence' export class ReviewTargetBootstrapError extends Error { @@ -32,12 +22,6 @@ export class ReviewTargetBootstrapError extends Error { } } -const ACTIVE_RESEEDED_RUNTIME: ReviewTargetRuntimeState = { - docState: 'active', - replaySafe: false, - reseededFromCanonical: true, -} - export function getRuntimeStateFromDoc(doc: Y.Doc): ReviewTargetRuntimeState { return getReviewTargetRuntimeState(doc) } @@ -101,22 +85,6 @@ async function getExistingYjsState(sessionId: string): Promise { - const [{ getDocument, setPersistence }, { getState, storeState }] = await Promise.all([ - import('@/socket-server/yjs/upstream-utils'), - import('@/socket-server/yjs/persistence'), - ]) - - setPersistence(sessionId, { getState, storeState }) - return getDocument(sessionId) -} - -async function persistDoc(sessionId: string, doc: Y.Doc): Promise { - const { storeState } = await import('@/socket-server/yjs/persistence') - const state = Y.encodeStateAsUpdate(doc) - await storeState(sessionId, state) -} - async function resolveExistingReviewTarget( descriptor: ReviewTargetDescriptor ): Promise { @@ -133,8 +101,7 @@ async function resolveExistingReviewTarget( /** * Ensures a review target has an active Yjs document. If an active blob already - * exists it is reused; workflows can be bootstrapped from normalized workflow - * tables; saved non-workflow entities require canonical Yjs state and unsaved + * exists it is reused. Saved entities require canonical Yjs state; unsaved * drafts return the explicit expired state. */ export async function bootstrapReviewTarget( @@ -145,10 +112,6 @@ export async function bootstrapReviewTarget( return existing } - if (descriptor.entityKind === 'workflow') { - return bootstrapWorkflowTarget(descriptor) - } - if (descriptor.entityId) { throw new ReviewTargetBootstrapError(404, 'Saved entity Yjs state is missing') } @@ -162,67 +125,3 @@ export async function bootstrapReviewTarget( }, } } - -async function bootstrapWorkflowTarget( - descriptor: ReviewTargetDescriptor -): Promise { - const workflowId = descriptor.entityId ?? descriptor.yjsSessionId - if (!workflowId) { - throw new ReviewTargetBootstrapError(404, 'Workflow target is missing a workflow id') - } - - const [workflowRow] = await db - .select({ - id: workflow.id, - name: workflow.name, - workspaceId: workflow.workspaceId, - updatedAt: workflow.updatedAt, - isDeployed: workflow.isDeployed, - deployedAt: workflow.deployedAt, - variables: workflow.variables, - }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (!workflowRow) { - throw new ReviewTargetBootstrapError(404, 'Workflow target no longer exists') - } - - const normalizedState = await loadWorkflowFromNormalizedTables(workflowId) - const workflowSnapshot: WorkflowSnapshot = { - blocks: normalizedState?.blocks ?? {}, - edges: normalizedState?.edges ?? [], - loops: normalizedState?.loops ?? {}, - parallels: normalizedState?.parallels ?? {}, - lastSaved: workflowRow.updatedAt?.toISOString(), - isDeployed: workflowRow.isDeployed, - deployedAt: workflowRow.deployedAt?.toISOString(), - } - - const doc = await getBootstrapDoc(workflowId) - setWorkflowState(doc, workflowSnapshot, 'bootstrap') - setVariables( - doc, - ((workflowRow.variables as Record | null) ?? {}) as Record, - 'bootstrap' - ) - - doc.transact(() => { - const metadata = readWorkflowMetadataMap(doc) - metadata.set('entityName', workflowRow.name) - metadata.set('reseededFromCanonical', true) - }, 'bootstrap') - - await persistDoc(workflowId, doc) - - return { - descriptor: { - ...descriptor, - workspaceId: workflowRow.workspaceId ?? descriptor.workspaceId, - entityId: workflowId, - yjsSessionId: workflowId, - }, - runtime: ACTIVE_RESEEDED_RUNTIME, - } -} diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index a238018b9..ce6e9ff18 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -252,7 +252,7 @@ async function handleInternalYjsWorkflowApplyRequest( try { replaceWorkflowDocState(doc, body.workflowState, body.variables, body.entityName) - await storeState(workflowId, Y.encodeStateAsUpdate(doc)) + await storeCanonicalState(workflowId, Y.encodeStateAsUpdate(doc)) } finally { if (!liveDoc) doc.destroy() } diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts index c153de9ec..1454f1acd 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts @@ -23,6 +23,7 @@ const mockSetPersistence = vi.fn() const mockSetupWSConnection = vi.fn() const mockGetState = vi.fn() const mockStoreState = vi.fn() +const mockStoreCanonicalState = vi.fn() class MockYjsAuthError extends Error { constructor( @@ -74,6 +75,7 @@ beforeEach(() => { mockSetupWSConnection.mockReset() mockGetState.mockReset() mockStoreState.mockReset() + mockStoreCanonicalState.mockReset() vi.doMock('@/lib/logs/console/logger', () => ({ createLogger: vi.fn(() => mockLogger), @@ -114,6 +116,7 @@ beforeEach(() => { vi.doMock('./persistence', () => ({ getState: mockGetState, storeState: mockStoreState, + storeCanonicalState: mockStoreCanonicalState, })) }) @@ -223,7 +226,7 @@ describe('handleYjsUpgrade', () => { sessionId, expect.objectContaining({ getState: expect.any(Function), - storeState: expect.any(Function), + storeState: mockStoreCanonicalState, }) ) expect(wss.handleUpgrade).toHaveBeenCalledTimes(1) diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.ts index 8b7eba12e..d46891c17 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.ts @@ -114,7 +114,7 @@ async function authenticateAndPrepareUpgrade( return { userId, resolvedSessionId: pathSessionId, - canonical: descriptor.entityKind !== 'workflow' && descriptor.entityId !== null, + canonical: descriptor.reviewSessionId === null && descriptor.entityId !== null, } } diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts index 0518cfa8d..e3ae61fef 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts @@ -274,7 +274,7 @@ export async function applyAutoLayoutAndUpdateStore({ throw new Error(errorMessage) } - logger.info('Auto layout successfully persisted to database', { + logger.info('Auto layout successfully persisted', { workflowId: resolvedWorkflowId, channelId, }) @@ -284,7 +284,7 @@ export async function applyAutoLayoutAndUpdateStore({ saveError instanceof Error && saveError.message ? saveError.message : JSON.stringify(saveError) - logger.error('Failed to save auto layout to database, reverting Yjs doc:', { + logger.error('Failed to persist auto layout, reverting Yjs doc:', { workflowId: resolvedWorkflowId, error: message, }) @@ -296,7 +296,7 @@ export async function applyAutoLayoutAndUpdateStore({ return { success: false, - error: `Failed to save positions to database: ${ + error: `Failed to save positions: ${ saveError instanceof Error ? saveError.message : 'Unknown error' }`, } From 501fa1329bf29028e32f28a64eff9ae990e44cc9 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 18:38:05 -0600 Subject: [PATCH 027/284] feat(yjs): materialize saved entity state from database Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/indicators/custom/route.ts | 8 +- .../app/api/indicators/options/route.ts | 6 +- .../app/api/mcp/servers/[id]/route.ts | 30 ++--- .../tradinggoose/app/api/mcp/servers/route.ts | 22 ++-- apps/tradinggoose/app/api/monitors/shared.ts | 6 +- .../app/api/workflows/[id]/duplicate/route.ts | 35 +++--- .../app/api/workflows/[id]/route.test.ts | 2 +- .../app/api/workflows/[id]/route.ts | 16 +-- .../copilot/review-sessions/permissions.ts | 2 +- .../tools/server/entities/custom-tool.ts | 12 +- .../tools/server/entities/indicator.ts | 30 +++-- .../tools/server/entities/mcp-server.ts | 26 +++-- .../copilot/tools/server/entities/shared.ts | 29 ++--- .../copilot/tools/server/entities/skill.ts | 9 +- .../copilot/tools/server/entities/workflow.ts | 44 ++++--- .../tools/server/knowledge/knowledge-base.ts | 8 +- .../lib/custom-tools/operations.ts | 66 ++++++----- .../lib/indicators/custom/operations.ts | 66 ++++++----- apps/tradinggoose/lib/knowledge/service.ts | 73 +++++++----- apps/tradinggoose/lib/mcp/service.ts | 45 ++++---- apps/tradinggoose/lib/skills/operations.ts | 66 ++++++----- .../lib/workflows/db-helpers.test.ts | 103 ++++++++--------- apps/tradinggoose/lib/workflows/db-helpers.ts | 109 ++++++++++-------- .../lib/workflows/execution-runner.test.ts | 42 +++++++ .../lib/workflows/execution-runner.ts | 10 +- apps/tradinggoose/lib/yjs/entity-state.ts | 69 +++++------ .../lib/yjs/server/apply-entity-state.ts | 109 ++++++++++++++++++ .../lib/yjs/server/apply-workflow-state.ts | 25 +++- .../lib/yjs/server/bootstrap-review-target.ts | 80 ++++++++++++- .../lib/yjs/server/entity-loaders.ts | 66 ++++++++++- apps/tradinggoose/socket-server/index.test.ts | 4 +- .../market/indicator-monitor-runtime.ts | 4 +- 32 files changed, 800 insertions(+), 422 deletions(-) diff --git a/apps/tradinggoose/app/api/indicators/custom/route.ts b/apps/tradinggoose/app/api/indicators/custom/route.ts index 43d4acd7d..c0aa0a050 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -6,7 +6,7 @@ import { z } from 'zod' import { upsertIndicators } from '@/lib/indicators/custom/operations' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityYjsStateToRows } from '@/lib/yjs/entity-state' +import { applySavedEntityCurrentFieldsToRows } from '@/lib/yjs/entity-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { authenticateIndicatorRequest, checkWorkspacePermission } from '../utils' @@ -27,7 +27,9 @@ const logWorkspacePermissionDenied = ({ logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`) return } - logger.warn(`[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}`) + logger.warn( + `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` + ) } const IndicatorSchema = z.object({ @@ -103,7 +105,7 @@ export async function GET(request: NextRequest) { .from(pineIndicators) .where(eq(pineIndicators.workspaceId, resolvedWorkspaceId)) .orderBy(desc(pineIndicators.createdAt)) - const result = await applySavedEntityYjsStateToRows('indicator', rows) + const result = await applySavedEntityCurrentFieldsToRows('indicator', rows) return NextResponse.json({ data: result }, { status: 200 }) } catch (error) { diff --git a/apps/tradinggoose/app/api/indicators/options/route.ts b/apps/tradinggoose/app/api/indicators/options/route.ts index abbb380ce..3acd8a51b 100644 --- a/apps/tradinggoose/app/api/indicators/options/route.ts +++ b/apps/tradinggoose/app/api/indicators/options/route.ts @@ -5,11 +5,11 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { DEFAULT_INDICATOR_RUNTIME_ENTRIES } from '@/lib/indicators/default/runtime' import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' -import type { InputMetaMap } from '@/lib/indicators/types' import { isIndicatorTriggerCapable } from '@/lib/indicators/trigger-detection' +import type { InputMetaMap } from '@/lib/indicators/types' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityYjsStateToRows } from '@/lib/yjs/entity-state' +import { applySavedEntityCurrentFieldsToRows } from '@/lib/yjs/entity-state' import { authenticateIndicatorRequest, checkWorkspacePermission } from '../utils' const logger = createLogger('IndicatorOptionsAPI') @@ -97,7 +97,7 @@ export async function GET(request: NextRequest) { }) .from(pineIndicators) .where(eq(pineIndicators.workspaceId, workspaceId)) - .then((rows) => applySavedEntityYjsStateToRows('indicator', rows)) + .then((rows) => applySavedEntityCurrentFieldsToRows('indicator', rows)) const customOptions: IndicatorOptionRecord[] = customRows .filter((row) => copilotSurface || isIndicatorTriggerCapable(row.pineCode)) diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts index 1ebafe741..6aad74176 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts @@ -7,7 +7,7 @@ import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { savedEntityRowToFields } from '@/lib/yjs/entity-state' +import { applySavedEntityCurrentFieldsToRow, savedEntityRowToFields } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' import { UpdateMcpServerSchema } from '../schema' @@ -63,15 +63,9 @@ export const PATCH = withMcpAuth('write')( body.url = urlValidation.normalizedUrl } - // Remove workspaceId from body to prevent it from being updated - const { workspaceId: _, ...updateData } = body - - const [updatedServer] = await db - .update(mcpServers) - .set({ - ...updateData, - updatedAt: new Date(), - }) + const [existingServer] = await db + .select() + .from(mcpServers) .where( and( eq(mcpServers.id, serverId), @@ -79,9 +73,9 @@ export const PATCH = withMcpAuth('write')( isNull(mcpServers.deletedAt) ) ) - .returning() + .limit(1) - if (!updatedServer) { + if (!existingServer) { return createMcpErrorResponse( new Error('Server not found or access denied'), 'Server not found', @@ -89,11 +83,19 @@ export const PATCH = withMcpAuth('write')( ) } + const { workspaceId: _, ...updateData } = body + const nextServer = { + ...existingServer, + ...updateData, + updatedAt: new Date(), + } + await applySavedEntityState( 'mcp_server', - updatedServer.id, - savedEntityRowToFields('mcp_server', updatedServer) + nextServer.id, + savedEntityRowToFields('mcp_server', nextServer) ) + const updatedServer = await applySavedEntityCurrentFieldsToRow('mcp_server', nextServer) // Clear MCP service cache after update mcpService.clearCache(workspaceId) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 3bfb06406..0c5f6e38e 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -8,10 +8,7 @@ import { mcpService } from '@/lib/mcp/service' import type { McpTransport } from '@/lib/mcp/types' import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { - applySavedEntityYjsStateToRows, - savedEntityRowToFields, -} from '@/lib/yjs/entity-state' +import { applySavedEntityCurrentFieldsToRows, savedEntityRowToFields } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { CreateMcpServerSchema } from './schema' @@ -39,7 +36,7 @@ export const GET = withMcpAuth('read')( .select() .from(mcpServers) .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) - const servers = await applySavedEntityYjsStateToRows('mcp_server', rows) + const servers = await applySavedEntityCurrentFieldsToRows('mcp_server', rows) logger.info( `[${requestId}] Listed ${servers.length} MCP servers for workspace ${workspaceId}` @@ -117,11 +114,16 @@ export const POST = withMcpAuth('write')( }) .returning() - await applySavedEntityState( - 'mcp_server', - server.id, - savedEntityRowToFields('mcp_server', server) - ) + try { + await applySavedEntityState( + 'mcp_server', + server.id, + savedEntityRowToFields('mcp_server', server) + ) + } catch (error) { + await db.delete(mcpServers).where(eq(mcpServers.id, server.id)) + throw error + } mcpService.clearCache(workspaceId) diff --git a/apps/tradinggoose/app/api/monitors/shared.ts b/apps/tradinggoose/app/api/monitors/shared.ts index a2e0b5b5b..1769bf8b4 100644 --- a/apps/tradinggoose/app/api/monitors/shared.ts +++ b/apps/tradinggoose/app/api/monitors/shared.ts @@ -35,7 +35,7 @@ import { resolveTradingProviderSelectedAccount, } from '@/lib/trading/context' import { isTradingServiceError } from '@/lib/trading/errors' -import { applySavedEntityYjsStateToRows } from '@/lib/yjs/entity-state' +import { applySavedEntityCurrentFieldsToRows } from '@/lib/yjs/entity-state' type WebhookRow = typeof webhook.$inferSelect @@ -272,7 +272,7 @@ export const ensureTriggerCapableIndicator = async (workspaceId: string, indicat .from(pineIndicators) .where(and(eq(pineIndicators.id, indicatorId), eq(pineIndicators.workspaceId, workspaceId))) .limit(1) - .then((rows) => applySavedEntityYjsStateToRows('indicator', rows)) + .then((rows) => applySavedEntityCurrentFieldsToRows('indicator', rows)) const customIndicator = customRows[0] if (!customIndicator) { @@ -306,7 +306,7 @@ export const loadIndicatorInputMetadata = async ( .from(pineIndicators) .where(and(eq(pineIndicators.id, indicatorId), eq(pineIndicators.workspaceId, workspaceId))) .limit(1) - .then((rows) => applySavedEntityYjsStateToRows('indicator', rows)) + .then((rows) => applySavedEntityCurrentFieldsToRows('indicator', rows)) const row = rows[0] if (!row) { diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts index ad068a876..581581545 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -8,7 +8,6 @@ import { getStableVibrantColor } from '@/lib/colors' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' -import { normalizeVariables } from '@/lib/workflows/variable-utils' import { ensureUniqueBlockIds, ensureUniqueEdgeIds, @@ -17,6 +16,7 @@ import { remapVariableIds, saveWorkflowToNormalizedTables, } from '@/lib/workflows/db-helpers' +import { normalizeVariables } from '@/lib/workflows/variable-utils' import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' import type { Variable } from '@/stores/variables/types' @@ -31,12 +31,10 @@ const DuplicateRequestSchema = z.object({ folderId: z.string().nullable().optional(), }) -async function loadSourceWorkflowArtifacts( - sourceWorkflowId: string -): Promise<{ +async function loadSourceWorkflowArtifacts(sourceWorkflowId: string): Promise<{ workflowState: WorkflowState variables: Record - source: 'yjs' + source: 'yjs' | 'db' }> { const stateWithSource = await loadWorkflowState(sourceWorkflowId) if (!stateWithSource) { @@ -158,14 +156,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: isDeployed: false, }) - await applyWorkflowState( + await applyWorkflowState(newWorkflowId, duplicatedSnapshot, duplicatedVariables, name) + + const saveResult = await saveWorkflowToNormalizedTables( newWorkflowId, - duplicatedSnapshot, - duplicatedVariables, - name + persistedDuplicatedState ) - - const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, persistedDuplicatedState) if (!saveResult.success) { throw new Error(saveResult.error || 'Failed to materialize duplicated workflow state') } @@ -174,16 +170,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: throw duplicationError } - logger.info( - `[${requestId}] Duplicated workflow state using ${sourceArtifacts.source} source`, - { - sourceWorkflowId, - newWorkflowId, - blocksCount: Object.keys(duplicatedWorkflowState.blocks || {}).length, - edgesCount: duplicatedWorkflowState.edges?.length || 0, - variablesCount: Object.keys(duplicatedVariables).length, - } - ) + logger.info(`[${requestId}] Duplicated workflow state using ${sourceArtifacts.source} source`, { + sourceWorkflowId, + newWorkflowId, + blocksCount: Object.keys(duplicatedWorkflowState.blocks || {}).length, + edgesCount: duplicatedWorkflowState.edges?.length || 0, + variablesCount: Object.keys(duplicatedVariables).length, + }) const elapsed = Date.now() - startTime logger.info( diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index 81b53b4f4..f621ca4d6 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -313,7 +313,7 @@ describe('Workflow By ID API Route', () => { expect(data.data.state.edges).toEqual(mockWorkflowState.edges) }) - it('should return 409 when canonical Yjs state is missing', async () => { + it('should return 409 when saved workflow state is missing', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index f99cb887e..fc8db10fa 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -6,9 +6,9 @@ import { z } from 'zod' import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' import { getSession } from '@/lib/auth' import { verifyInternalTokenDetailed } from '@/lib/auth/internal' +import { hydrateListingUI } from '@/lib/listing/hydrate-ui' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { hydrateListingUI } from '@/lib/listing/hydrate-ui' import { loadWorkflowState } from '@/lib/workflows/db-helpers' import { readWorkflowAccessContext, readWorkflowById } from '@/lib/workflows/utils' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' @@ -123,11 +123,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } } - logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from authoritative state`) + logger.debug( + `[${requestId}] Attempting to load workflow ${workflowId} from authoritative state` + ) const workflowState = await loadWorkflowState(workflowId) if (!workflowState) { - logger.warn(`[${requestId}] Workflow ${workflowId} is missing canonical Yjs state`) + logger.warn(`[${requestId}] Workflow ${workflowId} is missing saved state`) return NextResponse.json({ error: 'Workflow state is missing' }, { status: 409 }) } @@ -289,10 +291,10 @@ export async function DELETE( try { await deleteYjsSessionInSocketServer(workflowId) } catch (error) { - logger.warn( - `[${requestId}] Failed to delete socket/Yjs session for workflow ${workflowId}`, - { error, workflowId } - ) + logger.warn(`[${requestId}] Failed to delete socket/Yjs session for workflow ${workflowId}`, { + error, + workflowId, + }) } const elapsed = Date.now() - startTime diff --git a/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts b/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts index 5c47c2070..f1ccee786 100644 --- a/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts +++ b/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts @@ -306,7 +306,7 @@ function hasAccessToReviewSession( /** * Loads a review session when the caller can access it. * Review-session rows are chat/draft history and remain creator-owned. - * Saved entities use canonical Yjs entity targets keyed by entityId. + * Saved entities use Yjs editing targets keyed by entityId. */ export async function loadReviewSessionForUser( reviewSessionId: string, diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts index 4452fa8ae..2e43526da 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts @@ -7,12 +7,12 @@ import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { acceptEntityDocumentReview, buildDocumentEnvelope, - executeCreateEntityDocumentMutation, - executeUpdateEntityDocumentMutation, type EntityCreateResult, type EntityListEntry, type EntityServerTool, - readSavedEntityYjsFields, + executeCreateEntityDocumentMutation, + executeUpdateEntityDocumentMutation, + readSavedEntityDocumentFields, requireEntityId, verifySavedEntityContext, verifyWorkspaceContext, @@ -112,7 +112,11 @@ export const readCustomToolServerTool: EntityServerTool = { entityId, 'read' ) - const fields = await readSavedEntityYjsFields(ENTITY_KIND_CUSTOM_TOOL, entityId, workspaceId) + const fields = await readSavedEntityDocumentFields( + ENTITY_KIND_CUSTOM_TOOL, + entityId, + workspaceId + ) return buildDocumentEnvelope(ENTITY_KIND_CUSTOM_TOOL, entityId, fields) }, } diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts index 2d7c83dbd..8fc1cdb3b 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts @@ -9,19 +9,16 @@ import { resolveDefaultIndicatorRuntimeEntry, } from '@/lib/indicators/default/runtime' import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' -import { - applySavedEntityYjsStateToRows, - savedEntityRowToFields, -} from '@/lib/yjs/entity-state' +import { applySavedEntityCurrentFieldsToRows, savedEntityRowToFields } from '@/lib/yjs/entity-state' import { acceptEntityDocumentReview, buildDocumentEnvelope, - executeCreateEntityDocumentMutation, - executeUpdateEntityDocumentMutation, type CopilotIndicatorListEntry, type EntityCreateResult, type EntityServerTool, - readSavedEntityYjsFields, + executeCreateEntityDocumentMutation, + executeUpdateEntityDocumentMutation, + readSavedEntityDocumentFields, requireEntityId, requireUserId, verifySavedEntityContext, @@ -42,7 +39,9 @@ function toDefaultIndicatorListEntry(entry: (typeof DEFAULT_INDICATOR_RUNTIME_EN } function toCustomIndicatorListEntry( - row: Awaited>>[number] + row: Awaited< + ReturnType> + >[number] ): CopilotIndicatorListEntry { const inputMeta = normalizeInputMetaMap(row.inputMeta) const inputTitles = Object.keys(inputMeta ?? {}) @@ -64,7 +63,7 @@ async function listCopilotIndicators(workspaceId: string): Promise applySavedEntityYjsStateToRows(ENTITY_KIND_INDICATOR, rows)) + .then((rows) => applySavedEntityCurrentFieldsToRows(ENTITY_KIND_INDICATOR, rows)) const customOptions = customRows.map(toCustomIndicatorListEntry) return [...defaultOptions, ...customOptions].sort((a, b) => a.name.localeCompare(b.name)) @@ -85,7 +84,9 @@ async function createIndicatorEntity( name: String(fields.name ?? ''), pineCode: String(fields.pineCode ?? ''), inputMeta: - fields.inputMeta && typeof fields.inputMeta === 'object' && !Array.isArray(fields.inputMeta) + fields.inputMeta && + typeof fields.inputMeta === 'object' && + !Array.isArray(fields.inputMeta) ? (fields.inputMeta as Record) : undefined, }, @@ -144,7 +145,7 @@ export const readIndicatorServerTool: EntityServerTool = { entityId, 'read' ) - const fields = await readSavedEntityYjsFields(ENTITY_KIND_INDICATOR, entityId, workspaceId) + const fields = await readSavedEntityDocumentFields(ENTITY_KIND_INDICATOR, entityId, workspaceId) return buildDocumentEnvelope(ENTITY_KIND_INDICATOR, entityId, fields) }, } @@ -164,7 +165,12 @@ export const createIndicatorServerTool: EntityServerTool = { export const editIndicatorServerTool: EntityServerTool = { name: 'edit_indicator', execute(args, context) { - return executeUpdateEntityDocumentMutation(ENTITY_KIND_INDICATOR, 'edit_indicator', args, context) + return executeUpdateEntityDocumentMutation( + ENTITY_KIND_INDICATOR, + 'edit_indicator', + args, + context + ) }, } diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts index 62ea6a664..dcebdee29 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -6,20 +6,17 @@ import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' import { mcpService } from '@/lib/mcp/service' import type { McpTransport } from '@/lib/mcp/types' import { validateMcpServerUrl } from '@/lib/mcp/url-validator' -import { - applySavedEntityYjsStateToRows, - savedEntityRowToFields, -} from '@/lib/yjs/entity-state' +import { applySavedEntityCurrentFieldsToRows, savedEntityRowToFields } from '@/lib/yjs/entity-state' import { acceptEntityDocumentReview, applySavedEntityDocument, buildDocumentEnvelope, - executeCreateEntityDocumentMutation, - executeUpdateEntityDocumentMutation, type EntityCreateResult, type EntityListEntry, type EntityServerTool, - readSavedEntityYjsFields, + executeCreateEntityDocumentMutation, + executeUpdateEntityDocumentMutation, + readSavedEntityDocumentFields, requireEntityId, verifySavedEntityContext, verifyWorkspaceContext, @@ -116,7 +113,12 @@ async function createMcpServerEntity( } const savedFields = savedEntityRowToFields(ENTITY_KIND_MCP_SERVER, row) - await applySavedEntityDocument(ENTITY_KIND_MCP_SERVER, row.id, savedFields) + try { + await applySavedEntityDocument(ENTITY_KIND_MCP_SERVER, row.id, savedFields) + } catch (error) { + await db.delete(mcpServers).where(eq(mcpServers.id, row.id)) + throw error + } mcpService.clearCache(workspaceId) return { @@ -149,7 +151,7 @@ export const listMcpServersServerTool: EntityServerTool> = .select() .from(mcpServers) .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) - .then((serverRows) => applySavedEntityYjsStateToRows(ENTITY_KIND_MCP_SERVER, serverRows)) + .then((serverRows) => applySavedEntityCurrentFieldsToRows(ENTITY_KIND_MCP_SERVER, serverRows)) const entities = rows.map(toMcpServerListEntry) return { @@ -170,7 +172,11 @@ export const readMcpServerServerTool: EntityServerTool = { entityId, 'read' ) - const fields = await readSavedEntityYjsFields(ENTITY_KIND_MCP_SERVER, entityId, workspaceId) + const fields = await readSavedEntityDocumentFields( + ENTITY_KIND_MCP_SERVER, + entityId, + workspaceId + ) return buildDocumentEnvelope(ENTITY_KIND_MCP_SERVER, entityId, fields) }, } diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index 3ece9c79b..b9a6d061f 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -1,4 +1,3 @@ -import * as Y from 'yjs' import { type EntityDocumentKind, getEntityDocumentFormat, @@ -16,11 +15,8 @@ import { withWorkspaceArgContext, } from '@/lib/copilot/tools/server/base-tool' import { checkWorkspaceAccess } from '@/lib/permissions/utils' -import { getEntityFields } from '@/lib/yjs/entity-session' -import type { SavedEntityKind } from '@/lib/yjs/entity-state' -import { buildSavedEntityYjsDescriptor } from '@/lib/yjs/entity-state' +import { readSavedEntityFields, type SavedEntityKind } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' -import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' export type SavedEntityDocumentKind = EntityDocumentKind export type EntityDocumentArgs = { @@ -163,7 +159,9 @@ export function parseEntityMutationDocument( const expectedFormat = getEntityDocumentFormat(kind) if (args.documentFormat && args.documentFormat !== expectedFormat) { - throw new Error(`Unsupported documentFormat "${args.documentFormat}". Expected ${expectedFormat}`) + throw new Error( + `Unsupported documentFormat "${args.documentFormat}". Expected ${expectedFormat}` + ) } return parseEntityDocument(kind, entityDocument) @@ -194,25 +192,12 @@ export function buildDocumentDiff( } } -export async function readSavedEntityYjsFields( +export async function readSavedEntityDocumentFields( kind: SavedEntityDocumentKind, entityId: string, workspaceId: string ): Promise> { - const descriptor = buildSavedEntityYjsDescriptor(kind as SavedEntityKind, entityId, workspaceId) - const snapshot = await readBootstrappedReviewTargetSnapshot(descriptor) - - if (!snapshot.snapshotBase64) { - throw new Error(`Current Yjs ${ENTITY_KIND_LABELS[kind]} state is required for ${entityId}`) - } - - const doc = new Y.Doc() - try { - Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) - return getEntityFields(doc, kind as SavedEntityKind) - } finally { - doc.destroy() - } + return readSavedEntityFields(kind as SavedEntityKind, entityId, workspaceId) } export async function applySavedEntityDocument( @@ -276,7 +261,7 @@ export async function executeUpdateEntityDocumentMutation( const { workspaceId } = await verifySavedEntityContext(context, kind, entityId, 'write') if (shouldStageServerToolMutationForReview(context)) { - const currentFields = await readSavedEntityYjsFields(kind, entityId, workspaceId) + const currentFields = await readSavedEntityDocumentFields(kind, entityId, workspaceId) return { requiresReview: true, success: true, diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts index a63859460..57fafcfb7 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts @@ -6,13 +6,12 @@ import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { acceptEntityDocumentReview, buildDocumentEnvelope, - executeCreateEntityDocumentMutation, - executeUpdateEntityDocumentMutation, type EntityCreateResult, - type EntityDocumentArgs, type EntityListEntry, type EntityServerTool, - readSavedEntityYjsFields, + executeCreateEntityDocumentMutation, + executeUpdateEntityDocumentMutation, + readSavedEntityDocumentFields, requireEntityId, verifySavedEntityContext, verifyWorkspaceContext, @@ -84,7 +83,7 @@ export const readSkillServerTool: EntityServerTool = { entityId, 'read' ) - const fields = await readSavedEntityYjsFields(ENTITY_KIND_SKILL, entityId, workspaceId) + const fields = await readSavedEntityDocumentFields(ENTITY_KIND_SKILL, entityId, workspaceId) return buildDocumentEnvelope(ENTITY_KIND_SKILL, entityId, fields) }, } diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index c49970153..19e9e1923 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -7,6 +7,7 @@ import { getStableVibrantColor } from '@/lib/colors' import { WORKFLOW_VARIABLE_DOCUMENT_FORMAT } from '@/lib/copilot/entity-documents' import { verifyWorkflowAccess } from '@/lib/copilot/review-sessions/permissions' import { ENTITY_KIND_WORKFLOW, type ReviewAccessMode } from '@/lib/copilot/review-sessions/types' +import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target' import type { BaseServerTool, ServerToolExecutionContext, @@ -15,9 +16,11 @@ import { shouldStageServerToolMutationForReview, withWorkspaceArgContext, } from '@/lib/copilot/tools/server/base-tool' -import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target' +import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow' +import { editWorkflowBlockServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow-block' import { generateCreativeWorkflowName } from '@/lib/naming' import { VariableManager } from '@/lib/variables/variable-manager' +import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' import { TG_MERMAID_DOCUMENT_FORMAT, WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT, @@ -27,17 +30,16 @@ import { readWorkflowEdgeScope, serializeWorkflowToTgMermaid, } from '@/lib/workflows/studio-workflow-mermaid' -import { applyWorkflowStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { isWorkflowVariableType, type WorkflowVariableType } from '@/lib/workflows/value-types' +import { applyWorkflowEntityName } from '@/lib/yjs/server/apply-workflow-state' import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' +import { applyWorkflowStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { - getVariablesSnapshot, createWorkflowSnapshot, + getVariablesSnapshot, readWorkflowSnapshot, type WorkflowSnapshot, } from '@/lib/yjs/workflow-session' -import { isWorkflowVariableType, type WorkflowVariableType } from '@/lib/workflows/value-types' -import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow' -import { editWorkflowBlockServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow-block' import { requireUserId, verifyWorkspaceContext } from './shared' type WorkflowSummary = { @@ -477,7 +479,16 @@ export const createWorkflowServerTool: BaseServerTool< marketplaceData: null, }) - await applyWorkflowStateInSocketServer(workflowId, workflowState, {}, name) + try { + await applyWorkflowStateInSocketServer(workflowId, workflowState, {}, name) + const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState) + if (!saveResult.success) { + throw new Error(saveResult.error || 'Failed to materialize initial workflow state') + } + } catch (error) { + await db.delete(workflow).where(eq(workflow.id, workflowId)) + throw error + } return { success: true, @@ -498,23 +509,20 @@ export const renameWorkflowServerTool: BaseServerTool<{ entityId: string; name: throw new Error('name is required') } - await verifyWorkflowContext(workflowId, context, 'write') - const [updatedWorkflow] = await db - .update(workflow) - .set({ name: nextName, updatedAt: new Date() }) - .where(eq(workflow.id, workflowId)) - .returning() - - if (!updatedWorkflow) { - throw new Error('Workflow not found') - } + const current = await loadWorkflowSnapshotForCopilot(workflowId, context, 'write') + const updatedWorkflow = await applyWorkflowEntityName( + workflowId, + current.workflowState, + current.variables, + nextName + ) return { success: true, entityKind: ENTITY_KIND_WORKFLOW, entityId: workflowId, entityName: nextName, - workspaceId: updatedWorkflow.workspaceId ?? undefined, + workspaceId: updatedWorkflow.workspaceId ?? current.workspaceId ?? undefined, } }, } diff --git a/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts index 326474ead..6ecbb59e9 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -17,12 +17,12 @@ import { getQueryStrategy, handleVectorOnlySearch } from '@/app/api/knowledge/se import { acceptEntityDocumentReview, buildDocumentEnvelope, - executeCreateEntityDocumentMutation, - executeUpdateEntityDocumentMutation, type EntityCreateResult, type EntityDocumentArgs, type EntityServerTool, - readSavedEntityYjsFields, + executeCreateEntityDocumentMutation, + executeUpdateEntityDocumentMutation, + readSavedEntityDocumentFields, requireEntityId, verifySavedEntityContext, verifyWorkspaceContext, @@ -127,7 +127,7 @@ export const readKnowledgeBaseServerTool: EntityServerTool = { ) const [kb, fields] = await Promise.all([ getKnowledgeBaseById(entityId), - readSavedEntityYjsFields(ENTITY_KIND_KNOWLEDGE_BASE, entityId, workspaceId), + readSavedEntityDocumentFields(ENTITY_KIND_KNOWLEDGE_BASE, entityId, workspaceId), ]) if (!kb) { throw new Error('Knowledge base not found') diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index 1a8047b3c..a183bcd90 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -1,6 +1,6 @@ import { db } from '@tradinggoose/db' import { customTools } from '@tradinggoose/db/schema' -import { and, desc, eq } from 'drizzle-orm' +import { and, desc, eq, inArray } from 'drizzle-orm' import { nanoid } from 'nanoid' import { type CustomToolTransferRecord, @@ -8,10 +8,7 @@ import { } from '@/lib/custom-tools/import-export' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { - applySavedEntityYjsStateToRows, - seedSavedEntityYjsStateFromRows, -} from '@/lib/yjs/entity-state' +import { applySavedEntityCurrentFieldsToRows, applySavedEntityRows } from '@/lib/yjs/entity-state' const logger = createLogger('CustomToolsOperations') @@ -41,7 +38,7 @@ export async function listCustomTools(params: { workspaceId: string }) { .where(eq(customTools.workspaceId, params.workspaceId)) .orderBy(desc(customTools.createdAt)) - return applySavedEntityYjsStateToRows('custom_tool', rows) + return applySavedEntityCurrentFieldsToRows('custom_tool', rows) } /** @@ -53,7 +50,9 @@ export async function upsertCustomTools({ userId, requestId = generateRequestId(), }: UpsertCustomToolsParams) { - const affectedIds: string[] = [] + const createdRows: Array = [] + const updatedRows: Array = [] + const createdIds: string[] = [] const result = await db.transaction(async (tx) => { for (const tool of tools) { const nowTime = new Date() @@ -75,24 +74,20 @@ export async function upsertCustomTools({ .limit(1) if (existingTool.length > 0) { - await tx - .update(customTools) - .set({ - title: tool.title, - schema: tool.schema, - code: tool.code, - updatedAt: nowTime, - }) - .where(eq(customTools.id, tool.id)) - logger.info(`[${requestId}] Updated custom tool ${tool.id}`) - affectedIds.push(tool.id) + updatedRows.push({ + ...existingTool[0], + title: tool.title, + schema: tool.schema, + code: tool.code, + updatedAt: nowTime, + }) continue } } const toolId = tool.id || nanoid() - await tx.insert(customTools).values({ + const newTool = { id: toolId, workspaceId, userId, @@ -101,10 +96,12 @@ export async function upsertCustomTools({ code: tool.code, createdAt: nowTime, updatedAt: nowTime, - }) + } + await tx.insert(customTools).values(newTool) logger.info(`[${requestId}] Created custom tool ${tool.title}`) - affectedIds.push(toolId) + createdRows.push(newTool) + createdIds.push(toolId) } return tx @@ -114,12 +111,16 @@ export async function upsertCustomTools({ .orderBy(desc(customTools.createdAt)) }) - await seedSavedEntityYjsStateFromRows( - 'custom_tool', - result.filter((row) => affectedIds.includes(row.id)) - ) + await applySavedEntityRows('custom_tool', createdRows, { + rollbackRows: async () => { + if (createdIds.length > 0) { + await db.delete(customTools).where(inArray(customTools.id, createdIds)) + } + }, + }) + await applySavedEntityRows('custom_tool', updatedRows) - return applySavedEntityYjsStateToRows('custom_tool', result) + return applySavedEntityCurrentFieldsToRows('custom_tool', result) } export async function importCustomTools({ @@ -169,7 +170,18 @@ export async function importCustomTools({ } }) - await seedSavedEntityYjsStateFromRows('custom_tool', result.tools) + await applySavedEntityRows('custom_tool', result.tools, { + rollbackRows: async () => { + if (result.tools.length > 0) { + await db.delete(customTools).where( + inArray( + customTools.id, + result.tools.map((row) => row.id) + ) + ) + } + }, + }) return result } diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index 419bbee83..4068f654e 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -1,6 +1,6 @@ import { db } from '@tradinggoose/db' import { pineIndicators } from '@tradinggoose/db/schema' -import { and, desc, eq } from 'drizzle-orm' +import { and, desc, eq, inArray } from 'drizzle-orm' import { getStableVibrantColor } from '@/lib/colors' import { type IndicatorTransferRecord, @@ -9,10 +9,7 @@ import { import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { - applySavedEntityYjsStateToRows, - seedSavedEntityYjsStateFromRows, -} from '@/lib/yjs/entity-state' +import { applySavedEntityCurrentFieldsToRows, applySavedEntityRows } from '@/lib/yjs/entity-state' const logger = createLogger('IndicatorsOperations') @@ -55,7 +52,9 @@ export async function upsertIndicators({ userId, requestId = generateRequestId(), }: UpsertIndicatorsParams) { - const affectedIds: string[] = [] + const createdRows: Array = [] + const updatedRows: Array = [] + const createdIds: string[] = [] const result = await db.transaction(async (tx) => { for (const indicator of indicators) { const nowTime = new Date() @@ -72,25 +71,21 @@ export async function upsertIndicators({ if (existing.length > 0) { const existingColor = existing[0]?.color - await tx - .update(pineIndicators) - .set({ - name: indicator.name, - color: existingColor ?? getStableVibrantColor(indicator.id), - pineCode: indicator.pineCode, - inputMeta: indicator.inputMeta ?? null, - updatedAt: nowTime, - }) - .where(eq(pineIndicators.id, indicator.id)) - logger.info(`[${requestId}] Updated Indicator ${indicator.id}`) - affectedIds.push(indicator.id) + updatedRows.push({ + ...existing[0], + name: indicator.name, + color: existingColor ?? getStableVibrantColor(indicator.id), + pineCode: indicator.pineCode, + inputMeta: indicator.inputMeta ?? null, + updatedAt: nowTime, + }) continue } } const indicatorId = indicator.id ?? crypto.randomUUID() - await tx.insert(pineIndicators).values({ + const newIndicator = { id: indicatorId, workspaceId, userId, @@ -100,10 +95,12 @@ export async function upsertIndicators({ inputMeta: indicator.inputMeta ?? null, createdAt: nowTime, updatedAt: nowTime, - }) + } + await tx.insert(pineIndicators).values(newIndicator) logger.info(`[${requestId}] Created Indicator ${indicator.name}`) - affectedIds.push(indicatorId) + createdRows.push(newIndicator) + createdIds.push(indicatorId) } return tx @@ -113,12 +110,16 @@ export async function upsertIndicators({ .orderBy(desc(pineIndicators.createdAt)) }) - await seedSavedEntityYjsStateFromRows( - 'indicator', - result.filter((row) => affectedIds.includes(row.id)) - ) + await applySavedEntityRows('indicator', createdRows, { + rollbackRows: async () => { + if (createdIds.length > 0) { + await db.delete(pineIndicators).where(inArray(pineIndicators.id, createdIds)) + } + }, + }) + await applySavedEntityRows('indicator', updatedRows) - return applySavedEntityYjsStateToRows('indicator', result) + return applySavedEntityCurrentFieldsToRows('indicator', result) } export async function importIndicators({ @@ -174,7 +175,18 @@ export async function importIndicators({ } }) - await seedSavedEntityYjsStateFromRows('indicator', result.indicators) + await applySavedEntityRows('indicator', result.indicators, { + rollbackRows: async () => { + if (result.indicators.length > 0) { + await db.delete(pineIndicators).where( + inArray( + pineIndicators.id, + result.indicators.map((row) => row.id) + ) + ) + } + }, + }) return result } diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index 71163b445..53aca9ad4 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -22,8 +22,8 @@ import type { import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/permissions/utils' import { - applySavedEntityYjsStateToRow, - applySavedEntityYjsStateToRows, + applySavedEntityCurrentFieldsToRow, + applySavedEntityCurrentFieldsToRows, savedEntityRowToFields, } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' @@ -66,7 +66,7 @@ export async function getKnowledgeBases( .groupBy(knowledgeBase.id) .orderBy(knowledgeBase.createdAt) - return applySavedEntityYjsStateToRows( + return applySavedEntityCurrentFieldsToRows( ENTITY_KIND_KNOWLEDGE_BASE, knowledgeBasesWithCounts.map((kb) => ({ ...kb, @@ -124,11 +124,16 @@ export async function createKnowledgeBase( docCount: 0, } - await applySavedEntityState( - ENTITY_KIND_KNOWLEDGE_BASE, - created.id, - savedEntityRowToFields(ENTITY_KIND_KNOWLEDGE_BASE, created) - ) + try { + await applySavedEntityState( + ENTITY_KIND_KNOWLEDGE_BASE, + created.id, + savedEntityRowToFields(ENTITY_KIND_KNOWLEDGE_BASE, created) + ) + } catch (error) { + await db.delete(knowledgeBase).where(eq(knowledgeBase.id, kbId)) + throw error + } return created } @@ -153,7 +158,7 @@ export async function copyKnowledgeBaseToWorkspace( if (!sourceKnowledgeBase) { throw new Error(`Knowledge base ${sourceKnowledgeBaseId} not found`) } - const sourceKnowledgeBaseFields = await applySavedEntityYjsStateToRow( + const sourceKnowledgeBaseFields = await applySavedEntityCurrentFieldsToRow( ENTITY_KIND_KNOWLEDGE_BASE, sourceKnowledgeBase ) @@ -341,22 +346,6 @@ export async function copyKnowledgeBaseToWorkspace( throw error } - if (totalDocumentSize > 0) { - try { - await incrementStorageUsage(userId, totalDocumentSize, targetWorkspaceId) - } catch (error) { - logger.error(`[${requestId}] Failed to update copied knowledge base storage usage:`, error) - } - } - - if (processingJobs.length > 0) { - await enqueueDocumentProcessingJobs(processingJobs, requestId) - } - - logger.info( - `[${requestId}] Copied knowledge base ${sourceKnowledgeBaseId} to workspace ${targetWorkspaceId} as ${newKnowledgeBaseId}` - ) - const copied = { id: newKnowledgeBaseId, name: `${sourceKnowledgeBaseFields.name} (Copy)`, @@ -371,10 +360,34 @@ export async function copyKnowledgeBaseToWorkspace( docCount: sourceDocuments.length, } - await applySavedEntityState( - ENTITY_KIND_KNOWLEDGE_BASE, - copied.id, - savedEntityRowToFields(ENTITY_KIND_KNOWLEDGE_BASE, copied) + try { + await applySavedEntityState( + ENTITY_KIND_KNOWLEDGE_BASE, + copied.id, + savedEntityRowToFields(ENTITY_KIND_KNOWLEDGE_BASE, copied) + ) + } catch (error) { + await db.delete(knowledgeBase).where(eq(knowledgeBase.id, newKnowledgeBaseId)) + if (copiedDocuments.length > 0) { + await deleteKnowledgeDocumentFiles(copiedDocuments.map(({ fileUrl }) => fileUrl)) + } + throw error + } + + if (totalDocumentSize > 0) { + try { + await incrementStorageUsage(userId, totalDocumentSize, targetWorkspaceId) + } catch (error) { + logger.error(`[${requestId}] Failed to update copied knowledge base storage usage:`, error) + } + } + + if (processingJobs.length > 0) { + await enqueueDocumentProcessingJobs(processingJobs, requestId) + } + + logger.info( + `[${requestId}] Copied knowledge base ${sourceKnowledgeBaseId} to workspace ${targetWorkspaceId} as ${newKnowledgeBaseId}` ) return copied @@ -434,7 +447,7 @@ export async function getKnowledgeBaseById( return null } - return applySavedEntityYjsStateToRow(ENTITY_KIND_KNOWLEDGE_BASE, { + return applySavedEntityCurrentFieldsToRow(ENTITY_KIND_KNOWLEDGE_BASE, { ...result[0], chunkingConfig: result[0].chunkingConfig as ChunkingConfig, docCount: Number(result[0].docCount), diff --git a/apps/tradinggoose/lib/mcp/service.ts b/apps/tradinggoose/lib/mcp/service.ts index a6bebd787..76a069f07 100644 --- a/apps/tradinggoose/lib/mcp/service.ts +++ b/apps/tradinggoose/lib/mcp/service.ts @@ -20,8 +20,8 @@ import type { import { MCP_CONSTANTS } from '@/lib/mcp/utils' import { generateRequestId } from '@/lib/utils' import { - applySavedEntityYjsStateToRow, - applySavedEntityYjsStateToRows, + applySavedEntityCurrentFieldsToRow, + applySavedEntityCurrentFieldsToRows, } from '@/lib/yjs/entity-state' const logger = createLogger('McpService') @@ -201,7 +201,7 @@ class McpService { if (missingVars.length > 0) { throw new Error( `Missing required environment variable${missingVars.length > 1 ? 's' : ''}: ${missingVars.join(', ')}. ` + - `Please set ${missingVars.length > 1 ? 'these variables' : 'this variable'} in your workspace or personal environment settings.` + `Please set ${missingVars.length > 1 ? 'these variables' : 'this variable'} in your workspace or personal environment settings.` ) } @@ -263,7 +263,7 @@ class McpService { return null } - const config = await applySavedEntityYjsStateToRow('mcp_server', server) + const config = await applySavedEntityCurrentFieldsToRow('mcp_server', server) if (!config.enabled) { return null } @@ -287,30 +287,29 @@ class McpService { * Get all enabled servers for a workspace */ private async getWorkspaceServers(workspaceId: string): Promise { - const whereConditions = [ - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt), - ] + const whereConditions = [eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt)] const rows = await db .select() .from(mcpServers) .where(and(...whereConditions)) - const servers = await applySavedEntityYjsStateToRows('mcp_server', rows) - - return servers.filter((server) => server.enabled).map((server) => ({ - id: server.id, - name: server.name, - description: server.description || undefined, - transport: server.transport as McpTransport, - url: server.url || undefined, - headers: (server.headers as Record) || {}, - timeout: server.timeout || 30000, - retries: server.retries || 3, - enabled: server.enabled, - createdAt: server.createdAt.toISOString(), - updatedAt: server.updatedAt.toISOString(), - })) + const servers = await applySavedEntityCurrentFieldsToRows('mcp_server', rows) + + return servers + .filter((server) => server.enabled) + .map((server) => ({ + id: server.id, + name: server.name, + description: server.description || undefined, + transport: server.transport as McpTransport, + url: server.url || undefined, + headers: (server.headers as Record) || {}, + timeout: server.timeout || 30000, + retries: server.retries || 3, + enabled: server.enabled, + createdAt: server.createdAt.toISOString(), + updatedAt: server.updatedAt.toISOString(), + })) } /** diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index 1606be40a..8fe84971e 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -1,6 +1,6 @@ import { db } from '@tradinggoose/db' import { skill } from '@tradinggoose/db/schema' -import { and, desc, eq, ne } from 'drizzle-orm' +import { and, desc, eq, inArray, ne } from 'drizzle-orm' import { nanoid } from 'nanoid' import { createLogger } from '@/lib/logs/console/logger' import { @@ -9,10 +9,7 @@ import { type SkillTransferRecord, } from '@/lib/skills/import-export' import { generateRequestId } from '@/lib/utils' -import { - applySavedEntityYjsStateToRows, - seedSavedEntityYjsStateFromRows, -} from '@/lib/yjs/entity-state' +import { applySavedEntityCurrentFieldsToRows, applySavedEntityRows } from '@/lib/yjs/entity-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('SkillsOperations') @@ -43,7 +40,7 @@ export async function listSkills(params: { workspaceId: string }) { .where(eq(skill.workspaceId, params.workspaceId)) .orderBy(desc(skill.createdAt)) - return applySavedEntityYjsStateToRows('skill', rows) + return applySavedEntityCurrentFieldsToRows('skill', rows) } export async function deleteSkill(params: { @@ -75,7 +72,9 @@ export async function upsertSkills({ userId, requestId = generateRequestId(), }: UpsertSkillsParams) { - const affectedIds: string[] = [] + const createdRows: Array = [] + const updatedRows: Array = [] + const createdIds: string[] = [] const result = await db.transaction(async (tx) => { for (const currentSkill of skills) { const nowTime = new Date() @@ -108,18 +107,14 @@ export async function upsertSkills({ } } - await tx - .update(skill) - .set({ - name: currentSkill.name, - description: currentSkill.description, - content: currentSkill.content, - updatedAt: nowTime, - }) - .where(and(eq(skill.id, currentSkill.id), eq(skill.workspaceId, workspaceId))) - logger.info(`[${requestId}] Updated skill ${currentSkill.id}`) - affectedIds.push(currentSkill.id) + updatedRows.push({ + ...existingSkill[0], + name: currentSkill.name, + description: currentSkill.description, + content: currentSkill.content, + updatedAt: nowTime, + }) continue } } @@ -137,7 +132,7 @@ export async function upsertSkills({ } const skillId = currentSkill.id || nanoid() - await tx.insert(skill).values({ + const newSkill = { id: skillId, workspaceId, userId, @@ -146,10 +141,12 @@ export async function upsertSkills({ content: currentSkill.content, createdAt: nowTime, updatedAt: nowTime, - }) + } + await tx.insert(skill).values(newSkill) logger.info(`[${requestId}] Created skill "${currentSkill.name}"`) - affectedIds.push(skillId) + createdRows.push(newSkill) + createdIds.push(skillId) } return tx @@ -159,12 +156,16 @@ export async function upsertSkills({ .orderBy(desc(skill.createdAt)) }) - await seedSavedEntityYjsStateFromRows( - 'skill', - result.filter((row) => affectedIds.includes(row.id)) - ) + await applySavedEntityRows('skill', createdRows, { + rollbackRows: async () => { + if (createdIds.length > 0) { + await db.delete(skill).where(inArray(skill.id, createdIds)) + } + }, + }) + await applySavedEntityRows('skill', updatedRows) - return applySavedEntityYjsStateToRows('skill', result) + return applySavedEntityCurrentFieldsToRows('skill', result) } export async function importSkills({ @@ -227,7 +228,18 @@ export async function importSkills({ } }) - await seedSavedEntityYjsStateFromRows('skill', result.skills) + await applySavedEntityRows('skill', result.skills, { + rollbackRows: async () => { + if (result.skills.length > 0) { + await db.delete(skill).where( + inArray( + skill.id, + result.skills.map((row) => row.id) + ) + ) + } + }, + }) return result } diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index 73836f151..1e4311ec5 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -8,8 +8,8 @@ */ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import * as Y from 'yjs' -import type { WorkflowState } from '@/stores/workflows/workflow/types' import { setVariables, setWorkflowState } from '@/lib/yjs/workflow-session' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const mockDb = { select: vi.fn(), @@ -23,6 +23,7 @@ const mockWorkflowTable = { id: 'id', variables: 'variables', lastSynced: 'lastSynced', + updatedAt: 'updatedAt', userId: 'userId', } @@ -106,19 +107,19 @@ vi.doMock('@/lib/logs/console/logger', () => ({ })) const mockReconcilePublishedChatsForDeploymentTx = vi.fn() -const mockGetYjsSnapshot = vi.fn() -class MockSocketServerBridgeError extends Error { +const mockReadBootstrappedReviewTargetSnapshot = vi.fn() +class MockReviewTargetBootstrapError extends Error { constructor( public status: number, - public body: string + message: string ) { - super(body) - this.name = 'SocketServerBridgeError' + super(message) + this.name = 'ReviewTargetBootstrapError' } } -vi.doMock('@/lib/yjs/server/snapshot-bridge', () => ({ - getYjsSnapshot: mockGetYjsSnapshot, - SocketServerBridgeError: MockSocketServerBridgeError, +vi.doMock('@/lib/yjs/server/bootstrap-review-target', () => ({ + readBootstrappedReviewTargetSnapshot: mockReadBootstrappedReviewTargetSnapshot, + ReviewTargetBootstrapError: MockReviewTargetBootstrapError, })) vi.doMock('@/lib/chat/published-deployment', () => ({ @@ -278,7 +279,9 @@ const mockWorkflowState: WorkflowState = { deploymentStatuses: {}, } -const createMockTx = (overrides: Partial> = {}) => ({ +const createMockTx = ( + overrides: Partial> = {} +) => ({ execute: overrides.execute ?? vi.fn().mockResolvedValue([]), update: overrides.update ?? @@ -316,7 +319,9 @@ describe('Database Helpers', () => { beforeEach(() => { vi.clearAllMocks() - mockGetYjsSnapshot.mockRejectedValue(new MockSocketServerBridgeError(404, 'Not found')) + mockReadBootstrappedReviewTargetSnapshot.mockRejectedValue( + new MockReviewTargetBootstrapError(404, 'Not found') + ) mockReconcilePublishedChatsForDeploymentTx.mockResolvedValue(undefined) mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ @@ -517,7 +522,7 @@ describe('Database Helpers', () => { it('should handle database connection errors gracefully', async () => { const connectionError = new Error('Connection refused') - ; (connectionError as any).code = 'ECONNREFUSED' + ;(connectionError as any).code = 'ECONNREFUSED' // Mock database connection error mockDb.select.mockReturnValue({ @@ -538,9 +543,9 @@ describe('Database Helpers', () => { }) it('should successfully save workflow data to normalized tables', async () => { - const mockTransaction = vi.fn().mockImplementation(async (callback) => - callback(createMockTx()) - ) + const mockTransaction = vi + .fn() + .mockImplementation(async (callback) => callback(createMockTx())) mockDb.transaction = mockTransaction @@ -567,9 +572,9 @@ describe('Database Helpers', () => { deploymentStatuses: {}, } - const mockTransaction = vi.fn().mockImplementation(async (callback) => - callback(createMockTx()) - ) + const mockTransaction = vi + .fn() + .mockImplementation(async (callback) => callback(createMockTx())) mockDb.transaction = mockTransaction @@ -596,7 +601,7 @@ describe('Database Helpers', () => { it('should handle database constraint errors', async () => { const constraintError = new Error('Unique constraint violation') - ; (constraintError as any).code = '23505' + ;(constraintError as any).code = '23505' const mockTransaction = vi.fn().mockRejectedValue(constraintError) mockDb.transaction = mockTransaction @@ -837,9 +842,9 @@ describe('Database Helpers', () => { } it('should successfully migrate workflow from JSON to normalized tables', async () => { - const mockTransaction = vi.fn().mockImplementation(async (callback) => - callback(createMockTx()) - ) + const mockTransaction = vi + .fn() + .mockImplementation(async (callback) => callback(createMockTx())) mockDb.transaction = mockTransaction @@ -872,9 +877,9 @@ describe('Database Helpers', () => { // Missing loops, parallels, and other properties } - const mockTransaction = vi.fn().mockImplementation(async (callback) => - callback(createMockTx()) - ) + const mockTransaction = vi + .fn() + .mockImplementation(async (callback) => callback(createMockTx())) mockDb.transaction = mockTransaction @@ -932,9 +937,9 @@ describe('Database Helpers', () => { }) } - const mockTransaction = vi.fn().mockImplementation(async (callback) => - callback(createMockTx()) - ) + const mockTransaction = vi + .fn() + .mockImplementation(async (callback) => callback(createMockTx())) mockDb.transaction = mockTransaction @@ -979,7 +984,7 @@ describe('Database Helpers', () => { setWorkflowState(doc, yjsState, 'test') setVariables(doc, yjsVariables, 'test') - mockGetYjsSnapshot.mockResolvedValue( + mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( buildWorkflowSnapshotResponse(Y.encodeStateAsUpdate(doc)) ) @@ -1016,14 +1021,11 @@ describe('Database Helpers', () => { }) expect(result.success).toBe(true) - expect(mockGetYjsSnapshot).toHaveBeenCalledWith( - mockWorkflowId, + expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalledWith( expect.objectContaining({ - targetKind: 'workflow', - sessionId: mockWorkflowId, - workflowId: mockWorkflowId, entityKind: 'workflow', entityId: mockWorkflowId, + yjsSessionId: mockWorkflowId, }) ) expect(mockDb.select).not.toHaveBeenCalled() @@ -1042,6 +1044,7 @@ describe('Database Helpers', () => { blocks: yjsState.blocks, variables: yjsVariables, }) + expect(deploymentInsert?.data.state).not.toHaveProperty('source') const workflowUpdate = updateCalls.find((call) => call.table === mockWorkflowTable) expect(workflowUpdate?.data.variables).toEqual(yjsVariables) @@ -1060,7 +1063,7 @@ describe('Database Helpers', () => { }) describe('loadWorkflowStateFromYjs', () => { - it('should decode the workflow state from the socket-server bridge snapshot', async () => { + it('should decode the workflow state from the bootstrapped Yjs snapshot', async () => { const doc = new Y.Doc() const yjsState = { blocks: { @@ -1090,20 +1093,17 @@ describe('Database Helpers', () => { setWorkflowState(doc, yjsState, 'test') setVariables(doc, yjsVariables, 'test') - mockGetYjsSnapshot.mockResolvedValue( + mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( buildWorkflowSnapshotResponse(Y.encodeStateAsUpdate(doc)) ) const result = await dbHelpers.loadWorkflowStateFromYjs(mockWorkflowId) - expect(mockGetYjsSnapshot).toHaveBeenCalledWith( - mockWorkflowId, + expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalledWith( expect.objectContaining({ - targetKind: 'workflow', - sessionId: mockWorkflowId, - workflowId: mockWorkflowId, entityKind: 'workflow', entityId: mockWorkflowId, + yjsSessionId: mockWorkflowId, }) ) expect(result).toMatchObject({ @@ -1147,7 +1147,7 @@ describe('Database Helpers', () => { setWorkflowState(doc, yjsState, 'test') setVariables(doc, yjsVariables, 'test') - mockGetYjsSnapshot.mockResolvedValue( + mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( buildWorkflowSnapshotResponse(Y.encodeStateAsUpdate(doc)) ) @@ -1194,7 +1194,7 @@ describe('Database Helpers', () => { setWorkflowState(doc, yjsState, 'test') setVariables(doc, yjsVariables, 'test') - mockGetYjsSnapshot.mockResolvedValue( + mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( buildWorkflowSnapshotResponse(Y.encodeStateAsUpdate(doc)) ) @@ -1211,18 +1211,17 @@ describe('Database Helpers', () => { expect(mockDb.select).not.toHaveBeenCalled() }) - it('returns null when the Yjs bridge errors', async () => { - mockGetYjsSnapshot.mockRejectedValueOnce( - new Error('socket server unavailable') - ) - - const result = await dbHelpers.loadWorkflowState(mockWorkflowId) + it('does not read workflow tables when the bootstrapped Yjs read fails', async () => { + const error = new Error('socket server unavailable') + mockReadBootstrappedReviewTargetSnapshot.mockRejectedValueOnce(error) - expect(result).toBeNull() + await expect(dbHelpers.loadWorkflowState(mockWorkflowId)).rejects.toThrow( + 'socket server unavailable' + ) expect(mockDb.select).not.toHaveBeenCalled() }) - it('uses the stored Yjs snapshot without normalized-table fallback', async () => { + it('prefers the stored Yjs snapshot over the DB materialization', async () => { const doc = new Y.Doc() setWorkflowState( doc, @@ -1245,7 +1244,7 @@ describe('Database Helpers', () => { }, 'test' ) - mockGetYjsSnapshot.mockResolvedValue( + mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( buildWorkflowSnapshotResponse(Y.encodeStateAsUpdate(doc)) ) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index aff73378c..e9abf0316 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -12,14 +12,9 @@ import { and, desc, eq, inArray, ne, sql } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' import * as Y from 'yjs' import { reconcilePublishedChatsForDeploymentTx } from '@/lib/chat/published-deployment' -import { - buildYjsTransportEnvelope, - serializeYjsTransportEnvelope, -} from '@/lib/copilot/review-sessions/identity' import { createLogger } from '@/lib/logs/console/logger' import { resolveStoredDateValue } from '@/lib/time-format' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' -import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' import { extractPersistedStateFromDoc } from '@/lib/yjs/workflow-session' import type { Variable } from '@/stores/variables/types' import type { @@ -88,69 +83,81 @@ export type PersistedWorkflowState = { lastSaved: number } -/** - * Attempt to load the current workflow state from the authoritative socket - * server Yjs session through the generic Yjs snapshot transport. The socket - * server resolves a live workflow doc first and otherwise falls back to its - * persisted Yjs blob. - */ export async function loadWorkflowStateFromYjs( workflowId: string ): Promise { - try { - const snapshot = await getYjsSnapshot( - workflowId, - serializeYjsTransportEnvelope( - buildYjsTransportEnvelope({ - workspaceId: null, - entityKind: 'workflow', - entityId: workflowId, - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: workflowId, - }) - ) - ) - - if (!snapshot.snapshotBase64) { - return null - } + const { readBootstrappedReviewTargetSnapshot, ReviewTargetBootstrapError } = await import( + '@/lib/yjs/server/bootstrap-review-target' + ) - const doc = new Y.Doc() - try { - Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) - return extractPersistedStateFromDoc(doc) - } finally { - doc.destroy() - } + let snapshot: Awaited> + try { + snapshot = await readBootstrappedReviewTargetSnapshot({ + workspaceId: null, + entityKind: 'workflow', + entityId: workflowId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: workflowId, + }) } catch (error) { - if (error instanceof SocketServerBridgeError && error.status === 404) { + if (error instanceof ReviewTargetBootstrapError && error.status === 404) { return null } throw error } + + if (!snapshot.snapshotBase64) { + return null + } + + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) + return extractPersistedStateFromDoc(doc) + } finally { + doc.destroy() + } } export type WorkflowStateWithSource = PersistedWorkflowState & { source: 'yjs' } -/** - * Loads the current editable workflow state from Yjs. - */ export async function loadWorkflowState( workflowId: string ): Promise { - try { - const yjsState = await loadWorkflowStateFromYjs(workflowId) - if (yjsState) { - return { ...yjsState, source: 'yjs' } - } - } catch (error) { - logger.warn(`Failed to load Yjs state for workflow ${workflowId}`, error) + const yjsState = await loadWorkflowStateFromYjs(workflowId) + return yjsState ? { ...yjsState, source: 'yjs' } : null +} + +export async function loadWorkflowStateFromSavedTables( + workflowId: string +): Promise { + const [workflowRow, normalizedState] = await Promise.all([ + db + .select({ + variables: workflow.variables, + updatedAt: workflow.updatedAt, + }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1), + loadWorkflowFromNormalizedTables(workflowId), + ]) + const row = workflowRow[0] + if (!row) { + return null } - return null + return { + blocks: normalizedState?.blocks ?? {}, + edges: normalizedState?.edges ?? [], + loops: normalizedState?.loops ?? {}, + parallels: normalizedState?.parallels ?? {}, + variables: (row.variables as Record) ?? {}, + lastSaved: row.updatedAt?.getTime() ?? Date.now(), + } } /** @@ -394,6 +401,7 @@ export interface NormalizedWorkflowData { edges: Edge[] loops: Record parallels: Record + variables?: Record isFromNormalizedTables: boolean // Flag to indicate source (true = normalized tables, false = deployed state) } @@ -558,13 +566,14 @@ export async function loadDeployedWorkflowState( throw new Error(`Workflow ${workflowId} has no active deployment`) } - const state = active.state as WorkflowState + const state = active.state as WorkflowState & { variables?: Record } return { blocks: state.blocks || {}, edges: state.edges || [], loops: state.loops || {}, parallels: state.parallels || {}, + variables: state.variables || {}, isFromNormalizedTables: false, } } catch (error) { @@ -955,7 +964,7 @@ export async function deployWorkflow(params: { if (!stateWithSource) { return { success: false, error: 'Failed to load workflow state' } } - const currentState: PersistedWorkflowState = stateWithSource + const { source: _source, ...currentState } = stateWithSource const now = new Date() diff --git a/apps/tradinggoose/lib/workflows/execution-runner.test.ts b/apps/tradinggoose/lib/workflows/execution-runner.test.ts index 909334aff..43781f8d8 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.test.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.test.ts @@ -429,4 +429,46 @@ describe('loadWorkflowExecutionBlueprint', () => { expect(loadDeployedWorkflowState).not.toHaveBeenCalled() expect(mocks.dbSelect).not.toHaveBeenCalled() }) + + it('uses variables from the active deployment for deployed execution', async () => { + const { loadDeployedWorkflowState, loadWorkflowState } = await import( + '@/lib/workflows/db-helpers' + ) + const deployedVariables = { + risk: { id: 'var-deployed', name: 'risk', value: 'deployed' }, + } + vi.mocked(loadDeployedWorkflowState).mockResolvedValueOnce({ + blocks: { + trigger: { + id: 'trigger', + type: 'api_trigger', + name: 'Trigger', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + }, + }, + edges: [{ id: 'edge-1', source: 'trigger', target: 'worker' }], + loops: {}, + parallels: {}, + variables: deployedVariables, + isFromNormalizedTables: false, + }) + mocks.dbRowsQueue.push([ + { + workspaceId: 'workspace-1', + variables: { risk: { id: 'var-live', name: 'risk', value: 'live' } }, + }, + ]) + + const result = await loadWorkflowExecutionBlueprint({ + workflowId: 'workflow-1', + executionTarget: 'deployed', + }) + + expect(result.workflowContext.variables).toEqual(deployedVariables) + expect(result.workflowData.blocks.trigger?.subBlocks).toEqual({}) + expect(loadWorkflowState).not.toHaveBeenCalled() + }) }) diff --git a/apps/tradinggoose/lib/workflows/execution-runner.ts b/apps/tradinggoose/lib/workflows/execution-runner.ts index 1014281df..c677adbb3 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.ts @@ -281,10 +281,18 @@ export async function loadWorkflowExecutionBlueprint(params: { throw new Error(`Workflow ${params.workflowId} has no ${executionTarget} state`) } + const deployedVariables = + executionTarget === 'deployed' + ? ((workflowData as { variables?: Record }).variables ?? {}) + : null + return { workflowId: params.workflowId, executionTarget, - workflowContext, + workflowContext: + executionTarget === 'deployed' + ? { ...workflowContext, variables: deployedVariables } + : workflowContext, workflowData: { blocks: workflowData.blocks || {}, edges: workflowData.edges || [], diff --git a/apps/tradinggoose/lib/yjs/entity-state.ts b/apps/tradinggoose/lib/yjs/entity-state.ts index 8fa8219fd..64259a9d8 100644 --- a/apps/tradinggoose/lib/yjs/entity-state.ts +++ b/apps/tradinggoose/lib/yjs/entity-state.ts @@ -1,12 +1,7 @@ import * as Y from 'yjs' -import { - buildYjsTransportEnvelope, - serializeYjsTransportEnvelope, -} from '@/lib/copilot/review-sessions/identity' import type { ReviewEntityKind, ReviewTargetDescriptor } from '@/lib/copilot/review-sessions/types' import { getEntityFields } from '@/lib/yjs/entity-session' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' -import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' export type SavedEntityKind = Exclude @@ -157,38 +152,32 @@ export function applySavedEntityFieldsToRow( } } -export async function readSavedEntityFieldsFromYjs( +export async function readSavedEntityFields( entityKind: SavedEntityKind, entityId: string, workspaceId: string ): Promise> { - try { - const descriptor = buildSavedEntityYjsDescriptor(entityKind, entityId, workspaceId) - const snapshot = await getYjsSnapshot( - entityId, - serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor)) - ) + const { readBootstrappedReviewTargetSnapshot } = await import( + '@/lib/yjs/server/bootstrap-review-target' + ) + const snapshot = await readBootstrappedReviewTargetSnapshot( + buildSavedEntityYjsDescriptor(entityKind, entityId, workspaceId) + ) - if (!snapshot.snapshotBase64) { - throw new Error(`Saved ${entityKind} Yjs state is empty for ${entityId}`) - } + if (!snapshot.snapshotBase64) { + throw new Error(`Saved ${entityKind} Yjs state is empty for ${entityId}`) + } - const doc = new Y.Doc() - try { - Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) - return getEntityFields(doc, entityKind) - } finally { - doc.destroy() - } - } catch (error) { - if (error instanceof SocketServerBridgeError && error.status === 404) { - throw new Error(`Saved ${entityKind} Yjs state is missing for ${entityId}`) - } - throw error + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) + return getEntityFields(doc, entityKind) + } finally { + doc.destroy() } } -export async function applySavedEntityYjsStateToRow( +export async function applySavedEntityCurrentFieldsToRow( entityKind: SavedEntityKind, row: T ): Promise { @@ -196,24 +185,30 @@ export async function applySavedEntityYjsStateToRow( return row } - const fields = await readSavedEntityFieldsFromYjs(entityKind, row.id, row.workspaceId) + const fields = await readSavedEntityFields(entityKind, row.id, row.workspaceId) return applySavedEntityFieldsToRow(entityKind, row, fields) } -export async function applySavedEntityYjsStateToRows( +export async function applySavedEntityCurrentFieldsToRows( entityKind: SavedEntityKind, rows: T[] ): Promise { - return Promise.all(rows.map((row) => applySavedEntityYjsStateToRow(entityKind, row))) + return Promise.all(rows.map((row) => applySavedEntityCurrentFieldsToRow(entityKind, row))) } -export async function seedSavedEntityYjsStateFromRows( +export async function applySavedEntityRows( entityKind: SavedEntityKind, - rows: T[] + rows: T[], + options?: { rollbackRows?: () => Promise } ): Promise { - await Promise.all( - rows.map((row) => - applySavedEntityState(entityKind, row.id, savedEntityRowToFields(entityKind, row)) + try { + await Promise.all( + rows.map((row) => + applySavedEntityState(entityKind, row.id, savedEntityRowToFields(entityKind, row)) + ) ) - ) + } catch (error) { + await options?.rollbackRows?.() + throw error + } } diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index 98ba1d757..6e7c588fb 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -1,10 +1,119 @@ +import { db } from '@tradinggoose/db' +import { + customTools, + knowledgeBase, + mcpServers, + pineIndicators, + skill, +} from '@tradinggoose/db/schema' +import { eq } from 'drizzle-orm' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { applyEntityStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +function parseObjectJson(value: unknown, fieldName: string): Record { + const parsed = JSON.parse(String(value ?? '')) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${fieldName} must be a JSON object`) + } + return parsed as Record +} + +function objectField(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + +async function persistSavedEntityState( + entityKind: SavedEntityKind, + entityId: string, + fields: Record +): Promise { + const now = new Date() + let persisted: Array<{ id: string }> + + switch (entityKind) { + case 'skill': + persisted = await db + .update(skill) + .set({ + name: String(fields.name ?? ''), + description: String(fields.description ?? ''), + content: String(fields.content ?? ''), + updatedAt: now, + }) + .where(eq(skill.id, entityId)) + .returning({ id: skill.id }) + break + case 'custom_tool': + persisted = await db + .update(customTools) + .set({ + title: String(fields.title ?? ''), + schema: parseObjectJson(fields.schemaText, 'schemaText'), + code: String(fields.codeText ?? ''), + updatedAt: now, + }) + .where(eq(customTools.id, entityId)) + .returning({ id: customTools.id }) + break + case 'indicator': + persisted = await db + .update(pineIndicators) + .set({ + name: String(fields.name ?? ''), + color: String(fields.color ?? ''), + pineCode: String(fields.pineCode ?? ''), + inputMeta: objectField(fields.inputMeta), + updatedAt: now, + }) + .where(eq(pineIndicators.id, entityId)) + .returning({ id: pineIndicators.id }) + break + case 'knowledge_base': + persisted = await db + .update(knowledgeBase) + .set({ + name: String(fields.name ?? ''), + description: String(fields.description ?? ''), + chunkingConfig: fields.chunkingConfig, + updatedAt: now, + }) + .where(eq(knowledgeBase.id, entityId)) + .returning({ id: knowledgeBase.id }) + break + case 'mcp_server': + persisted = await db + .update(mcpServers) + .set({ + name: String(fields.name ?? ''), + description: String(fields.description ?? '') || null, + transport: String(fields.transport ?? 'http'), + url: String(fields.url ?? '') || null, + headers: objectField(fields.headers), + command: String(fields.command ?? '') || null, + args: Array.isArray(fields.args) ? fields.args.map(String) : [], + env: objectField(fields.env), + timeout: Number(fields.timeout ?? 30000), + retries: Number(fields.retries ?? 3), + enabled: fields.enabled !== false, + updatedAt: now, + }) + .where(eq(mcpServers.id, entityId)) + .returning({ id: mcpServers.id }) + break + } + + if (persisted.length === 0) { + throw new Error(`Saved ${entityKind} ${entityId} was not found while materializing Yjs state`) + } +} + export async function applySavedEntityState( entityKind: SavedEntityKind, entityId: string, fields: Record ): Promise { await applyEntityStateInSocketServer(entityId, entityKind, fields) + await persistSavedEntityState(entityKind, entityId, fields) } diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index 9c4effd5d..44f5efc8a 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -1,5 +1,7 @@ -import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { db, workflow } from '@tradinggoose/db' +import { eq } from 'drizzle-orm' import { applyWorkflowStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' /** * Applies a complete workflow state replacement to the Yjs doc for a workflow. @@ -17,3 +19,24 @@ export async function applyWorkflowState( ): Promise { await applyWorkflowStateInSocketServer(workflowId, workflowState, variables, entityName) } + +export async function applyWorkflowEntityName( + workflowId: string, + workflowState: WorkflowSnapshot, + variables: Record, + entityName: string +): Promise { + await applyWorkflowState(workflowId, workflowState, variables, entityName) + + const [updatedWorkflow] = await db + .update(workflow) + .set({ name: entityName, updatedAt: new Date() }) + .where(eq(workflow.id, workflowId)) + .returning() + + if (!updatedWorkflow) { + throw new Error('Workflow not found') + } + + return updatedWorkflow +} diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 01ca7dda6..713901745 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -9,8 +9,25 @@ import type { ReviewTargetDescriptor, ReviewTargetRuntimeState, } from '@/lib/copilot/review-sessions/types' +import { loadWorkflowStateFromSavedTables } from '@/lib/workflows/db-helpers' +import { seedEntitySession } from '@/lib/yjs/entity-session' +import type { SavedEntityKind } from '@/lib/yjs/entity-state' +import { + readSavedEntityFieldsFromDb, + resolveEntityWorkspaceId, +} from '@/lib/yjs/server/entity-loaders' import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' -import { getState as getPersistedYjsState } from '@/socket-server/yjs/persistence' +import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' +import { + createWorkflowSnapshot, + getMetadataMap, + setVariables, + setWorkflowState, +} from '@/lib/yjs/workflow-session' +import { + getState as getPersistedYjsState, + storeCanonicalState, +} from '@/socket-server/yjs/persistence' export class ReviewTargetBootstrapError extends Error { status: number @@ -99,10 +116,65 @@ async function resolveExistingReviewTarget( } } +async function bootstrapSavedEntityFromDb( + descriptor: ReviewTargetDescriptor +): Promise { + if (!descriptor.entityId) { + throw new ReviewTargetBootstrapError(404, 'Saved entity id is required') + } + + const doc = new Y.Doc() + try { + if (descriptor.entityKind === 'workflow') { + const workflowState = await loadWorkflowStateFromSavedTables(descriptor.entityId) + if (!workflowState) { + throw new ReviewTargetBootstrapError(404, 'Workflow not found') + } + + setWorkflowState( + doc, + createWorkflowSnapshot({ + direction: workflowState.direction, + blocks: workflowState.blocks, + edges: workflowState.edges, + loops: workflowState.loops, + parallels: workflowState.parallels, + lastSaved: new Date(workflowState.lastSaved).toISOString(), + }), + YJS_ORIGINS.SYSTEM + ) + setVariables(doc, workflowState.variables, YJS_ORIGINS.SYSTEM) + } else { + const entityKind = descriptor.entityKind as SavedEntityKind + const workspaceId = + descriptor.workspaceId ?? (await resolveEntityWorkspaceId(entityKind, descriptor.entityId)) + if (!workspaceId) { + throw new ReviewTargetBootstrapError(404, 'Saved entity workspace is missing') + } + + seedEntitySession(doc, { + entityKind, + payload: await readSavedEntityFieldsFromDb(entityKind, descriptor.entityId, workspaceId), + }) + } + + getMetadataMap(doc).set('bootstrap-touch', Date.now()) + const state = Y.encodeStateAsUpdate(doc) + await storeCanonicalState(descriptor.yjsSessionId, state) + + return { + descriptor, + runtime: getRuntimeStateFromUpdate(state), + } + } finally { + doc.destroy() + } +} + /** * Ensures a review target has an active Yjs document. If an active blob already - * exists it is reused. Saved entities require canonical Yjs state; unsaved - * drafts return the explicit expired state. + * exists it is reused. Saved entities start a Yjs editing session from the + * saved database state; unsaved drafts return the explicit expired state. */ export async function bootstrapReviewTarget( descriptor: ReviewTargetDescriptor @@ -113,7 +185,7 @@ export async function bootstrapReviewTarget( } if (descriptor.entityId) { - throw new ReviewTargetBootstrapError(404, 'Saved entity Yjs state is missing') + return bootstrapSavedEntityFromDb(descriptor) } return { diff --git a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts index 8b48522e9..569e93c26 100644 --- a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts +++ b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts @@ -7,7 +7,7 @@ import { skill, } from '@tradinggoose/db/schema' import { and, eq, isNull } from 'drizzle-orm' -import type { SavedEntityKind } from '@/lib/yjs/entity-state' +import { type SavedEntityKind, savedEntityRowToFields } from '@/lib/yjs/entity-state' export async function resolveEntityWorkspaceId( entityKind: SavedEntityKind, @@ -56,3 +56,67 @@ export async function resolveEntityWorkspaceId( } } } + +export async function readSavedEntityFieldsFromDb( + entityKind: SavedEntityKind, + entityId: string, + workspaceId: string +): Promise> { + let row: Parameters[1] | undefined + + switch (entityKind) { + case 'skill': + ;[row] = await db + .select() + .from(skill) + .where(and(eq(skill.id, entityId), eq(skill.workspaceId, workspaceId))) + .limit(1) + break + case 'custom_tool': + ;[row] = await db + .select() + .from(customTools) + .where(and(eq(customTools.id, entityId), eq(customTools.workspaceId, workspaceId))) + .limit(1) + break + case 'indicator': + ;[row] = await db + .select() + .from(pineIndicators) + .where(and(eq(pineIndicators.id, entityId), eq(pineIndicators.workspaceId, workspaceId))) + .limit(1) + break + case 'knowledge_base': + ;[row] = await db + .select() + .from(knowledgeBase) + .where( + and( + eq(knowledgeBase.id, entityId), + eq(knowledgeBase.workspaceId, workspaceId), + isNull(knowledgeBase.deletedAt) + ) + ) + .limit(1) + break + case 'mcp_server': + ;[row] = await db + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.id, entityId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + break + } + + if (!row) { + throw new Error(`Saved ${entityKind} ${entityId} was not found`) + } + + return savedEntityRowToFields(entityKind, row) +} diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index e237e858d..1e5ad8062 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -5,8 +5,8 @@ */ import { createServer, request as httpRequest } from 'http' import { io as createClient } from 'socket.io-client' -import * as Y from 'yjs' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' import { createLogger } from '@/lib/logs/console/logger' import { getEntityFields } from '@/lib/yjs/entity-session' import { @@ -343,7 +343,7 @@ describe('Socket Server Index Integration', () => { } }) - it('should apply saved entity state as canonical Yjs state', async () => { + it('should apply saved entity state through Yjs', async () => { const response = await sendHttpRequestWithOptions( PORT, '/internal/yjs/entities/skill-1/apply-state', diff --git a/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts b/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts index 562c908d9..1b5726abd 100644 --- a/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts +++ b/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts @@ -25,7 +25,7 @@ import { isMonitorProviderConfigForProvider, } from '@/lib/monitors/sources' import { decryptSecret } from '@/lib/utils-server' -import { applySavedEntityYjsStateToRows } from '@/lib/yjs/entity-state' +import { applySavedEntityCurrentFieldsToRows } from '@/lib/yjs/entity-state' import type { MonitorExecutionPayload } from '@/background/monitor-execution' import { executeProviderRequest } from '@/providers/market' import { getMarketProviderConfig } from '@/providers/market/providers' @@ -280,7 +280,7 @@ async function resolveIndicatorDefinitions( ) ) - const indicators = await applySavedEntityYjsStateToRows('indicator', rows) + const indicators = await applySavedEntityCurrentFieldsToRows('indicator', rows) indicators.forEach((row) => { definitions.set(`${row.workspaceId}:${row.id}`, { From 50ea458d8b8d2d30af75e7d3c14bee8f56d580fb Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 19:11:13 -0600 Subject: [PATCH 028/284] refactor(workflows): centralize workflow state persistence Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../[version]/revert/route.test.ts | 37 +--------- .../deployments/[version]/revert/route.ts | 17 +---- .../workflows/[id]/duplicate/route.test.ts | 23 +----- .../app/api/workflows/[id]/duplicate/route.ts | 9 --- .../api/workflows/[id]/state/route.test.ts | 47 +----------- .../app/api/workflows/[id]/state/route.ts | 25 ------- .../app/api/workflows/route.test.ts | 74 ++----------------- apps/tradinggoose/app/api/workflows/route.ts | 6 -- .../app/api/workspaces/route.test.ts | 25 +------ .../server/entities/workflow-variable.test.ts | 31 ++++---- .../copilot/tools/server/entities/workflow.ts | 20 ++--- .../workflow/workflow-mutation-utils.ts | 6 +- apps/tradinggoose/lib/workspaces/service.ts | 6 -- .../lib/yjs/server/apply-workflow-state.ts | 27 ++++--- 14 files changed, 58 insertions(+), 295 deletions(-) diff --git a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts index f012a458c..5b2c75945 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts @@ -6,27 +6,16 @@ import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('Revert To Deployment Version API Route', () => { - const callOrder: string[] = [] - const mockValidateWorkflowPermissions = vi.fn() - const mockSaveWorkflowToNormalizedTables = vi.fn() const mockApplyWorkflowState = vi.fn() const mockDbSelectLimit = vi.fn() - const mockDbUpdateWhere = vi.fn() beforeEach(() => { vi.resetModules() vi.clearAllMocks() - callOrder.length = 0 mockValidateWorkflowPermissions.mockResolvedValue({ error: null }) - mockSaveWorkflowToNormalizedTables.mockImplementation(async () => { - callOrder.push('save') - return { success: true } - }) - mockApplyWorkflowState.mockImplementation(async () => { - callOrder.push('apply') - }) + mockApplyWorkflowState.mockResolvedValue(undefined) mockDbSelectLimit.mockResolvedValue([ { state: { @@ -52,10 +41,6 @@ describe('Revert To Deployment Version API Route', () => { }, }, ]) - mockDbUpdateWhere.mockImplementation(async () => { - callOrder.push('db-update') - }) - vi.doMock('drizzle-orm', () => ({ and: vi.fn((...conditions) => conditions), eq: vi.fn((field, value) => ({ field, value })), @@ -70,14 +55,6 @@ describe('Revert To Deployment Version API Route', () => { }), }), }), - update: vi.fn().mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: mockDbUpdateWhere, - }), - }), - }, - workflow: { - id: 'workflow.id', }, workflowDeploymentVersion: { state: 'state', @@ -108,7 +85,6 @@ describe('Revert To Deployment Version API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ ensureUniqueBlockIds: vi.fn(async (_workflowId: string, state: any) => state), ensureUniqueEdgeIds: vi.fn(async (_workflowId: string, state: any) => state), - saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, })) vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ @@ -145,7 +121,7 @@ describe('Revert To Deployment Version API Route', () => { vi.clearAllMocks() }) - it('publishes the reverted Yjs state before materializing derived tables', async () => { + it('applies the reverted deployment state through the workflow state helper', async () => { const { POST } = await import('@/app/api/workflows/[id]/deployments/[version]/revert/route') const request = new NextRequest( 'http://localhost:3000/api/workflows/workflow-1/deployments/active/revert' @@ -156,7 +132,6 @@ describe('Revert To Deployment Version API Route', () => { }) expect(response.status).toBe(200) - expect(callOrder).toEqual(['apply', 'save', 'db-update']) expect(mockApplyWorkflowState).toHaveBeenCalledWith( 'workflow-1', expect.objectContaining({ @@ -174,11 +149,8 @@ describe('Revert To Deployment Version API Route', () => { ) }) - it('reports workflow-row update failures after Yjs and materialization complete', async () => { - mockDbUpdateWhere.mockImplementationOnce(async () => { - callOrder.push('db-update') - throw new Error('database unavailable') - }) + it('reports workflow state apply failures', async () => { + mockApplyWorkflowState.mockRejectedValueOnce(new Error('database unavailable')) const { POST } = await import('@/app/api/workflows/[id]/deployments/[version]/revert/route') const request = new NextRequest( @@ -190,7 +162,6 @@ describe('Revert To Deployment Version API Route', () => { }) expect(response.status).toBe(500) - expect(callOrder).toEqual(['apply', 'save', 'db-update']) expect(mockApplyWorkflowState).toHaveBeenCalledOnce() }) }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts index c6d79c6ce..1a2e56199 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -1,4 +1,4 @@ -import { db, workflow, workflowDeploymentVersion } from '@tradinggoose/db' +import { db, workflowDeploymentVersion } from '@tradinggoose/db' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' @@ -6,7 +6,6 @@ import { generateRequestId } from '@/lib/utils' import { ensureUniqueBlockIds, ensureUniqueEdgeIds, - saveWorkflowToNormalizedTables, } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' @@ -101,20 +100,6 @@ export async function POST( await applyWorkflowState(id, revertSnapshot, revertVariables) - const saveResult = await saveWorkflowToNormalizedTables(id, persistedRevertedState) - if (!saveResult.success) { - return createErrorResponse(saveResult.error || 'Failed to materialize deployed state', 500) - } - - await db - .update(workflow) - .set({ - lastSynced: now, - updatedAt: now, - ...(revertVariables ? { variables: revertVariables } : {}), - }) - .where(eq(workflow.id, id)) - await pauseMonitorsMissingDeployedTrigger(id) await notifyMonitorsReconcile({ requestId, logger }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts index c8eccfbb0..85b5bb965 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts @@ -8,11 +8,9 @@ describe('Workflow Duplicate API Route', () => { let loadWorkflowStateMock: ReturnType let remapVariableIdsMock: ReturnType let regenerateWorkflowStateIdsMock: ReturnType - let saveWorkflowToNormalizedTablesMock: ReturnType let applyWorkflowStateMock: ReturnType let insertValuesMock: ReturnType let deleteWhereMock: ReturnType - const callOrder: string[] = [] const sourceWorkflowRow = { id: 'workflow-id', @@ -43,7 +41,6 @@ describe('Workflow Duplicate API Route', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() - callOrder.length = 0 loadWorkflowStateMock = vi.fn() remapVariableIdsMock = vi.fn((variables, newWorkflowId: string) => @@ -59,13 +56,7 @@ describe('Workflow Duplicate API Route', () => { ) ) regenerateWorkflowStateIdsMock = vi.fn((state) => JSON.parse(JSON.stringify(state))) - saveWorkflowToNormalizedTablesMock = vi.fn().mockImplementation(async (_workflowId, state) => { - callOrder.push('save') - return { success: true, normalizedState: state } - }) - applyWorkflowStateMock = vi.fn().mockImplementation(async () => { - callOrder.push('apply') - }) + applyWorkflowStateMock = vi.fn().mockResolvedValue(undefined) insertValuesMock = vi.fn().mockResolvedValue(undefined) deleteWhereMock = vi.fn().mockResolvedValue(undefined) @@ -134,7 +125,6 @@ describe('Workflow Duplicate API Route', () => { loadWorkflowState: loadWorkflowStateMock, remapVariableIds: remapVariableIdsMock, regenerateWorkflowStateIds: regenerateWorkflowStateIdsMock, - saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, })) vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ @@ -189,15 +179,12 @@ describe('Workflow Duplicate API Route', () => { expect(response.status).toBe(201) expect(insertValuesMock).toHaveBeenCalledOnce() - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() expect(applyWorkflowStateMock).toHaveBeenCalledOnce() - expect(callOrder).toEqual(['apply', 'save']) const insertedWorkflow = insertValuesMock.mock.calls[0][0] const appliedWorkflowId = applyWorkflowStateMock.mock.calls[0][0] const appliedSnapshot = applyWorkflowStateMock.mock.calls[0][1] const appliedVariables = applyWorkflowStateMock.mock.calls[0][2] - const savedState = saveWorkflowToNormalizedTablesMock.mock.calls[0][1] expect(insertedWorkflow.id).toBe(appliedWorkflowId) expect(appliedSnapshot.blocks).toEqual( @@ -207,13 +194,6 @@ describe('Workflow Duplicate API Route', () => { }), }) ) - expect(savedState.blocks).toEqual( - expect.objectContaining({ - [Object.keys(savedState.blocks)[0]]: expect.objectContaining({ - name: 'Live Agent', - }), - }) - ) expect(Object.keys(appliedVariables)).toHaveLength(1) expect(Object.values(appliedVariables)).toEqual([ expect.objectContaining({ @@ -247,7 +227,6 @@ describe('Workflow Duplicate API Route', () => { expect(response.status).toBe(500) expect(applyWorkflowStateMock).toHaveBeenCalledOnce() - expect(saveWorkflowToNormalizedTablesMock).not.toHaveBeenCalled() expect(deleteWhereMock).toHaveBeenCalledOnce() }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts index 581581545..22dcbf036 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -14,7 +14,6 @@ import { loadWorkflowState, regenerateWorkflowStateIds, remapVariableIds, - saveWorkflowToNormalizedTables, } from '@/lib/workflows/db-helpers' import { normalizeVariables } from '@/lib/workflows/variable-utils' import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' @@ -157,14 +156,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: }) await applyWorkflowState(newWorkflowId, duplicatedSnapshot, duplicatedVariables, name) - - const saveResult = await saveWorkflowToNormalizedTables( - newWorkflowId, - persistedDuplicatedState - ) - if (!saveResult.success) { - throw new Error(saveResult.error || 'Failed to materialize duplicated workflow state') - } } catch (duplicationError) { await db.delete(workflow).where(eq(workflow.id, newWorkflowId)) throw duplicationError diff --git a/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts index 4efee7de7..940c98d98 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts @@ -6,9 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('Workflow State API Route', () => { let loadWorkflowStateFromYjsMock: ReturnType - let saveWorkflowToNormalizedTablesMock: ReturnType let applyWorkflowStateMock: ReturnType - let updateSetMock: ReturnType const createRequest = (body: Record) => new NextRequest('http://localhost:3000/api/workflows/workflow-id/state', { @@ -41,29 +39,7 @@ describe('Workflow State API Route', () => { vi.clearAllMocks() loadWorkflowStateFromYjsMock = vi.fn().mockResolvedValue(null) - saveWorkflowToNormalizedTablesMock = vi.fn().mockResolvedValue({ success: true }) applyWorkflowStateMock = vi.fn().mockResolvedValue(undefined) - updateSetMock = vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(undefined), - }) - - vi.doMock('drizzle-orm', () => ({ - eq: vi.fn((field, value) => ({ field, value })), - })) - - vi.doMock('@tradinggoose/db/schema', () => ({ - workflow: { - id: 'id', - }, - })) - - vi.doMock('@tradinggoose/db', () => ({ - db: { - update: vi.fn().mockReturnValue({ - set: updateSetMock, - }), - }, - })) vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ @@ -115,7 +91,6 @@ describe('Workflow State API Route', () => { ensureUniqueBlockIds: vi.fn(async (_workflowId: string, state: any) => state), ensureUniqueEdgeIds: vi.fn(async (_workflowId: string, state: any) => state), loadWorkflowStateFromYjs: loadWorkflowStateFromYjsMock, - saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, toISOStringOrUndefined: vi.fn((value: string | number | Date | null | undefined) => value == null ? undefined : new Date(value).toISOString() ), @@ -172,16 +147,6 @@ describe('Workflow State API Route', () => { }, undefined ) - expect(updateSetMock).toHaveBeenCalledWith( - expect.objectContaining({ - variables: { - 'live-var': expect.objectContaining({ - name: 'liveVar', - value: 'live value', - }), - }, - }) - ) }) it('rejects saves without request or Yjs variables', async () => { @@ -192,8 +157,6 @@ describe('Workflow State API Route', () => { expect(response.status).toBe(409) expect(applyWorkflowStateMock).not.toHaveBeenCalled() - expect(saveWorkflowToNormalizedTablesMock).not.toHaveBeenCalled() - expect(updateSetMock).not.toHaveBeenCalled() }) it('rejects saves when authoritative Yjs variable lookup fails', async () => { @@ -206,15 +169,10 @@ describe('Workflow State API Route', () => { expect(response.status).toBe(409) expect(applyWorkflowStateMock).not.toHaveBeenCalled() - expect(saveWorkflowToNormalizedTablesMock).not.toHaveBeenCalled() - expect(updateSetMock).not.toHaveBeenCalled() }) - it('returns an error when derived table materialization fails after Yjs apply', async () => { - saveWorkflowToNormalizedTablesMock.mockResolvedValueOnce({ - success: false, - error: 'validation failed', - }) + it('returns an error when workflow state apply fails', async () => { + applyWorkflowStateMock.mockRejectedValueOnce(new Error('validation failed')) const { PUT } = await import('@/app/api/workflows/[id]/state/route') const response = await PUT( @@ -229,6 +187,5 @@ describe('Workflow State API Route', () => { expect(response.status).toBe(500) expect(applyWorkflowStateMock).toHaveBeenCalledOnce() - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() }) }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/state/route.ts b/apps/tradinggoose/app/api/workflows/[id]/state/route.ts index 1a9d94c2b..a6f80afa5 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/state/route.ts @@ -1,6 +1,3 @@ -import { db } from '@tradinggoose/db' -import { workflow } from '@tradinggoose/db/schema' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { createLogger } from '@/lib/logs/console/logger' @@ -10,7 +7,6 @@ import { ensureUniqueBlockIds, ensureUniqueEdgeIds, loadWorkflowStateFromYjs, - saveWorkflowToNormalizedTables, toISOStringOrUndefined, } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' @@ -231,16 +227,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ workflowData.name ) - const saveResult = await saveWorkflowToNormalizedTables(workflowId, persistedWorkflowState as any) - - if (!saveResult.success) { - logger.error(`[${requestId}] Failed to materialize workflow ${workflowId} state:`, saveResult.error) - return NextResponse.json( - { error: 'Failed to materialize workflow state', details: saveResult.error }, - { status: 500 } - ) - } - // Extract and persist custom tools to database try { const { saved, errors } = await extractAndPersistCustomTools( @@ -260,17 +246,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ logger.error(`[${requestId}] Failed to persist custom tools`, { error, workflowId }) } - // Update workflow metadata and persist variables - const syncedAt = new Date(workflowState.lastSaved) - await db - .update(workflow) - .set({ - lastSynced: syncedAt, - updatedAt: syncedAt, - variables: resolvedVariables.value ?? {}, - }) - .where(eq(workflow.id, workflowId)) - const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully saved workflow ${workflowId} state in ${elapsed}ms`) diff --git a/apps/tradinggoose/app/api/workflows/route.test.ts b/apps/tradinggoose/app/api/workflows/route.test.ts index 6a5e83759..36dc9bb70 100644 --- a/apps/tradinggoose/app/api/workflows/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/route.test.ts @@ -7,10 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('Workflow API Route', () => { const insertValuesMock = vi.fn() const deleteWhereMock = vi.fn() - const saveWorkflowToNormalizedTablesMock = vi.fn() const applyWorkflowStateMock = vi.fn() const randomUUIDMock = vi.fn() - const callOrder: string[] = [] const createRequest = (body: Record) => new NextRequest('http://localhost:3000/api/workflows', { @@ -24,17 +22,10 @@ describe('Workflow API Route', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() - callOrder.length = 0 insertValuesMock.mockResolvedValue(undefined) deleteWhereMock.mockResolvedValue(undefined) - saveWorkflowToNormalizedTablesMock.mockImplementation(async (_workflowId, state) => { - callOrder.push('save') - return { success: true, normalizedState: state } - }) - applyWorkflowStateMock.mockImplementation(async () => { - callOrder.push('apply') - }) + applyWorkflowStateMock.mockResolvedValue(undefined) randomUUIDMock.mockReset() randomUUIDMock.mockReturnValueOnce('workflow-123').mockReturnValueOnce('variable-123') vi.stubGlobal('crypto', { @@ -108,7 +99,6 @@ describe('Workflow API Route', () => { ]) ) ), - saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, })) vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ @@ -128,7 +118,7 @@ describe('Workflow API Route', () => { vi.unstubAllGlobals() }) - it('seeds Yjs before materializing initial workflow state', async () => { + it('applies initial workflow state through the workflow state helper', async () => { const initialWorkflowState = { blocks: { 'block-1': { @@ -167,12 +157,9 @@ describe('Workflow API Route', () => { expect(response.status).toBe(200) expect(insertValuesMock).toHaveBeenCalledOnce() - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() expect(applyWorkflowStateMock).toHaveBeenCalledOnce() - expect(callOrder).toEqual(['apply', 'save']) const insertedWorkflow = insertValuesMock.mock.calls[0][0] - const canonicalState = saveWorkflowToNormalizedTablesMock.mock.calls[0][1] const insertedVariableValues = Object.values(insertedWorkflow.variables as Record) expect(insertedVariableValues).toHaveLength(1) @@ -183,7 +170,7 @@ describe('Workflow API Route', () => { type: 'plain', value: 'secret', }) - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledWith( + expect(applyWorkflowStateMock).toHaveBeenCalledWith( insertedWorkflow.id, expect.objectContaining({ blocks: initialWorkflowState.blocks, @@ -191,51 +178,14 @@ describe('Workflow API Route', () => { loops: initialWorkflowState.loops, parallels: initialWorkflowState.parallels, isDeployed: false, - }) - ) - expect(canonicalState.lastSaved).toEqual(expect.any(Number)) - expect(applyWorkflowStateMock).toHaveBeenCalledWith( - insertedWorkflow.id, - expect.objectContaining({ - blocks: initialWorkflowState.blocks, + lastSaved: expect.any(String), }), insertedWorkflow.variables, 'Workflow Copy' ) }) - it('rolls back the workflow row when initial-state materialization fails', async () => { - saveWorkflowToNormalizedTablesMock.mockImplementationOnce(async () => { - callOrder.push('save') - return { - success: false, - error: 'save failed', - } - }) - - const { POST } = await import('@/app/api/workflows/route') - const response = await POST( - createRequest({ - name: 'Workflow Copy', - workspaceId: 'workspace-1', - initialWorkflowState: { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - variables: {}, - }, - }) - ) - - expect(response.status).toBe(500) - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() - expect(deleteWhereMock).toHaveBeenCalledOnce() - expect(applyWorkflowStateMock).toHaveBeenCalledOnce() - expect(callOrder).toEqual(['apply', 'save']) - }) - - it('rolls back the workflow row when Yjs seeding fails', async () => { + it('rolls back the workflow row when workflow state apply fails', async () => { applyWorkflowStateMock.mockRejectedValueOnce(new Error('socket unavailable')) const { POST } = await import('@/app/api/workflows/route') @@ -255,11 +205,10 @@ describe('Workflow API Route', () => { expect(response.status).toBe(500) expect(applyWorkflowStateMock).toHaveBeenCalledOnce() - expect(saveWorkflowToNormalizedTablesMock).not.toHaveBeenCalled() expect(deleteWhereMock).toHaveBeenCalledOnce() }) - it('seeds and materializes default workflow state when no initial state is provided', async () => { + it('applies default workflow state when no initial state is provided', async () => { const { POST } = await import('@/app/api/workflows/route') const response = await POST( createRequest({ @@ -270,8 +219,6 @@ describe('Workflow API Route', () => { expect(response.status).toBe(200) expect(applyWorkflowStateMock).toHaveBeenCalledOnce() - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() - expect(callOrder).toEqual(['apply', 'save']) const insertedWorkflow = insertValuesMock.mock.calls[0][0] expect(insertedWorkflow.variables).toEqual({}) @@ -286,15 +233,6 @@ describe('Workflow API Route', () => { {}, 'Blank Workflow' ) - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledWith( - insertedWorkflow.id, - expect.objectContaining({ - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - }) - ) }) it('rejects workflow creation without workspace scope', async () => { diff --git a/apps/tradinggoose/app/api/workflows/route.ts b/apps/tradinggoose/app/api/workflows/route.ts index be62d091a..130f5056c 100644 --- a/apps/tradinggoose/app/api/workflows/route.ts +++ b/apps/tradinggoose/app/api/workflows/route.ts @@ -12,7 +12,6 @@ import { ensureUniqueBlockIds, ensureUniqueEdgeIds, remapVariableIds, - saveWorkflowToNormalizedTables, } from '@/lib/workflows/db-helpers' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { normalizeVariables } from '@/lib/workflows/variable-utils' @@ -217,11 +216,6 @@ export async function POST(req: NextRequest) { await applyWorkflowState(workflowId, defaultWorkflowSnapshot, remappedVariables, name) logger.info(`[${requestId}] Seeded Yjs doc for new workflow ${workflowId}`) - - const saveResult = await saveWorkflowToNormalizedTables(workflowId, initialStateWithUniqueIds) - if (!saveResult.success) { - throw new Error(saveResult.error || 'Failed to materialize initial workflow state') - } } catch (error) { await db.delete(workflow).where(eq(workflow.id, workflowId)) throw error diff --git a/apps/tradinggoose/app/api/workspaces/route.test.ts b/apps/tradinggoose/app/api/workspaces/route.test.ts index fd024dee7..c5c4fee27 100644 --- a/apps/tradinggoose/app/api/workspaces/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/route.test.ts @@ -17,7 +17,6 @@ describe('Workspaces API Route', () => { const updateWhereMock = vi.fn() const updateSetMock = vi.fn() const updateMock = vi.fn() - const mockSaveWorkflowToNormalizedTables = vi.fn() const mockApplyWorkflowState = vi.fn() let userWorkspaces: Array<{ workspace: Record @@ -37,7 +36,6 @@ describe('Workspaces API Route', () => { updateWhereMock.mockResolvedValue([]) updateSetMock.mockReturnValue({ where: updateWhereMock }) updateMock.mockReturnValue({ set: updateSetMock }) - mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) mockApplyWorkflowState.mockResolvedValue(undefined) vi.doMock('@tradinggoose/db', () => ({ @@ -111,7 +109,6 @@ describe('Workspaces API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ ensureUniqueBlockIds: vi.fn(async (_workflowId: string, state: any) => state), ensureUniqueEdgeIds: vi.fn(async (_workflowId: string, state: any) => state), - saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, })) vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ @@ -253,25 +250,9 @@ describe('Workspaces API Route', () => { expect(updateMock).toHaveBeenCalled() }) - it.each([ - [ - 'persistence fails', - () => - mockSaveWorkflowToNormalizedTables.mockResolvedValue({ - success: false, - error: 'Failed to persist normalized workflow state', - }), - ], - [ - 'persistence throws', - () => mockSaveWorkflowToNormalizedTables.mockRejectedValue(new Error('database unavailable')), - ], - [ - 'Yjs seeding fails', - () => mockApplyWorkflowState.mockRejectedValue(new Error('socket unavailable')), - ], - ])('removes a newly created workspace when default workflow %s', async (_case, fail) => { - fail() + it('removes a newly created workspace when default workflow state apply fails', async () => { + mockApplyWorkflowState.mockRejectedValue(new Error('socket unavailable')) + const response = await postWorkspace() expect(response.status).toBe(500) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts index 2eef6876e..9a8ffa3a1 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts @@ -6,16 +6,12 @@ import { editWorkflowVariableServerTool, readWorkflowServerTool, } from '@/lib/copilot/tools/server/entities/workflow' -import { - createWorkflowSnapshot, - setVariables, - setWorkflowState, -} from '@/lib/yjs/workflow-session' +import { createWorkflowSnapshot, setVariables, setWorkflowState } from '@/lib/yjs/workflow-session' const mockDbLimit = vi.hoisted(() => vi.fn()) const mockReadBootstrappedReviewTargetSnapshot = vi.hoisted(() => vi.fn()) const mockVerifyWorkflowAccess = vi.hoisted(() => vi.fn()) -const mockApplyWorkflowStateInSocketServer = vi.hoisted(() => vi.fn()) +const mockApplyWorkflowState = vi.hoisted(() => vi.fn()) vi.mock('@tradinggoose/db', () => ({ db: { @@ -38,14 +34,17 @@ vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ mockReadBootstrappedReviewTargetSnapshot(...args), })) -vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ - applyWorkflowStateInSocketServer: (...args: any[]) => - mockApplyWorkflowStateInSocketServer(...args), +vi.mock('@/lib/yjs/server/apply-workflow-state', () => ({ + applyWorkflowState: (...args: any[]) => mockApplyWorkflowState(...args), + applyWorkflowEntityName: vi.fn(), })) -function workflowSnapshotBase64(variables: Record): string { +function workflowSnapshotBase64( + variables: Record, + workflowState = createWorkflowSnapshot() +): string { const doc = new Y.Doc() - setWorkflowState(doc, createWorkflowSnapshot(), 'test') + setWorkflowState(doc, workflowState, 'test') setVariables(doc, variables, 'test') const encoded = Buffer.from(Y.encodeStateAsUpdate(doc)).toString('base64') doc.destroy() @@ -59,7 +58,7 @@ describe('workflow variable server tools', () => { mockDbLimit.mockReset() mockReadBootstrappedReviewTargetSnapshot.mockReset() mockVerifyWorkflowAccess.mockReset() - mockApplyWorkflowStateInSocketServer.mockReset() + mockApplyWorkflowState.mockReset() mockDbLimit.mockResolvedValue([ { id: 'wf-1', @@ -142,7 +141,7 @@ describe('workflow variable server tools', () => { expect(result.preview.documentDiff.after).toContain('enabled') }) - it('applies full-access workflow variable edits through the workflow Yjs bridge', async () => { + it('applies full-access workflow variable edits through workflow state persistence', async () => { const result = await editWorkflowVariableServerTool.execute( { entityId: 'wf-1', @@ -166,7 +165,7 @@ describe('workflow variable server tools', () => { workspaceId: 'workspace-1', documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, }) - expect(mockApplyWorkflowStateInSocketServer).toHaveBeenCalledWith( + expect(mockApplyWorkflowState).toHaveBeenCalledWith( 'wf-1', expect.objectContaining({ blocks: {}, @@ -176,7 +175,7 @@ describe('workflow variable server tools', () => { ) }) - it('applies accepted workflow variable reviews through the workflow Yjs bridge', async () => { + it('applies accepted workflow variable reviews through workflow state persistence', async () => { const result = await editWorkflowVariableServerTool.execute( { entityId: 'wf-1', @@ -190,7 +189,7 @@ describe('workflow variable server tools', () => { await acceptWorkflowDocumentReview('edit_workflow_variable', result, { userId: 'user-1', accessLevel: 'limited' }) - expect(mockApplyWorkflowStateInSocketServer).toHaveBeenCalledWith( + expect(mockApplyWorkflowState).toHaveBeenCalledWith( 'wf-1', expect.objectContaining({ blocks: {}, diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index 19e9e1923..e337cd7d1 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -20,7 +20,6 @@ import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit import { editWorkflowBlockServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow-block' import { generateCreativeWorkflowName } from '@/lib/naming' import { VariableManager } from '@/lib/variables/variable-manager' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' import { TG_MERMAID_DOCUMENT_FORMAT, WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT, @@ -31,9 +30,8 @@ import { serializeWorkflowToTgMermaid, } from '@/lib/workflows/studio-workflow-mermaid' import { isWorkflowVariableType, type WorkflowVariableType } from '@/lib/workflows/value-types' -import { applyWorkflowEntityName } from '@/lib/yjs/server/apply-workflow-state' +import { applyWorkflowEntityName, applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' -import { applyWorkflowStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot, getVariablesSnapshot, @@ -423,7 +421,7 @@ export const editWorkflowVariableServerTool: BaseServerTool< } } - await applyWorkflowStateInSocketServer(workflowId, workflowState, nextVariables) + await applyWorkflowState(workflowId, workflowState, nextVariables) return { success: true, entityKind: ENTITY_KIND_WORKFLOW, @@ -480,11 +478,7 @@ export const createWorkflowServerTool: BaseServerTool< }) try { - await applyWorkflowStateInSocketServer(workflowId, workflowState, {}, name) - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState) - if (!saveResult.success) { - throw new Error(saveResult.error || 'Failed to materialize initial workflow state') - } + await applyWorkflowState(workflowId, workflowState, {}, name) } catch (error) { await db.delete(workflow).where(eq(workflow.id, workflowId)) throw error @@ -565,11 +559,7 @@ export async function acceptWorkflowDocumentReview( throw new Error(`variables are required for ${toolName} review acceptance`) } const { workflowState } = await loadWorkflowSnapshotForCopilot(workflowId, context, 'write') - await applyWorkflowStateInSocketServer( - workflowId, - workflowState, - reviewResult.variables as Record - ) + await applyWorkflowState(workflowId, workflowState, reviewResult.variables as Record) return { ...reviewResult, @@ -582,7 +572,7 @@ export async function acceptWorkflowDocumentReview( } await verifyWorkflowContext(workflowId, context, 'write') - await applyWorkflowStateInSocketServer( + await applyWorkflowState( workflowId, createWorkflowSnapshot(reviewResult.workflowState as Partial) ) diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts index d1199a951..2b39ff13f 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts @@ -1,7 +1,7 @@ import * as Y from 'yjs' import { - shouldStageServerToolMutationForReview, type ServerToolExecutionContext, + shouldStageServerToolMutationForReview, } from '@/lib/copilot/tools/server/base-tool' import { findIntroducedNonCanonicalSubBlocks } from '@/lib/workflows/block-config-canonicalization' import { WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' @@ -13,8 +13,8 @@ import { } from '@/lib/workflows/studio-workflow-mermaid' import { validateWorkflowState } from '@/lib/workflows/validation' import { normalizeWorkflowStateToMermaidDirection } from '@/lib/workflows/workflow-direction' +import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' -import { applyWorkflowStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot, readWorkflowSnapshot, @@ -133,7 +133,7 @@ export async function resolveWorkflowMutationResultForExecution( return result } - await applyWorkflowStateInSocketServer( + await applyWorkflowState( result.entityId, createWorkflowSnapshot(result.workflowState as Partial) ) diff --git a/apps/tradinggoose/lib/workspaces/service.ts b/apps/tradinggoose/lib/workspaces/service.ts index 70877982f..3aebfefeb 100644 --- a/apps/tradinggoose/lib/workspaces/service.ts +++ b/apps/tradinggoose/lib/workspaces/service.ts @@ -5,7 +5,6 @@ import { buildWorkspaceAccessScope } from '@/lib/permissions/utils' import { ensureUniqueBlockIds, ensureUniqueEdgeIds, - saveWorkflowToNormalizedTables, } from '@/lib/workflows/db-helpers' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { toWorkspaceApiRecord } from '@/lib/workspaces/billing-owner' @@ -121,11 +120,6 @@ export async function createWorkspace(userId: string, name: string) { undefined, 'default-agent' ) - - const saveResult = await saveWorkflowToNormalizedTables(workflowId, persistedWorkflowState) - if (!saveResult.success) { - throw new Error(saveResult.error || 'Failed to materialize default workflow state') - } } catch (error) { await db.transaction(async (tx) => { await tx.delete(workflow).where(eq(workflow.id, workflowId)) diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index 44f5efc8a..a66c7c01b 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -1,16 +1,10 @@ import { db, workflow } from '@tradinggoose/db' import { eq } from 'drizzle-orm' +import { resolveStoredDateValue } from '@/lib/time-format' +import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' import { applyWorkflowStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' -/** - * Applies a complete workflow state replacement to the Yjs doc for a workflow. - * This is the server-only bridge used by POST /api/workflows, duplicate, template-use, - * checkpoint-revert, deployment-revert, and workspace bootstrap. - * - * Server routes must not bypass this helper by posting raw body state directly - * to a save route that now reads from Yjs. - */ export async function applyWorkflowState( workflowId: string, workflowState: WorkflowSnapshot, @@ -18,6 +12,21 @@ export async function applyWorkflowState( entityName?: string ): Promise { await applyWorkflowStateInSocketServer(workflowId, workflowState, variables, entityName) + + const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState) + if (!saveResult.success) { + throw new Error(saveResult.error || 'Failed to materialize workflow state') + } + + const syncedAt = resolveStoredDateValue(workflowState.lastSaved) ?? new Date() + await db + .update(workflow) + .set({ + lastSynced: syncedAt, + updatedAt: syncedAt, + ...(variables === undefined ? {} : { variables }), + }) + .where(eq(workflow.id, workflowId)) } export async function applyWorkflowEntityName( @@ -26,7 +35,7 @@ export async function applyWorkflowEntityName( variables: Record, entityName: string ): Promise { - await applyWorkflowState(workflowId, workflowState, variables, entityName) + await applyWorkflowStateInSocketServer(workflowId, workflowState, variables, entityName) const [updatedWorkflow] = await db .update(workflow) From fcfde6fa45c5a96fc41f98f83f5d316b57f0b470 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 19:11:28 -0600 Subject: [PATCH 029/284] feat(copilot): preserve workflow variable document ids Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../server/entities/workflow-variable.test.ts | 15 ++++---- .../copilot/tools/server/entities/workflow.ts | 34 ++++++++----------- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts index 9a8ffa3a1..0da7ca1bb 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts @@ -94,18 +94,18 @@ describe('workflow variable server tools', () => { expect(result.workflowVariableDocumentFormat).toBe(WORKFLOW_VARIABLE_DOCUMENT_FORMAT) expect(JSON.parse(result.workflowVariableDocument)).toEqual({ - variables: [{ name: 'riskLimit', type: 'number', value: 10 }], + variables: [{ variableId: 'var-1', name: 'riskLimit', type: 'number', value: 10 }], }) }) - it('prepares a document-diff review while preserving existing variable ids by name', async () => { + it('prepares a document-diff review while preserving existing variable ids', async () => { const result = await editWorkflowVariableServerTool.execute( { entityId: 'wf-1', documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, entityDocument: JSON.stringify({ variables: [ - { name: 'riskLimit', type: 'number', value: 25 }, + { variableId: 'var-1', name: 'riskLimit', type: 'number', value: 25 }, { name: 'enabled', type: 'boolean', value: true }, ], }), @@ -148,7 +148,7 @@ describe('workflow variable server tools', () => { documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, entityDocument: JSON.stringify({ variables: [ - { name: 'riskLimit', type: 'number', value: 25 }, + { variableId: 'var-1', name: 'riskLimit', type: 'number', value: 25 }, { name: 'enabled', type: 'boolean', value: true }, ], }), @@ -181,13 +181,16 @@ describe('workflow variable server tools', () => { entityId: 'wf-1', documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, entityDocument: JSON.stringify({ - variables: [{ name: 'riskLimit', type: 'number', value: 25 }], + variables: [{ variableId: 'var-1', name: 'riskLimit', type: 'number', value: 25 }], }), }, { userId: 'user-1', accessLevel: 'limited' } ) - await acceptWorkflowDocumentReview('edit_workflow_variable', result, { userId: 'user-1', accessLevel: 'limited' }) + await acceptWorkflowDocumentReview('edit_workflow_variable', result, { + userId: 'user-1', + accessLevel: 'limited', + }) expect(mockApplyWorkflowState).toHaveBeenCalledWith( 'wf-1', diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index e337cd7d1..64283f936 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -73,6 +73,7 @@ type WorkflowSummary = { } type WorkflowVariableDocumentEntry = { + variableId?: string name: string type: WorkflowVariableType value?: unknown @@ -82,6 +83,7 @@ const WorkflowVariableDocumentSchema = z .object({ variables: z.array( z.object({ + variableId: z.string().trim().min(1).optional(), name: z.string().trim().min(1), type: z.string().trim().min(1), value: z.unknown().optional(), @@ -234,25 +236,11 @@ export async function loadWorkflowSnapshotForCopilot( } } -function buildVariablesByName(variables: Record): Record { - const byName: Record = {} - Object.values(variables).forEach((variable: any) => { - if ( - variable && - typeof variable === 'object' && - typeof variable.id === 'string' && - typeof variable.name === 'string' - ) { - byName[variable.name] = variable - } - }) - return byName -} - function serializeWorkflowVariableDocument(variables: Record): string { const entries = Object.values(variables) .filter((variable: any) => variable && typeof variable === 'object') .map((variable: any) => ({ + variableId: String(variable.id ?? ''), name: String(variable.name ?? ''), type: isWorkflowVariableType(variable.type) ? variable.type : 'plain', value: variable.value ?? '', @@ -266,8 +254,17 @@ function serializeWorkflowVariableDocument(variables: Record): stri function parseWorkflowVariableDocument(entityDocument: string): WorkflowVariableDocumentEntry[] { const parsed = WorkflowVariableDocumentSchema.parse(JSON.parse(entityDocument)) const seenNames = new Set() + const seenVariableIds = new Set() return parsed.variables.map((variable) => { + const variableId = variable.variableId?.trim() + if (variableId) { + if (seenVariableIds.has(variableId)) { + throw new Error(`Duplicate workflow variableId: ${variableId}`) + } + seenVariableIds.add(variableId) + } + const name = variable.name.trim() if (seenNames.has(name)) { throw new Error(`Duplicate workflow variable name: ${name}`) @@ -279,6 +276,7 @@ function parseWorkflowVariableDocument(entityDocument: string): WorkflowVariable } return { + ...(variableId ? { variableId } : {}), name, type: variable.type, value: variable.value, @@ -301,16 +299,13 @@ function normalizeWorkflowVariableValue(value: unknown, type: WorkflowVariableTy function buildWorkflowVariablesFromDocument(input: { workflowId: string - currentVariables: Record entityDocument: string }): Record { - const existingByName = buildVariablesByName(input.currentVariables) const entries = parseWorkflowVariableDocument(input.entityDocument) return Object.fromEntries( entries.map((entry) => { - const existing = existingByName[entry.name] - const id = typeof existing?.id === 'string' ? existing.id : crypto.randomUUID() + const id = entry.variableId ?? crypto.randomUUID() return [ id, { @@ -396,7 +391,6 @@ export const editWorkflowVariableServerTool: BaseServerTool< ) const nextVariables = buildWorkflowVariablesFromDocument({ workflowId, - currentVariables: variables, entityDocument: args.entityDocument, }) const nextDocument = serializeWorkflowVariableDocument(nextVariables) From 55a0967070b7ac11ecf23791161c0e13216d5500 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 19:11:40 -0600 Subject: [PATCH 030/284] fix(chat): omit workflow variables from published payloads Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/chat/[identifier]/route.test.ts | 4 +++- .../app/api/chat/[identifier]/route.ts | 23 ++++++++----------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/apps/tradinggoose/app/api/chat/[identifier]/route.test.ts b/apps/tradinggoose/app/api/chat/[identifier]/route.test.ts index 33b677036..b5e76b0ec 100644 --- a/apps/tradinggoose/app/api/chat/[identifier]/route.test.ts +++ b/apps/tradinggoose/app/api/chat/[identifier]/route.test.ts @@ -50,7 +50,6 @@ vi.mock('@tradinggoose/db/schema', () => ({ id: 'workflow.id', isDeployed: 'workflow.isDeployed', workspaceId: 'workflow.workspaceId', - variables: 'workflow.variables', pinnedApiKeyId: 'workflow.pinnedApiKeyId', }, })) @@ -281,6 +280,9 @@ describe('/api/chat/[identifier]', () => { }), }) ) + expect(enqueuePendingExecutionMock.mock.calls[0]?.[0].payload).not.toHaveProperty( + 'workflowVariables' + ) const body = await response.text() diff --git a/apps/tradinggoose/app/api/chat/[identifier]/route.ts b/apps/tradinggoose/app/api/chat/[identifier]/route.ts index 00e9ae4d2..cd290436f 100644 --- a/apps/tradinggoose/app/api/chat/[identifier]/route.ts +++ b/apps/tradinggoose/app/api/chat/[identifier]/route.ts @@ -14,7 +14,6 @@ import { ChatFiles } from '@/lib/uploads' import { encodeSSE, generateRequestId, SSE_HEADERS } from '@/lib/utils' import { createChatOutputEventReader } from '@/lib/workflows/chat-output' import type { WorkflowExecutionEventEntry } from '@/lib/workflows/execution-events' -import { CHAT_ERROR_CODES } from '@/app/chat/constants' import { addCorsHeaders, setChatAuthCookie, @@ -22,6 +21,7 @@ import { validateChatAuth, } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { CHAT_ERROR_CODES } from '@/app/chat/constants' const logger = createLogger('ChatIdentifierAPI') @@ -55,14 +55,14 @@ export async function POST( // Parse the request body once let parsedBody - try { - parsedBody = await request.json() - } catch (_error) { - return addCorsHeaders( - createErrorResponse('Invalid request body', 400, CHAT_ERROR_CODES.INVALID_REQUEST_BODY), - request - ) - } + try { + parsedBody = await request.json() + } catch (_error) { + return addCorsHeaders( + createErrorResponse('Invalid request body', 400, CHAT_ERROR_CODES.INVALID_REQUEST_BODY), + request + ) + } // Find the chat deployment for this identifier const deploymentResult = await db @@ -143,7 +143,6 @@ export async function POST( .select({ isDeployed: workflow.isDeployed, workspaceId: workflow.workspaceId, - variables: workflow.variables, pinnedApiKeyId: workflow.pinnedApiKeyId, }) .from(workflow) @@ -240,10 +239,6 @@ export async function POST( executionTarget: 'deployed', stream: true, selectedOutputs, - workflowVariables: - workflowResult[0].variables && typeof workflowResult[0].variables === 'object' - ? (workflowResult[0].variables as Record) - : undefined, metadata: { source: 'published_chat', chatId: deployment.id, From 20320d153d60ce9c6298afff05975b73e491d6a9 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 19:11:51 -0600 Subject: [PATCH 031/284] fix(copilot): clarify MCP workspace-scoped tool guidance Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/copilot/mcp/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index c33adbe2d..29a7fe872 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -97,7 +97,7 @@ async function buildInstructions(userId: string) { return [ 'TradingGoose Copilot MCP exposes the same server-side Copilot tools used by TradingGoose Studio.', 'Local MCP config stores only this user auth token. Do not store workspaceId, entityId, or entity targets in the local MCP config.', - 'Use workspaceId only for workspace-scoped list/create tools. Use entityId for read/edit/rename tools that target an existing entity.', + 'Use entityId for read/edit/rename tools that target an existing entity. Use workspaceId for workspace-scoped tools, including list/create and environment, credential, OAuth, Google Drive, and workspace account reads.', 'Accessible workspaces for the authenticated user:', ...workspaceLines, ].join('\n') From 41608d14398dabb662a1afad88d464d13966d816 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 19:26:24 -0600 Subject: [PATCH 032/284] fix(workflows): load saved workflow state from database tables Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../api/workflows/[id]/autolayout/route.ts | 2 +- .../workflows/[id]/duplicate/route.test.ts | 6 +- .../app/api/workflows/[id]/duplicate/route.ts | 2 +- .../app/api/workflows/[id]/route.test.ts | 8 +- .../app/api/workflows/[id]/route.ts | 2 +- .../api/workflows/[id]/status/route.test.ts | 4 +- .../app/api/workflows/[id]/status/route.ts | 2 +- .../api/workflows/yaml/export/route.test.ts | 8 +- .../lib/workflows/db-helpers.test.ts | 260 +++++------------- apps/tradinggoose/lib/workflows/db-helpers.ts | 6 +- .../lib/workflows/execution-runner.test.ts | 7 +- .../lib/workflows/execution-runner.ts | 7 +- 12 files changed, 105 insertions(+), 209 deletions(-) diff --git a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts index f903f3674..5cb881dd4 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts @@ -60,7 +60,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ edges: layoutOptions.edges, } } else { - logger.info(`[${requestId}] Loading blocks from Yjs`) + logger.info(`[${requestId}] Loading blocks from saved workflow state`) currentWorkflowData = await loadWorkflowState(workflowId) } diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts index 85b5bb965..d120e5b3b 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts @@ -140,7 +140,7 @@ describe('Workflow Duplicate API Route', () => { vi.clearAllMocks() }) - it('prefers the live Yjs source graph and variables when duplicating a workflow', async () => { + it('uses the saved source graph and variables when duplicating a workflow', async () => { loadWorkflowStateMock.mockResolvedValue({ blocks: { 'live-block': { @@ -166,7 +166,7 @@ describe('Workflow Duplicate API Route', () => { }, }, lastSaved: Date.now(), - source: 'yjs', + source: 'db', }) const { POST } = await import('@/app/api/workflows/[id]/duplicate/route') @@ -213,7 +213,7 @@ describe('Workflow Duplicate API Route', () => { parallels: {}, variables: {}, lastSaved: Date.now(), - source: 'yjs', + source: 'db', }) applyWorkflowStateMock.mockRejectedValueOnce(new Error('socket bridge unavailable')) diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts index 22dcbf036..3f5ecb5e0 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -33,7 +33,7 @@ const DuplicateRequestSchema = z.object({ async function loadSourceWorkflowArtifacts(sourceWorkflowId: string): Promise<{ workflowState: WorkflowState variables: Record - source: 'yjs' | 'db' + source: 'db' }> { const stateWithSource = await loadWorkflowState(sourceWorkflowId) if (!stateWithSource) { diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index f621ca4d6..dc3f66de4 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -141,7 +141,7 @@ describe('Workflow By ID API Route', () => { edges: [], loops: {}, parallels: {}, - source: 'yjs', + source: 'db', } vi.doMock('@/lib/auth', () => ({ @@ -194,7 +194,7 @@ describe('Workflow By ID API Route', () => { edges: [], loops: {}, parallels: {}, - source: 'yjs', + source: 'db', } vi.doMock('@/lib/auth', () => ({ @@ -268,7 +268,7 @@ describe('Workflow By ID API Route', () => { expect(data.error).toBe('Access denied') }) - it('should return Yjs-backed workflow state when the authoritative loader has it', async () => { + it('should return saved workflow state when the authoritative loader has it', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -281,7 +281,7 @@ describe('Workflow By ID API Route', () => { edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2' }], loops: {}, parallels: {}, - source: 'yjs', + source: 'db', } vi.doMock('@/lib/auth', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index fc8db10fa..e9ca8704e 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -27,7 +27,7 @@ const UpdateWorkflowSchema = z /** * GET /api/workflows/[id] * Fetch a single workflow by ID - * Uses the authoritative Yjs workflow state loader. + * Uses the saved workflow state loader. */ export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts index 58f3fd2aa..532e85bdd 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts @@ -121,7 +121,7 @@ describe('Workflow Status API Route', () => { value: 'us-west-2', }, }, - source: 'yjs', + source: 'db', }) mockLimit.mockResolvedValue([ @@ -177,7 +177,7 @@ describe('Workflow Status API Route', () => { value: 'us-west-2', }, }, - source: 'yjs', + source: 'db', }) mockLimit.mockResolvedValue([ diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts index 79d1f89bc..531df4ecb 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts @@ -26,7 +26,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ let needsRedeployment = false if (validation.workflow.isDeployed) { - // Load current editable state from Yjs and the active deployment version in parallel. + // Load saved workflow state and the active deployment version in parallel. const [currentState, [active]] = await Promise.all([ loadWorkflowState(id), db diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts index b5a20f469..b824943ad 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts @@ -130,7 +130,7 @@ describe('Workflow YAML Export API Route', () => { }) it( - 'prefers the live Yjs workflow snapshot and includes variables in the export payload', + 'uses the saved workflow state and includes variables in the export payload', { timeout: 10_000 }, async () => { loadWorkflowStateMock.mockResolvedValue({ @@ -160,7 +160,7 @@ describe('Workflow YAML Export API Route', () => { }, }, lastSaved: Date.now(), - source: 'yjs', + source: 'db', }) const { GET } = await import('@/app/api/workflows/yaml/export/route') @@ -193,7 +193,7 @@ describe('Workflow YAML Export API Route', () => { } ) - it('exports the canonical saved Yjs state when no live doc exists', async () => { + it('exports the saved workflow state', async () => { loadWorkflowStateMock.mockResolvedValue({ blocks: { 'db-block': { @@ -221,7 +221,7 @@ describe('Workflow YAML Export API Route', () => { }, }, lastSaved: Date.now(), - source: 'yjs', + source: 'db', }) const { GET } = await import('@/app/api/workflows/yaml/export/route') diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index 1e4311ec5..586979733 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -953,44 +953,42 @@ describe('Database Helpers', () => { }) describe('deployWorkflow', () => { - it('should deploy the persisted Yjs workflow state when no live document is connected', async () => { - const doc = new Y.Doc() - const yjsState = { - blocks: { - 'block-yjs': { - id: 'block-yjs', - type: 'api', - name: 'Persisted block', - position: { x: 10, y: 20 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - lastSaved: new Date().toISOString(), - } - const yjsVariables = { - 'var-yjs': { - id: 'var-yjs', + it('should deploy the saved workflow state', async () => { + const savedVariables = { + 'var-db': { + id: 'var-db', name: 'Persisted variable', type: 'plain', value: 'latest', }, } - - setWorkflowState(doc, yjsState, 'test') - setVariables(doc, yjsVariables, 'test') - - mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( - buildWorkflowSnapshotResponse(Y.encodeStateAsUpdate(doc)) - ) - + const updatedAt = new Date('2026-04-06T00:00:00.000Z') + let selectCallCount = 0 + mockDb.select.mockImplementation(() => { + selectCallCount++ + if (selectCallCount === 1) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ variables: savedVariables, updatedAt }]), + }), + }), + } + } + const rows = + selectCallCount === 2 + ? mockBlocksFromDb + : selectCallCount === 3 + ? mockEdgesFromDb + : mockSubflowsFromDb + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(rows), + }), + } + }) const updateCalls: Array<{ table: unknown; data: Record }> = [] const insertCalls: Array<{ table: unknown; data: Record }> = [] - const workflowLastSaved = new Date('2026-04-06T00:00:00.000Z') const tx = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ @@ -1021,41 +1019,38 @@ describe('Database Helpers', () => { }) expect(result.success).toBe(true) - expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalledWith( - expect.objectContaining({ - entityKind: 'workflow', - entityId: mockWorkflowId, - yjsSessionId: mockWorkflowId, - }) - ) - expect(mockDb.select).not.toHaveBeenCalled() + expect(mockReadBootstrappedReviewTargetSnapshot).not.toHaveBeenCalled() expect(result.currentState).toMatchObject({ - blocks: yjsState.blocks, - edges: yjsState.edges, - loops: yjsState.loops, - parallels: yjsState.parallels, - variables: yjsVariables, + blocks: expect.objectContaining({ + 'block-1': expect.objectContaining({ id: 'block-1' }), + }), + edges: expect.arrayContaining([expect.objectContaining({ id: 'edge-1' })]), + variables: savedVariables, }) const deploymentInsert = insertCalls.find( (call) => call.table === mockWorkflowDeploymentVersion ) expect(deploymentInsert?.data.state).toMatchObject({ - blocks: yjsState.blocks, - variables: yjsVariables, + blocks: expect.objectContaining({ + 'block-1': expect.objectContaining({ id: 'block-1' }), + }), + variables: savedVariables, }) expect(deploymentInsert?.data.state).not.toHaveProperty('source') const workflowUpdate = updateCalls.find((call) => call.table === mockWorkflowTable) - expect(workflowUpdate?.data.variables).toEqual(yjsVariables) + expect(workflowUpdate?.data.variables).toEqual(savedVariables) expect(mockReconcilePublishedChatsForDeploymentTx).toHaveBeenCalledWith( expect.objectContaining({ workflowId: mockWorkflowId, workflowOwnerId: 'owner-1', state: expect.objectContaining({ - blocks: yjsState.blocks, - variables: yjsVariables, + blocks: expect.objectContaining({ + 'block-1': expect.objectContaining({ id: 'block-1' }), + }), + variables: savedVariables, }), }) ) @@ -1117,149 +1112,46 @@ describe('Database Helpers', () => { }) describe('loadWorkflowState', () => { - it('returns the Yjs state without a workflow-row query', async () => { - const doc = new Y.Doc() - const yjsState = { - blocks: { - 'block-yjs': { - id: 'block-yjs', - type: 'api', - name: 'Fresh Yjs block', - position: { x: 10, y: 20 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - lastSaved: '2026-04-06T00:05:00.000Z', - } - const yjsVariables = { - 'var-yjs': { - id: 'var-yjs', - name: 'Live variable', - type: 'plain', - value: 'latest', - }, - } - - setWorkflowState(doc, yjsState, 'test') - setVariables(doc, yjsVariables, 'test') - mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( - buildWorkflowSnapshotResponse(Y.encodeStateAsUpdate(doc)) - ) - - const result = await dbHelpers.loadWorkflowState(mockWorkflowId) - - expect(result).toMatchObject({ - blocks: yjsState.blocks, - edges: yjsState.edges, - loops: yjsState.loops, - parallels: yjsState.parallels, - variables: yjsVariables, - source: 'yjs', - }) - expect(mockDb.select).not.toHaveBeenCalled() - }) - - it('does not compare Yjs snapshots against workflow row timestamps', async () => { - const doc = new Y.Doc() - const yjsState = { - blocks: { - 'block-yjs': { - id: 'block-yjs', - type: 'api', - name: 'Fresh Yjs block', - position: { x: 10, y: 20 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - lastSaved: '2026-04-06T00:05:00.000Z', - } - const yjsVariables = { - 'var-yjs': { - id: 'var-yjs', - name: 'Live variable', - type: 'plain', - value: 'latest', - }, - } - - setWorkflowState(doc, yjsState, 'test') - setVariables(doc, yjsVariables, 'test') - mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( - buildWorkflowSnapshotResponse(Y.encodeStateAsUpdate(doc)) - ) - - const result = await dbHelpers.loadWorkflowState(mockWorkflowId) - - expect(result).toMatchObject({ - blocks: yjsState.blocks, - edges: yjsState.edges, - loops: yjsState.loops, - parallels: yjsState.parallels, - variables: yjsVariables, - source: 'yjs', + it('loads the saved workflow state from normalized database tables', async () => { + const variables = { 'var-db': { id: 'var-db', name: 'risk', value: 'saved' } } + const updatedAt = new Date('2026-04-06T00:05:00.000Z') + let callCount = 0 + mockDb.select.mockImplementation(() => { + callCount++ + if (callCount === 1) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ variables, updatedAt }]), + }), + }), + } + } + const rows = + callCount === 2 + ? mockBlocksFromDb + : callCount === 3 + ? mockEdgesFromDb + : mockSubflowsFromDb + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(rows), + }), + } }) - expect(mockDb.select).not.toHaveBeenCalled() - }) - - it('does not read workflow tables when the bootstrapped Yjs read fails', async () => { - const error = new Error('socket server unavailable') - mockReadBootstrappedReviewTargetSnapshot.mockRejectedValueOnce(error) - - await expect(dbHelpers.loadWorkflowState(mockWorkflowId)).rejects.toThrow( - 'socket server unavailable' - ) - expect(mockDb.select).not.toHaveBeenCalled() - }) - - it('prefers the stored Yjs snapshot over the DB materialization', async () => { - const doc = new Y.Doc() - setWorkflowState( - doc, - { - blocks: { - 'block-yjs': { - id: 'block-yjs', - type: 'api', - name: 'Stale Yjs block', - position: { x: 10, y: 20 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - lastSaved: '2026-04-06T00:00:00.000Z', - }, - 'test' - ) - mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( - buildWorkflowSnapshotResponse(Y.encodeStateAsUpdate(doc)) - ) const result = await dbHelpers.loadWorkflowState(mockWorkflowId) expect(result).toMatchObject({ blocks: expect.objectContaining({ - 'block-yjs': expect.objectContaining({ - id: 'block-yjs', - type: 'api', - }), + 'block-1': expect.objectContaining({ id: 'block-1' }), }), - source: 'yjs', + edges: expect.arrayContaining([expect.objectContaining({ id: 'edge-1' })]), + variables, + lastSaved: updatedAt.getTime(), + source: 'db', }) - expect(mockDb.select).not.toHaveBeenCalled() + expect(mockReadBootstrappedReviewTargetSnapshot).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index e9abf0316..5f05a5895 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -121,14 +121,14 @@ export async function loadWorkflowStateFromYjs( } export type WorkflowStateWithSource = PersistedWorkflowState & { - source: 'yjs' + source: 'db' } export async function loadWorkflowState( workflowId: string ): Promise { - const yjsState = await loadWorkflowStateFromYjs(workflowId) - return yjsState ? { ...yjsState, source: 'yjs' } : null + const savedState = await loadWorkflowStateFromSavedTables(workflowId) + return savedState ? { ...savedState, source: 'db' } : null } export async function loadWorkflowStateFromSavedTables( diff --git a/apps/tradinggoose/lib/workflows/execution-runner.test.ts b/apps/tradinggoose/lib/workflows/execution-runner.test.ts index 43781f8d8..c7ceaa262 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.test.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.test.ts @@ -67,6 +67,7 @@ vi.mock('@/lib/utils-server', () => ({ vi.mock('@/lib/workflows/db-helpers', () => ({ loadDeployedWorkflowState: vi.fn(), loadWorkflowState: vi.fn(), + loadWorkflowStateFromYjs: vi.fn(), })) vi.mock('@/lib/workflows/triggers', () => ({ @@ -403,17 +404,16 @@ describe('loadWorkflowExecutionBlueprint', () => { }) it('loads Yjs workflow state for live execution when no snapshot is supplied', async () => { - const { loadDeployedWorkflowState, loadWorkflowState } = await import( + const { loadDeployedWorkflowState, loadWorkflowStateFromYjs } = await import( '@/lib/workflows/db-helpers' ) - vi.mocked(loadWorkflowState).mockResolvedValueOnce({ + vi.mocked(loadWorkflowStateFromYjs).mockResolvedValueOnce({ blocks: { trigger: { subBlocks: {} } }, edges: [{ source: 'trigger', target: 'worker' }], loops: {}, parallels: {}, variables: { risk: { value: 1 } }, lastSaved: Date.now(), - source: 'yjs', }) const result = await loadWorkflowExecutionBlueprint({ @@ -427,6 +427,7 @@ describe('loadWorkflowExecutionBlueprint', () => { expect(result.workflowData.blocks).toEqual({ trigger: { subBlocks: {} } }) expect(result.workflowContext.variables).toEqual({ risk: { value: 1 } }) expect(loadDeployedWorkflowState).not.toHaveBeenCalled() + expect(loadWorkflowStateFromYjs).toHaveBeenCalledWith('workflow-1') expect(mocks.dbSelect).not.toHaveBeenCalled() }) diff --git a/apps/tradinggoose/lib/workflows/execution-runner.ts b/apps/tradinggoose/lib/workflows/execution-runner.ts index c677adbb3..b72ab3717 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.ts @@ -8,7 +8,10 @@ import { createLogger } from '@/lib/logs/console/logger' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { decryptSecret } from '@/lib/utils-server' -import { loadDeployedWorkflowState, loadWorkflowState } from '@/lib/workflows/db-helpers' +import { + loadDeployedWorkflowState, + loadWorkflowStateFromYjs, +} from '@/lib/workflows/db-helpers' import { TriggerUtils } from '@/lib/workflows/triggers' import { updateWorkflowRunCounts } from '@/lib/workflows/utils' import { normalizeVariables } from '@/lib/workflows/variable-utils' @@ -259,7 +262,7 @@ export async function loadWorkflowExecutionBlueprint(params: { const executionTarget = params.executionTarget ?? 'deployed' const liveWorkflowState = executionTarget === 'live' && !params.workflowData - ? await loadWorkflowState(params.workflowId) + ? await loadWorkflowStateFromYjs(params.workflowId) : null const workflowContext = await resolveRequiredWorkflowExecutionContext( params.workflowId, From 93dd229e1829b2e799bcf7050b7f6e80fa23422a Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 19:26:41 -0600 Subject: [PATCH 033/284] fix(socket): evict canonical Yjs persistence entries Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/socket-server/index.test.ts | 16 +++++++++++++++- .../socket-server/yjs/persistence.ts | 16 ++++++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 1e5ad8062..db407cd5d 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -16,7 +16,12 @@ import { } from '@/lib/yjs/workflow-session' import { createSocketIOServer } from '@/socket-server/config/socket' import { createHttpHandler } from '@/socket-server/routes/http' -import { cleanupPersistence, getState, storeState } from '@/socket-server/yjs/persistence' +import { + cleanupPersistence, + getState, + storeCanonicalState, + storeState, +} from '@/socket-server/yjs/persistence' import { cleanupAllDocuments, getDocument, @@ -273,6 +278,15 @@ describe('Socket Server Index Integration', () => { expect(workflowReverted.statusCode).toBe(404) }) + it('bounds canonical local persistence entries', async () => { + for (let index = 0; index < 101; index++) { + await storeCanonicalState(`canonical-${index}`, new Uint8Array([index])) + } + + expect(await getState('canonical-0')).toBeNull() + expect(await getState('canonical-100')).toEqual(new Uint8Array([100])) + }) + it('should apply workflow state through the internal Yjs route', async () => { const response = await sendHttpRequestWithOptions( PORT, diff --git a/apps/tradinggoose/socket-server/yjs/persistence.ts b/apps/tradinggoose/socket-server/yjs/persistence.ts index 60e41fb11..10b1fdafe 100644 --- a/apps/tradinggoose/socket-server/yjs/persistence.ts +++ b/apps/tradinggoose/socket-server/yjs/persistence.ts @@ -26,6 +26,14 @@ function isExpired(blob: YjsSessionBlob): boolean { return blob.expiresAt !== null && blob.expiresAt <= Date.now() } +function evictOldestLocalEntries(): void { + while (localStore.size > MAX_LOCAL_ENTRIES) { + const oldest = localStore.keys().next().value + if (!oldest) return + localStore.delete(oldest) + } +} + async function readRedisUpdatedAt(sessionId: string): Promise { const redis = getRedisClient() if (!redis) { @@ -126,12 +134,7 @@ export async function storeState(sessionId: string, state: Uint8Array): Promise< expiresAt: touchedAt + TTL_MS, }) - // Evict oldest entries if over the limit - while (localStore.size > MAX_LOCAL_ENTRIES) { - const oldest = Array.from(localStore.entries()).find(([, blob]) => blob.expiresAt !== null)?.[0] - if (oldest) localStore.delete(oldest) - else break - } + evictOldestLocalEntries() } export async function storeCanonicalState(sessionId: string, state: Uint8Array): Promise { @@ -152,6 +155,7 @@ export async function storeCanonicalState(sessionId: string, state: Uint8Array): if (blob) { blob.expiresAt = null } + evictOldestLocalEntries() } export async function hasSession(sessionId: string): Promise { From a30e4c1fbfad95cd7d5ac9a06bedc21bca4116c8 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 19:50:24 -0600 Subject: [PATCH 034/284] fix(workflows): prefer current workflow state snapshots Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../api/workflows/[id]/autolayout/route.ts | 2 +- .../app/api/workflows/[id]/duplicate/route.ts | 6 +- .../app/api/workflows/[id]/route.test.ts | 4 +- .../app/api/workflows/[id]/route.ts | 2 +- .../app/api/workflows/[id]/status/route.ts | 2 +- apps/tradinggoose/app/api/workflows/route.ts | 8 ++ .../api/workflows/yaml/export/route.test.ts | 4 +- .../app/api/workflows/yaml/export/route.ts | 1 + .../server/workflow/edit-workflow-block.ts | 7 +- .../tools/server/workflow/edit-workflow.ts | 7 +- .../workflow/workflow-mutation-utils.ts | 123 +++++++++++++----- .../lib/workflows/db-helpers.test.ts | 55 ++++++-- apps/tradinggoose/lib/workflows/db-helpers.ts | 22 +++- .../workflows/studio-workflow-mermaid.test.ts | 51 -------- .../lib/workflows/studio-workflow-mermaid.ts | 82 +----------- .../lib/workflows/workflow-direction.ts | 38 +----- .../lib/yjs/server/apply-workflow-state.ts | 14 +- 17 files changed, 202 insertions(+), 226 deletions(-) diff --git a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts index 5cb881dd4..d07fc560e 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts @@ -60,7 +60,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ edges: layoutOptions.edges, } } else { - logger.info(`[${requestId}] Loading blocks from saved workflow state`) + logger.info(`[${requestId}] Loading blocks from current workflow state`) currentWorkflowData = await loadWorkflowState(workflowId) } diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts index 3f5ecb5e0..2a3ee647f 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -33,7 +33,7 @@ const DuplicateRequestSchema = z.object({ async function loadSourceWorkflowArtifacts(sourceWorkflowId: string): Promise<{ workflowState: WorkflowState variables: Record - source: 'db' + source: 'yjs' | 'db' }> { const stateWithSource = await loadWorkflowState(sourceWorkflowId) if (!stateWithSource) { @@ -42,6 +42,7 @@ async function loadSourceWorkflowArtifacts(sourceWorkflowId: string): Promise<{ return { workflowState: { + ...(stateWithSource.direction !== undefined ? { direction: stateWithSource.direction } : {}), blocks: stateWithSource.blocks, edges: stateWithSource.edges, loops: stateWithSource.loops, @@ -147,6 +148,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: ) const duplicatedSnapshot = createWorkflowSnapshot({ + ...(persistedDuplicatedState.direction !== undefined + ? { direction: persistedDuplicatedState.direction } + : {}), blocks: persistedDuplicatedState.blocks, edges: persistedDuplicatedState.edges, loops: persistedDuplicatedState.loops, diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index dc3f66de4..33edb2a0b 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -268,7 +268,7 @@ describe('Workflow By ID API Route', () => { expect(data.error).toBe('Access denied') }) - it('should return saved workflow state when the authoritative loader has it', async () => { + it('should return current workflow state when the loader has it', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -313,7 +313,7 @@ describe('Workflow By ID API Route', () => { expect(data.data.state.edges).toEqual(mockWorkflowState.edges) }) - it('should return 409 when saved workflow state is missing', async () => { + it('should return 409 when current workflow state is missing', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index e9ca8704e..788f5a912 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -27,7 +27,7 @@ const UpdateWorkflowSchema = z /** * GET /api/workflows/[id] * Fetch a single workflow by ID - * Uses the saved workflow state loader. + * Uses the current workflow state loader. */ export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts index 531df4ecb..0178d9031 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts @@ -26,7 +26,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ let needsRedeployment = false if (validation.workflow.isDeployed) { - // Load saved workflow state and the active deployment version in parallel. + // Load current workflow state and the active deployment version in parallel. const [currentState, [active]] = await Promise.all([ loadWorkflowState(id), db diff --git a/apps/tradinggoose/app/api/workflows/route.ts b/apps/tradinggoose/app/api/workflows/route.ts index 130f5056c..08a8c900d 100644 --- a/apps/tradinggoose/app/api/workflows/route.ts +++ b/apps/tradinggoose/app/api/workflows/route.ts @@ -52,9 +52,14 @@ function getInitialWorkflowState( ? sourceRecord.parallels : {} const variables = isPlainObject(sourceRecord.variables) ? sourceRecord.variables : {} + const direction = + sourceRecord.direction === 'TD' || sourceRecord.direction === 'LR' + ? sourceRecord.direction + : undefined return { canonicalState: { + ...(direction ? { direction } : {}), blocks: blocks as WorkflowState['blocks'], edges: edges as WorkflowState['edges'], loops: loops as WorkflowState['loops'], @@ -206,6 +211,9 @@ export async function POST(req: NextRequest) { ) const defaultWorkflowSnapshot = createWorkflowSnapshot({ + ...(initialStateWithUniqueIds.direction + ? { direction: initialStateWithUniqueIds.direction } + : {}), blocks: initialStateWithUniqueIds.blocks, edges: initialStateWithUniqueIds.edges, loops: initialStateWithUniqueIds.loops, diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts index b824943ad..2832887e9 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts @@ -130,7 +130,7 @@ describe('Workflow YAML Export API Route', () => { }) it( - 'uses the saved workflow state and includes variables in the export payload', + 'uses the current workflow state and includes variables in the export payload', { timeout: 10_000 }, async () => { loadWorkflowStateMock.mockResolvedValue({ @@ -193,7 +193,7 @@ describe('Workflow YAML Export API Route', () => { } ) - it('exports the saved workflow state', async () => { + it('exports the current workflow state', async () => { loadWorkflowStateMock.mockResolvedValue({ blocks: { 'db-block': { diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts index 665d943f4..9e26b4235 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts @@ -81,6 +81,7 @@ export async function GET(request: NextRequest) { const workflowState: any = { deploymentStatuses: {}, + ...(stateWithSource.direction !== undefined ? { direction: stateWithSource.direction } : {}), blocks: stateWithSource.blocks, edges: stateWithSource.edges, loops: stateWithSource.loops, diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.ts index 3f88183f3..6086216e3 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow-block.ts @@ -6,6 +6,10 @@ import type { } from '@/lib/copilot/tools/server/base-tool' import { createLogger } from '@/lib/logs/console/logger' import { getAllowedSubBlockIds } from '@/lib/workflows/block-config-canonicalization' +import { + serializeWorkflowToTgMermaid, + TG_MERMAID_DOCUMENT_FORMAT, +} from '@/lib/workflows/studio-workflow-mermaid' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' import { getBlock } from '@/blocks' import { @@ -184,7 +188,8 @@ export const editWorkflowBlockServerTool: BaseServerTool = { workflowId, baseWorkflowState, nextWorkflowState, - requestedDirection: nextWorkflowState.direction, + renderEntityDocument: serializeWorkflowToGraphMermaid, documentFormat: WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT, }) diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts index 2b39ff13f..a30e27602 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts @@ -3,16 +3,9 @@ import { type ServerToolExecutionContext, shouldStageServerToolMutationForReview, } from '@/lib/copilot/tools/server/base-tool' +import { stableStringifyJsonValue } from '@/lib/json/stable' import { findIntroducedNonCanonicalSubBlocks } from '@/lib/workflows/block-config-canonicalization' -import { WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' -import { - buildWorkflowDocumentPreviewDiff, - serializeWorkflowToGraphMermaid, - serializeWorkflowToTgMermaid, - TG_MERMAID_DOCUMENT_FORMAT, -} from '@/lib/workflows/studio-workflow-mermaid' import { validateWorkflowState } from '@/lib/workflows/validation' -import { normalizeWorkflowStateToMermaidDirection } from '@/lib/workflows/workflow-direction' import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' import { @@ -20,7 +13,88 @@ import { readWorkflowSnapshot, type WorkflowSnapshot, } from '@/lib/yjs/workflow-session' -import type { WorkflowDirection } from '@/stores/workflows/workflow/types' + +function buildWorkflowDocumentPreviewDiff( + currentWorkflowState: WorkflowSnapshot | undefined, + nextWorkflowState: WorkflowSnapshot +): { + blockDiff: { added: string[]; removed: string[]; updated: string[] } + edgeDiff: { + added: Array< + Pick + > + removed: Array< + Pick + > + } + warnings: string[] +} { + const currentBlocks = currentWorkflowState?.blocks ?? {} + const nextBlocks = nextWorkflowState.blocks ?? {} + + const currentBlockIds = new Set(Object.keys(currentBlocks)) + const nextBlockIds = new Set(Object.keys(nextBlocks)) + + const added = [...nextBlockIds].filter((blockId) => !currentBlockIds.has(blockId)).sort() + const removed = [...currentBlockIds].filter((blockId) => !nextBlockIds.has(blockId)).sort() + const updated = [...nextBlockIds] + .filter((blockId) => currentBlockIds.has(blockId)) + .filter( + (blockId) => + stableStringifyJsonValue(currentBlocks[blockId]) !== + stableStringifyJsonValue(nextBlocks[blockId]) + ) + .sort() + + const toComparableEdge = (edge: WorkflowSnapshot['edges'][number]) => ({ + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle || 'source', + targetHandle: edge.targetHandle || 'target', + }) + + const currentEdges = (currentWorkflowState?.edges ?? []).map(toComparableEdge) + const nextEdges = (nextWorkflowState.edges ?? []).map(toComparableEdge) + const currentEdgeKeys = new Set( + currentEdges.map( + (edge) => `${edge.source}:${edge.sourceHandle}->${edge.target}:${edge.targetHandle}` + ) + ) + const nextEdgeKeys = new Set( + nextEdges.map( + (edge) => `${edge.source}:${edge.sourceHandle}->${edge.target}:${edge.targetHandle}` + ) + ) + + const edgeDiff = { + added: nextEdges.filter( + (edge) => + !currentEdgeKeys.has( + `${edge.source}:${edge.sourceHandle}->${edge.target}:${edge.targetHandle}` + ) + ), + removed: currentEdges.filter( + (edge) => + !nextEdgeKeys.has( + `${edge.source}:${edge.sourceHandle}->${edge.target}:${edge.targetHandle}` + ) + ), + } + + const warnings: string[] = [] + if (added.length === 0 && removed.length === 0 && updated.length === 0) { + warnings.push('No block changes detected.') + } + if (edgeDiff.added.length === 0 && edgeDiff.removed.length === 0) { + warnings.push('No edge changes detected.') + } + + return { + blockDiff: { added, removed, updated }, + edgeDiff, + warnings, + } +} export async function loadBaseWorkflowState( workflowId: string, @@ -63,12 +137,10 @@ export function buildWorkflowMutationResult(params: { workflowId: string baseWorkflowState: WorkflowSnapshot nextWorkflowState: WorkflowSnapshot - requestedDirection?: WorkflowDirection - entityDocument?: string - documentFormat?: string + renderEntityDocument: (workflowState: WorkflowSnapshot) => string + documentFormat: string }) { - const { workflowId, baseWorkflowState, nextWorkflowState, requestedDirection } = params - const documentFormat = params.documentFormat ?? TG_MERMAID_DOCUMENT_FORMAT + const { workflowId, baseWorkflowState, nextWorkflowState } = params const nonCanonicalSubBlockErrors = findIntroducedNonCanonicalSubBlocks( nextWorkflowState, baseWorkflowState @@ -83,28 +155,13 @@ export function buildWorkflowMutationResult(params: { throw new Error(`Invalid edited workflow: ${validation.errors.join('; ')}`) } - let finalWorkflowState = createWorkflowSnapshot( + const finalWorkflowState = createWorkflowSnapshot( (validation.sanitizedState as Partial | undefined) ?? nextWorkflowState ) - const direction = - requestedDirection ?? finalWorkflowState.direction ?? baseWorkflowState.direction ?? 'TD' - const orientationWarnings: string[] = [] - const normalizedWorkflow = normalizeWorkflowStateToMermaidDirection(finalWorkflowState, direction) - if (normalizedWorkflow.didRelayout) { - orientationWarnings.push(`Re-laid out workflow blocks to match Mermaid direction ${direction}.`) - } - - finalWorkflowState = createWorkflowSnapshot(normalizedWorkflow.workflowState) const preview = buildWorkflowDocumentPreviewDiff(baseWorkflowState, finalWorkflowState) - const warnings = Array.from( - new Set([...orientationWarnings, ...preview.warnings, ...validation.warnings]) - ) - const entityDocument = - params.entityDocument ?? - (documentFormat === WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT - ? serializeWorkflowToGraphMermaid(finalWorkflowState, { direction }) - : serializeWorkflowToTgMermaid(finalWorkflowState, { direction })) + const warnings = Array.from(new Set([...preview.warnings, ...validation.warnings])) + const entityDocument = params.renderEntityDocument(finalWorkflowState) return { requiresReview: true, @@ -112,7 +169,7 @@ export function buildWorkflowMutationResult(params: { entityKind: 'workflow' as const, entityId: workflowId, entityDocument, - documentFormat, + documentFormat: params.documentFormat, workflowState: finalWorkflowState, preview: { ...preview, diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index 586979733..6cce9e19f 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -147,6 +147,20 @@ function buildWorkflowSnapshotResponse(update: Uint8Array) { } } +function buildWorkflowSnapshotResponseFromState( + workflowState: Parameters[1], + variables: Record = {} +) { + const doc = new Y.Doc() + try { + setWorkflowState(doc, workflowState, 'test') + setVariables(doc, variables, 'test') + return buildWorkflowSnapshotResponse(Y.encodeStateAsUpdate(doc)) + } finally { + doc.destroy() + } +} + const mockBlocksFromDb = [ { id: 'block-1', @@ -953,7 +967,7 @@ describe('Database Helpers', () => { }) describe('deployWorkflow', () => { - it('should deploy the saved workflow state', async () => { + it('should deploy the current workflow state', async () => { const savedVariables = { 'var-db': { id: 'var-db', @@ -1019,7 +1033,7 @@ describe('Database Helpers', () => { }) expect(result.success).toBe(true) - expect(mockReadBootstrappedReviewTargetSnapshot).not.toHaveBeenCalled() + expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalled() expect(result.currentState).toMatchObject({ blocks: expect.objectContaining({ 'block-1': expect.objectContaining({ id: 'block-1' }), @@ -1059,7 +1073,6 @@ describe('Database Helpers', () => { describe('loadWorkflowStateFromYjs', () => { it('should decode the workflow state from the bootstrapped Yjs snapshot', async () => { - const doc = new Y.Doc() const yjsState = { blocks: { 'block-yjs': { @@ -1086,10 +1099,8 @@ describe('Database Helpers', () => { }, } - setWorkflowState(doc, yjsState, 'test') - setVariables(doc, yjsVariables, 'test') mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( - buildWorkflowSnapshotResponse(Y.encodeStateAsUpdate(doc)) + buildWorkflowSnapshotResponseFromState(yjsState, yjsVariables) ) const result = await dbHelpers.loadWorkflowStateFromYjs(mockWorkflowId) @@ -1112,7 +1123,34 @@ describe('Database Helpers', () => { }) describe('loadWorkflowState', () => { - it('loads the saved workflow state from normalized database tables', async () => { + it('loads the current workflow state from Yjs before saved database tables', async () => { + const yjsState = { + direction: 'LR' as const, + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + lastSaved: new Date().toISOString(), + } + const yjsVariables = { + 'var-yjs': { id: 'var-yjs', value: 'latest' }, + } + + mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( + buildWorkflowSnapshotResponseFromState(yjsState, yjsVariables) + ) + + const result = await dbHelpers.loadWorkflowState(mockWorkflowId) + + expect(result).toMatchObject({ + direction: 'LR', + variables: yjsVariables, + source: 'yjs', + }) + expect(mockDb.select).not.toHaveBeenCalled() + }) + + it('loads materialized workflow state when no Yjs state exists', async () => { const variables = { 'var-db': { id: 'var-db', name: 'risk', value: 'saved' } } const updatedAt = new Date('2026-04-06T00:05:00.000Z') let callCount = 0 @@ -1148,10 +1186,11 @@ describe('Database Helpers', () => { }), edges: expect.arrayContaining([expect.objectContaining({ id: 'edge-1' })]), variables, + direction: 'LR', lastSaved: updatedAt.getTime(), source: 'db', }) - expect(mockReadBootstrappedReviewTargetSnapshot).not.toHaveBeenCalled() + expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 5f05a5895..1ccd58af9 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -15,6 +15,7 @@ import { reconcilePublishedChatsForDeploymentTx } from '@/lib/chat/published-dep import { createLogger } from '@/lib/logs/console/logger' import { resolveStoredDateValue } from '@/lib/time-format' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' +import { inferWorkflowDirectionFromState } from '@/lib/workflows/workflow-direction' import { extractPersistedStateFromDoc } from '@/lib/yjs/workflow-session' import type { Variable } from '@/stores/variables/types' import type { @@ -121,12 +122,24 @@ export async function loadWorkflowStateFromYjs( } export type WorkflowStateWithSource = PersistedWorkflowState & { - source: 'db' + source: 'yjs' | 'db' } export async function loadWorkflowState( workflowId: string ): Promise { + try { + const liveState = await loadWorkflowStateFromYjs(workflowId) + if (liveState) { + return { ...liveState, source: 'yjs' } + } + } catch (error) { + logger.warn( + `Failed to load live workflow state ${workflowId}; using materialized workflow state`, + { error } + ) + } + const savedState = await loadWorkflowStateFromSavedTables(workflowId) return savedState ? { ...savedState, source: 'db' } : null } @@ -150,7 +163,7 @@ export async function loadWorkflowStateFromSavedTables( return null } - return { + const savedState = { blocks: normalizedState?.blocks ?? {}, edges: normalizedState?.edges ?? [], loops: normalizedState?.loops ?? {}, @@ -158,6 +171,11 @@ export async function loadWorkflowStateFromSavedTables( variables: (row.variables as Record) ?? {}, lastSaved: row.updatedAt?.getTime() ?? Date.now(), } + + return { + ...savedState, + direction: inferWorkflowDirectionFromState(savedState), + } } /** diff --git a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts index f1db52c16..f0a6fb662 100644 --- a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts +++ b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { applyAutoLayout } from '@/lib/workflows/autolayout' import { - buildWorkflowDocumentPreviewDiff, parseGraphOnlyWorkflowMermaid, parseTgMermaidToWorkflow, serializeWorkflowToGraphMermaid, @@ -646,54 +645,4 @@ agentBlock(["Agent"]) 'Workflow document edge metadata is inconsistent. Visible Mermaid connections and TG_EDGE payloads must resolve to the same logical workflow edges. missing visible connection lines for inputTrigger:source->agentBlock:target; expected visible lines like `inputTrigger --> agentBlock`.' ) }) - - it('computes block and edge preview diffs from canonical workflow states', () => { - const nextState: WorkflowSnapshot = { - ...workflowState, - blocks: { - ...workflowState.blocks, - sink: { - ...workflowState.blocks.sink, - name: 'Send Alert v2', - }, - sink_archive: { - id: 'sink_archive', - type: 'notion', - name: 'Archive Alert', - position: { x: 760, y: 24 }, - enabled: true, - subBlocks: {}, - outputs: {}, - }, - }, - edges: [ - ...workflowState.edges, - { - id: 'e-sink-archive', - source: 'sink', - target: 'sink_archive', - }, - ], - } - - expect(buildWorkflowDocumentPreviewDiff(workflowState, nextState)).toEqual({ - blockDiff: { - added: ['sink_archive'], - removed: [], - updated: ['sink'], - }, - edgeDiff: { - added: [ - { - source: 'sink', - target: 'sink_archive', - sourceHandle: 'source', - targetHandle: 'target', - }, - ], - removed: [], - }, - warnings: [], - }) - }) }) diff --git a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts index 81d2f7a29..a08c4eedd 100644 --- a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts +++ b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts @@ -1,7 +1,7 @@ import type { Edge } from '@xyflow/react' import { stableStringifyJsonValue } from '@/lib/json/stable' import { TG_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' -import { inferMermaidDirectionFromWorkflowState } from '@/lib/workflows/workflow-direction' +import { inferWorkflowDirectionFromState } from '@/lib/workflows/workflow-direction' import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' import type { BlockState, @@ -1910,7 +1910,7 @@ export function serializeWorkflowToTgMermaid( const direction = options.direction ?? workflowState.direction ?? - inferMermaidDirectionFromWorkflowState(workflowState) + inferWorkflowDirectionFromState(workflowState) const blocks = workflowState.blocks ?? {} const blockIds = Object.keys(blocks).sort((left, right) => left.localeCompare(right)) const aliases = buildAliasMap(blockIds) @@ -1974,7 +1974,7 @@ export function serializeWorkflowToGraphMermaid( const direction = options.direction ?? workflowState.direction ?? - inferMermaidDirectionFromWorkflowState(workflowState) + inferWorkflowDirectionFromState(workflowState) const blocks = workflowState.blocks ?? {} const blockIds = Object.keys(blocks).sort((left, right) => left.localeCompare(right)) const aliases = buildAliasMap(blockIds) @@ -2113,79 +2113,3 @@ export function parseTgMermaidToWorkflow( ...(normalizeMetadataValue(metadata.deployedAt) ? { deployedAt: metadata.deployedAt } : {}), } } - -export function buildWorkflowDocumentPreviewDiff( - currentWorkflowState: WorkflowSnapshot | undefined, - nextWorkflowState: WorkflowSnapshot -): { - blockDiff: { added: string[]; removed: string[]; updated: string[] } - edgeDiff: { - added: Array> - removed: Array> - } - warnings: string[] -} { - const currentBlocks = currentWorkflowState?.blocks ?? {} - const nextBlocks = nextWorkflowState.blocks ?? {} - - const currentBlockIds = new Set(Object.keys(currentBlocks)) - const nextBlockIds = new Set(Object.keys(nextBlocks)) - - const added = [...nextBlockIds].filter((blockId) => !currentBlockIds.has(blockId)).sort() - const removed = [...currentBlockIds].filter((blockId) => !nextBlockIds.has(blockId)).sort() - const updated = [...nextBlockIds] - .filter((blockId) => currentBlockIds.has(blockId)) - .filter( - (blockId) => toDocumentJson(currentBlocks[blockId]) !== toDocumentJson(nextBlocks[blockId]) - ) - .sort() - - const toComparableEdge = (edge: Edge) => ({ - source: edge.source, - target: edge.target, - sourceHandle: edge.sourceHandle || 'source', - targetHandle: edge.targetHandle || 'target', - }) - - const currentEdges = (currentWorkflowState?.edges ?? []).map(toComparableEdge) - const nextEdges = (nextWorkflowState.edges ?? []).map(toComparableEdge) - const currentEdgeKeys = new Set( - currentEdges.map( - (edge) => `${edge.source}:${edge.sourceHandle}->${edge.target}:${edge.targetHandle}` - ) - ) - const nextEdgeKeys = new Set( - nextEdges.map( - (edge) => `${edge.source}:${edge.sourceHandle}->${edge.target}:${edge.targetHandle}` - ) - ) - - const edgeDiff = { - added: nextEdges.filter( - (edge) => - !currentEdgeKeys.has( - `${edge.source}:${edge.sourceHandle}->${edge.target}:${edge.targetHandle}` - ) - ), - removed: currentEdges.filter( - (edge) => - !nextEdgeKeys.has( - `${edge.source}:${edge.sourceHandle}->${edge.target}:${edge.targetHandle}` - ) - ), - } - - const warnings: string[] = [] - if (added.length === 0 && removed.length === 0 && updated.length === 0) { - warnings.push('No block changes detected.') - } - if (edgeDiff.added.length === 0 && edgeDiff.removed.length === 0) { - warnings.push('No edge changes detected.') - } - - return { - blockDiff: { added, removed, updated }, - edgeDiff, - warnings, - } -} diff --git a/apps/tradinggoose/lib/workflows/workflow-direction.ts b/apps/tradinggoose/lib/workflows/workflow-direction.ts index 834d92072..1c03d2253 100644 --- a/apps/tradinggoose/lib/workflows/workflow-direction.ts +++ b/apps/tradinggoose/lib/workflows/workflow-direction.ts @@ -1,4 +1,3 @@ -import { applyAutoLayout } from '@/lib/workflows/autolayout' import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' import type { BlockState, WorkflowDirection } from '@/stores/workflows/workflow/types' @@ -29,7 +28,7 @@ export function getAbsoluteBlockPosition( } } -export function inferMermaidDirectionFromWorkflowState( +export function inferWorkflowDirectionFromState( workflowState: WorkflowGraphState ): WorkflowDirection { const blocks = workflowState.blocks ?? {} @@ -88,38 +87,3 @@ export function inferMermaidDirectionFromWorkflowState( return horizontalSpread > verticalSpread ? 'LR' : 'TD' } - -export function normalizeWorkflowStateToMermaidDirection( - workflowState: WorkflowSnapshot, - direction: WorkflowDirection -): { - workflowState: WorkflowSnapshot - didRelayout: boolean -} { - const inferredDirection = inferMermaidDirectionFromWorkflowState(workflowState) - - if (direction === inferredDirection) { - return { - workflowState: { - ...workflowState, - direction, - }, - didRelayout: false, - } - } - - const relayoutResult = applyAutoLayout(workflowState.blocks, workflowState.edges) - - if (!relayoutResult.success || !relayoutResult.blocks) { - throw new Error(relayoutResult.error || 'Failed to re-layout workflow for Mermaid direction') - } - - return { - workflowState: { - ...workflowState, - direction, - blocks: relayoutResult.blocks, - }, - didRelayout: true, - } -} diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index a66c7c01b..7b937f5b0 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -1,9 +1,8 @@ import { db, workflow } from '@tradinggoose/db' import { eq } from 'drizzle-orm' -import { resolveStoredDateValue } from '@/lib/time-format' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' import { applyWorkflowStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' -import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { createWorkflowSnapshot, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' export async function applyWorkflowState( workflowId: string, @@ -11,14 +10,19 @@ export async function applyWorkflowState( variables?: Record, entityName?: string ): Promise { - await applyWorkflowStateInSocketServer(workflowId, workflowState, variables, entityName) + const syncedAt = new Date() + const appliedWorkflowState = createWorkflowSnapshot({ + ...workflowState, + lastSaved: syncedAt.toISOString(), + }) + + await applyWorkflowStateInSocketServer(workflowId, appliedWorkflowState, variables, entityName) - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState) + const saveResult = await saveWorkflowToNormalizedTables(workflowId, appliedWorkflowState) if (!saveResult.success) { throw new Error(saveResult.error || 'Failed to materialize workflow state') } - const syncedAt = resolveStoredDateValue(workflowState.lastSaved) ?? new Date() await db .update(workflow) .set({ From 5366e930df155e76f98b4e22e37a37705ef42b0f Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 20:11:15 -0600 Subject: [PATCH 035/284] refactor(yjs): remove saved entity field overlays Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/indicators/custom/route.ts | 5 +- .../app/api/indicators/options/route.ts | 2 - .../app/api/mcp/servers/[id]/route.ts | 5 +- .../tradinggoose/app/api/mcp/servers/route.ts | 5 +- apps/tradinggoose/app/api/monitors/shared.ts | 3 - .../tools/server/entities/indicator.ts | 7 +- .../tools/server/entities/mcp-server.ts | 3 +- .../copilot/tools/server/entities/shared.ts | 5 +- .../lib/custom-tools/operations.ts | 16 +-- .../lib/indicators/custom/operations.ts | 16 +-- apps/tradinggoose/lib/knowledge/service.ts | 46 +++--- apps/tradinggoose/lib/mcp/service.ts | 32 ++--- apps/tradinggoose/lib/skills/operations.ts | 16 +-- apps/tradinggoose/lib/yjs/entity-state.ts | 135 +----------------- .../market/indicator-monitor-runtime.ts | 5 +- 15 files changed, 58 insertions(+), 243 deletions(-) diff --git a/apps/tradinggoose/app/api/indicators/custom/route.ts b/apps/tradinggoose/app/api/indicators/custom/route.ts index c0aa0a050..1eb360eae 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -6,7 +6,6 @@ import { z } from 'zod' import { upsertIndicators } from '@/lib/indicators/custom/operations' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityCurrentFieldsToRows } from '@/lib/yjs/entity-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { authenticateIndicatorRequest, checkWorkspacePermission } from '../utils' @@ -105,9 +104,7 @@ export async function GET(request: NextRequest) { .from(pineIndicators) .where(eq(pineIndicators.workspaceId, resolvedWorkspaceId)) .orderBy(desc(pineIndicators.createdAt)) - const result = await applySavedEntityCurrentFieldsToRows('indicator', rows) - - return NextResponse.json({ data: result }, { status: 200 }) + return NextResponse.json({ data: rows }, { status: 200 }) } catch (error) { logger.error(`[${requestId}] Error fetching indicators:`, error) return NextResponse.json({ error: 'Failed to fetch indicators' }, { status: 500 }) diff --git a/apps/tradinggoose/app/api/indicators/options/route.ts b/apps/tradinggoose/app/api/indicators/options/route.ts index 3acd8a51b..748011055 100644 --- a/apps/tradinggoose/app/api/indicators/options/route.ts +++ b/apps/tradinggoose/app/api/indicators/options/route.ts @@ -9,7 +9,6 @@ import { isIndicatorTriggerCapable } from '@/lib/indicators/trigger-detection' import type { InputMetaMap } from '@/lib/indicators/types' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityCurrentFieldsToRows } from '@/lib/yjs/entity-state' import { authenticateIndicatorRequest, checkWorkspacePermission } from '../utils' const logger = createLogger('IndicatorOptionsAPI') @@ -97,7 +96,6 @@ export async function GET(request: NextRequest) { }) .from(pineIndicators) .where(eq(pineIndicators.workspaceId, workspaceId)) - .then((rows) => applySavedEntityCurrentFieldsToRows('indicator', rows)) const customOptions: IndicatorOptionRecord[] = customRows .filter((row) => copilotSurface || isIndicatorTriggerCapable(row.pineCode)) diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts index 6aad74176..ecbb6bb3c 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts @@ -7,7 +7,7 @@ import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { applySavedEntityCurrentFieldsToRow, savedEntityRowToFields } from '@/lib/yjs/entity-state' +import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' import { UpdateMcpServerSchema } from '../schema' @@ -95,13 +95,12 @@ export const PATCH = withMcpAuth('write')( nextServer.id, savedEntityRowToFields('mcp_server', nextServer) ) - const updatedServer = await applySavedEntityCurrentFieldsToRow('mcp_server', nextServer) // Clear MCP service cache after update mcpService.clearCache(workspaceId) logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`) - return createMcpSuccessResponse({ server: updatedServer }) + return createMcpSuccessResponse({ server: nextServer }) } catch (error) { logger.error(`[${requestId}] Error updating MCP server:`, error) return createMcpErrorResponse( diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 0c5f6e38e..f6b0528ae 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -8,7 +8,7 @@ import { mcpService } from '@/lib/mcp/service' import type { McpTransport } from '@/lib/mcp/types' import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { applySavedEntityCurrentFieldsToRows, savedEntityRowToFields } from '@/lib/yjs/entity-state' +import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { CreateMcpServerSchema } from './schema' @@ -32,11 +32,10 @@ export const GET = withMcpAuth('read')( try { logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) - const rows = await db + const servers = await db .select() .from(mcpServers) .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) - const servers = await applySavedEntityCurrentFieldsToRows('mcp_server', rows) logger.info( `[${requestId}] Listed ${servers.length} MCP servers for workspace ${workspaceId}` diff --git a/apps/tradinggoose/app/api/monitors/shared.ts b/apps/tradinggoose/app/api/monitors/shared.ts index 1769bf8b4..d813478f6 100644 --- a/apps/tradinggoose/app/api/monitors/shared.ts +++ b/apps/tradinggoose/app/api/monitors/shared.ts @@ -35,7 +35,6 @@ import { resolveTradingProviderSelectedAccount, } from '@/lib/trading/context' import { isTradingServiceError } from '@/lib/trading/errors' -import { applySavedEntityCurrentFieldsToRows } from '@/lib/yjs/entity-state' type WebhookRow = typeof webhook.$inferSelect @@ -272,7 +271,6 @@ export const ensureTriggerCapableIndicator = async (workspaceId: string, indicat .from(pineIndicators) .where(and(eq(pineIndicators.id, indicatorId), eq(pineIndicators.workspaceId, workspaceId))) .limit(1) - .then((rows) => applySavedEntityCurrentFieldsToRows('indicator', rows)) const customIndicator = customRows[0] if (!customIndicator) { @@ -306,7 +304,6 @@ export const loadIndicatorInputMetadata = async ( .from(pineIndicators) .where(and(eq(pineIndicators.id, indicatorId), eq(pineIndicators.workspaceId, workspaceId))) .limit(1) - .then((rows) => applySavedEntityCurrentFieldsToRows('indicator', rows)) const row = rows[0] if (!row) { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts index 8fc1cdb3b..c33c1e4c6 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts @@ -9,7 +9,7 @@ import { resolveDefaultIndicatorRuntimeEntry, } from '@/lib/indicators/default/runtime' import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' -import { applySavedEntityCurrentFieldsToRows, savedEntityRowToFields } from '@/lib/yjs/entity-state' +import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { acceptEntityDocumentReview, buildDocumentEnvelope, @@ -39,9 +39,7 @@ function toDefaultIndicatorListEntry(entry: (typeof DEFAULT_INDICATOR_RUNTIME_EN } function toCustomIndicatorListEntry( - row: Awaited< - ReturnType> - >[number] + row: typeof pineIndicators.$inferSelect ): CopilotIndicatorListEntry { const inputMeta = normalizeInputMetaMap(row.inputMeta) const inputTitles = Object.keys(inputMeta ?? {}) @@ -63,7 +61,6 @@ async function listCopilotIndicators(workspaceId: string): Promise applySavedEntityCurrentFieldsToRows(ENTITY_KIND_INDICATOR, rows)) const customOptions = customRows.map(toCustomIndicatorListEntry) return [...defaultOptions, ...customOptions].sort((a, b) => a.name.localeCompare(b.name)) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts index dcebdee29..614f5827a 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -6,7 +6,7 @@ import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' import { mcpService } from '@/lib/mcp/service' import type { McpTransport } from '@/lib/mcp/types' import { validateMcpServerUrl } from '@/lib/mcp/url-validator' -import { applySavedEntityCurrentFieldsToRows, savedEntityRowToFields } from '@/lib/yjs/entity-state' +import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { acceptEntityDocumentReview, applySavedEntityDocument, @@ -151,7 +151,6 @@ export const listMcpServersServerTool: EntityServerTool> = .select() .from(mcpServers) .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) - .then((serverRows) => applySavedEntityCurrentFieldsToRows(ENTITY_KIND_MCP_SERVER, serverRows)) const entities = rows.map(toMcpServerListEntry) return { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index b9a6d061f..8f6541ddc 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -15,8 +15,9 @@ import { withWorkspaceArgContext, } from '@/lib/copilot/tools/server/base-tool' import { checkWorkspaceAccess } from '@/lib/permissions/utils' -import { readSavedEntityFields, type SavedEntityKind } from '@/lib/yjs/entity-state' +import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { readSavedEntityFieldsFromDb } from '@/lib/yjs/server/entity-loaders' export type SavedEntityDocumentKind = EntityDocumentKind export type EntityDocumentArgs = { @@ -197,7 +198,7 @@ export async function readSavedEntityDocumentFields( entityId: string, workspaceId: string ): Promise> { - return readSavedEntityFields(kind as SavedEntityKind, entityId, workspaceId) + return readSavedEntityFieldsFromDb(kind as SavedEntityKind, entityId, workspaceId) } export async function applySavedEntityDocument( diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index a183bcd90..d9d9d3dc2 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -8,7 +8,7 @@ import { } from '@/lib/custom-tools/import-export' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityCurrentFieldsToRows, applySavedEntityRows } from '@/lib/yjs/entity-state' +import { applySavedEntityRows } from '@/lib/yjs/entity-state' const logger = createLogger('CustomToolsOperations') @@ -32,13 +32,11 @@ interface ImportCustomToolsParams { } export async function listCustomTools(params: { workspaceId: string }) { - const rows = await db + return db .select() .from(customTools) .where(eq(customTools.workspaceId, params.workspaceId)) .orderBy(desc(customTools.createdAt)) - - return applySavedEntityCurrentFieldsToRows('custom_tool', rows) } /** @@ -53,7 +51,7 @@ export async function upsertCustomTools({ const createdRows: Array = [] const updatedRows: Array = [] const createdIds: string[] = [] - const result = await db.transaction(async (tx) => { + await db.transaction(async (tx) => { for (const tool of tools) { const nowTime = new Date() const duplicateTitle = await tx @@ -103,12 +101,6 @@ export async function upsertCustomTools({ createdRows.push(newTool) createdIds.push(toolId) } - - return tx - .select() - .from(customTools) - .where(eq(customTools.workspaceId, workspaceId)) - .orderBy(desc(customTools.createdAt)) }) await applySavedEntityRows('custom_tool', createdRows, { @@ -120,7 +112,7 @@ export async function upsertCustomTools({ }) await applySavedEntityRows('custom_tool', updatedRows) - return applySavedEntityCurrentFieldsToRows('custom_tool', result) + return listCustomTools({ workspaceId }) } export async function importCustomTools({ diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index 4068f654e..5d05fae2e 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -9,7 +9,7 @@ import { import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityCurrentFieldsToRows, applySavedEntityRows } from '@/lib/yjs/entity-state' +import { applySavedEntityRows } from '@/lib/yjs/entity-state' const logger = createLogger('IndicatorsOperations') @@ -55,7 +55,7 @@ export async function upsertIndicators({ const createdRows: Array = [] const updatedRows: Array = [] const createdIds: string[] = [] - const result = await db.transaction(async (tx) => { + await db.transaction(async (tx) => { for (const indicator of indicators) { const nowTime = new Date() @@ -102,12 +102,6 @@ export async function upsertIndicators({ createdRows.push(newIndicator) createdIds.push(indicatorId) } - - return tx - .select() - .from(pineIndicators) - .where(eq(pineIndicators.workspaceId, workspaceId)) - .orderBy(desc(pineIndicators.createdAt)) }) await applySavedEntityRows('indicator', createdRows, { @@ -119,7 +113,11 @@ export async function upsertIndicators({ }) await applySavedEntityRows('indicator', updatedRows) - return applySavedEntityCurrentFieldsToRows('indicator', result) + return db + .select() + .from(pineIndicators) + .where(eq(pineIndicators.workspaceId, workspaceId)) + .orderBy(desc(pineIndicators.createdAt)) } export async function importIndicators({ diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index 53aca9ad4..138bcc8ae 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -21,11 +21,7 @@ import type { } from '@/lib/knowledge/types' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/permissions/utils' -import { - applySavedEntityCurrentFieldsToRow, - applySavedEntityCurrentFieldsToRows, - savedEntityRowToFields, -} from '@/lib/yjs/entity-state' +import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' @@ -66,14 +62,11 @@ export async function getKnowledgeBases( .groupBy(knowledgeBase.id) .orderBy(knowledgeBase.createdAt) - return applySavedEntityCurrentFieldsToRows( - ENTITY_KIND_KNOWLEDGE_BASE, - knowledgeBasesWithCounts.map((kb) => ({ - ...kb, - chunkingConfig: kb.chunkingConfig as ChunkingConfig, - docCount: Number(kb.docCount), - })) - ) + return knowledgeBasesWithCounts.map((kb) => ({ + ...kb, + chunkingConfig: kb.chunkingConfig as ChunkingConfig, + docCount: Number(kb.docCount), + })) } /** @@ -158,10 +151,6 @@ export async function copyKnowledgeBaseToWorkspace( if (!sourceKnowledgeBase) { throw new Error(`Knowledge base ${sourceKnowledgeBaseId} not found`) } - const sourceKnowledgeBaseFields = await applySavedEntityCurrentFieldsToRow( - ENTITY_KIND_KNOWLEDGE_BASE, - sourceKnowledgeBase - ) const sourceDocuments = await db .select() @@ -209,12 +198,12 @@ export async function copyKnowledgeBaseToWorkspace( id: newKnowledgeBaseId, userId, workspaceId: targetWorkspaceId, - name: `${sourceKnowledgeBaseFields.name} (Copy)`, - description: sourceKnowledgeBaseFields.description, + name: `${sourceKnowledgeBase.name} (Copy)`, + description: sourceKnowledgeBase.description, tokenCount: sourceKnowledgeBase.tokenCount, embeddingModel: sourceKnowledgeBase.embeddingModel, embeddingDimension: sourceKnowledgeBase.embeddingDimension, - chunkingConfig: sourceKnowledgeBaseFields.chunkingConfig, + chunkingConfig: sourceKnowledgeBase.chunkingConfig, createdAt: now, updatedAt: now, deletedAt: null, @@ -256,10 +245,9 @@ export async function copyKnowledgeBaseToWorkspace( mimeType: sourceDocument.mimeType, }, processingOptions: { - chunkSize: (sourceKnowledgeBaseFields.chunkingConfig as ChunkingConfig).maxSize, - minCharactersPerChunk: (sourceKnowledgeBaseFields.chunkingConfig as ChunkingConfig) - .minSize, - chunkOverlap: (sourceKnowledgeBaseFields.chunkingConfig as ChunkingConfig).overlap, + chunkSize: (sourceKnowledgeBase.chunkingConfig as ChunkingConfig).maxSize, + minCharactersPerChunk: (sourceKnowledgeBase.chunkingConfig as ChunkingConfig).minSize, + chunkOverlap: (sourceKnowledgeBase.chunkingConfig as ChunkingConfig).overlap, }, requestId, }) @@ -348,12 +336,12 @@ export async function copyKnowledgeBaseToWorkspace( const copied = { id: newKnowledgeBaseId, - name: `${sourceKnowledgeBaseFields.name} (Copy)`, - description: sourceKnowledgeBaseFields.description, + name: `${sourceKnowledgeBase.name} (Copy)`, + description: sourceKnowledgeBase.description, tokenCount: sourceKnowledgeBase.tokenCount, embeddingModel: sourceKnowledgeBase.embeddingModel, embeddingDimension: sourceKnowledgeBase.embeddingDimension, - chunkingConfig: sourceKnowledgeBaseFields.chunkingConfig as ChunkingConfig, + chunkingConfig: sourceKnowledgeBase.chunkingConfig as ChunkingConfig, createdAt: now, updatedAt: now, workspaceId: targetWorkspaceId, @@ -447,11 +435,11 @@ export async function getKnowledgeBaseById( return null } - return applySavedEntityCurrentFieldsToRow(ENTITY_KIND_KNOWLEDGE_BASE, { + return { ...result[0], chunkingConfig: result[0].chunkingConfig as ChunkingConfig, docCount: Number(result[0].docCount), - }) + } } /** diff --git a/apps/tradinggoose/lib/mcp/service.ts b/apps/tradinggoose/lib/mcp/service.ts index 76a069f07..3137c4634 100644 --- a/apps/tradinggoose/lib/mcp/service.ts +++ b/apps/tradinggoose/lib/mcp/service.ts @@ -19,10 +19,6 @@ import type { } from '@/lib/mcp/types' import { MCP_CONSTANTS } from '@/lib/mcp/utils' import { generateRequestId } from '@/lib/utils' -import { - applySavedEntityCurrentFieldsToRow, - applySavedEntityCurrentFieldsToRows, -} from '@/lib/yjs/entity-state' const logger = createLogger('McpService') @@ -263,23 +259,22 @@ class McpService { return null } - const config = await applySavedEntityCurrentFieldsToRow('mcp_server', server) - if (!config.enabled) { + if (!server.enabled) { return null } return { - id: config.id, - name: config.name, - description: config.description || undefined, - transport: config.transport as 'http' | 'sse', - url: config.url || undefined, - headers: (config.headers as Record) || {}, - timeout: config.timeout || 30000, - retries: config.retries || 3, - enabled: config.enabled, - createdAt: config.createdAt.toISOString(), - updatedAt: config.updatedAt.toISOString(), + id: server.id, + name: server.name, + description: server.description || undefined, + transport: server.transport as 'http' | 'sse', + url: server.url || undefined, + headers: (server.headers as Record) || {}, + timeout: server.timeout || 30000, + retries: server.retries || 3, + enabled: server.enabled, + createdAt: server.createdAt.toISOString(), + updatedAt: server.updatedAt.toISOString(), } } @@ -289,11 +284,10 @@ class McpService { private async getWorkspaceServers(workspaceId: string): Promise { const whereConditions = [eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt)] - const rows = await db + const servers = await db .select() .from(mcpServers) .where(and(...whereConditions)) - const servers = await applySavedEntityCurrentFieldsToRows('mcp_server', rows) return servers .filter((server) => server.enabled) diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index 8fe84971e..f8a1308e4 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -9,7 +9,7 @@ import { type SkillTransferRecord, } from '@/lib/skills/import-export' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityCurrentFieldsToRows, applySavedEntityRows } from '@/lib/yjs/entity-state' +import { applySavedEntityRows } from '@/lib/yjs/entity-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('SkillsOperations') @@ -34,13 +34,11 @@ interface ImportSkillsParams { } export async function listSkills(params: { workspaceId: string }) { - const rows = await db + return db .select() .from(skill) .where(eq(skill.workspaceId, params.workspaceId)) .orderBy(desc(skill.createdAt)) - - return applySavedEntityCurrentFieldsToRows('skill', rows) } export async function deleteSkill(params: { @@ -75,7 +73,7 @@ export async function upsertSkills({ const createdRows: Array = [] const updatedRows: Array = [] const createdIds: string[] = [] - const result = await db.transaction(async (tx) => { + await db.transaction(async (tx) => { for (const currentSkill of skills) { const nowTime = new Date() @@ -148,12 +146,6 @@ export async function upsertSkills({ createdRows.push(newSkill) createdIds.push(skillId) } - - return tx - .select() - .from(skill) - .where(eq(skill.workspaceId, workspaceId)) - .orderBy(desc(skill.createdAt)) }) await applySavedEntityRows('skill', createdRows, { @@ -165,7 +157,7 @@ export async function upsertSkills({ }) await applySavedEntityRows('skill', updatedRows) - return applySavedEntityCurrentFieldsToRows('skill', result) + return listSkills({ workspaceId }) } export async function importSkills({ diff --git a/apps/tradinggoose/lib/yjs/entity-state.ts b/apps/tradinggoose/lib/yjs/entity-state.ts index 64259a9d8..04e73f2f0 100644 --- a/apps/tradinggoose/lib/yjs/entity-state.ts +++ b/apps/tradinggoose/lib/yjs/entity-state.ts @@ -1,6 +1,4 @@ -import * as Y from 'yjs' -import type { ReviewEntityKind, ReviewTargetDescriptor } from '@/lib/copilot/review-sessions/types' -import { getEntityFields } from '@/lib/yjs/entity-session' +import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' export type SavedEntityKind = Exclude @@ -11,29 +9,6 @@ type SavedEntityRow = { [key: string]: any } -function parseObjectJson(value: unknown, fieldName: string): Record { - const parsed = JSON.parse(String(value ?? '')) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`${fieldName} must be a JSON object`) - } - return parsed as Record -} - -export function buildSavedEntityYjsDescriptor( - entityKind: SavedEntityKind, - entityId: string, - workspaceId: string -): ReviewTargetDescriptor { - return { - workspaceId, - entityKind, - entityId, - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: entityId, - } -} - export function savedEntityRowToFields( entityKind: SavedEntityKind, row: SavedEntityRow @@ -88,114 +63,6 @@ export function savedEntityRowToFields( } } -export function applySavedEntityFieldsToRow( - entityKind: SavedEntityKind, - row: T, - fields: Record -): T { - switch (entityKind) { - case 'skill': - return { - ...row, - name: String(fields.name ?? ''), - description: String(fields.description ?? ''), - content: String(fields.content ?? ''), - } - case 'custom_tool': - return { - ...row, - title: String(fields.title ?? ''), - schema: parseObjectJson(fields.schemaText, 'schemaText'), - code: String(fields.codeText ?? ''), - } - case 'indicator': - return { - ...row, - name: String(fields.name ?? ''), - color: String(fields.color ?? ''), - pineCode: String(fields.pineCode ?? ''), - inputMeta: - fields.inputMeta && - typeof fields.inputMeta === 'object' && - !Array.isArray(fields.inputMeta) - ? fields.inputMeta - : null, - } - case 'knowledge_base': - return { - ...row, - name: String(fields.name ?? ''), - description: String(fields.description ?? ''), - chunkingConfig: fields.chunkingConfig, - } - case 'mcp_server': - return { - ...row, - name: String(fields.name ?? ''), - description: String(fields.description ?? ''), - transport: String(fields.transport ?? 'http'), - url: String(fields.url ?? ''), - headers: - fields.headers && typeof fields.headers === 'object' && !Array.isArray(fields.headers) - ? fields.headers - : {}, - command: String(fields.command ?? ''), - args: Array.isArray(fields.args) ? fields.args : [], - env: - fields.env && typeof fields.env === 'object' && !Array.isArray(fields.env) - ? fields.env - : {}, - timeout: Number(fields.timeout ?? 30000), - retries: Number(fields.retries ?? 3), - enabled: fields.enabled !== false, - } - } -} - -export async function readSavedEntityFields( - entityKind: SavedEntityKind, - entityId: string, - workspaceId: string -): Promise> { - const { readBootstrappedReviewTargetSnapshot } = await import( - '@/lib/yjs/server/bootstrap-review-target' - ) - const snapshot = await readBootstrappedReviewTargetSnapshot( - buildSavedEntityYjsDescriptor(entityKind, entityId, workspaceId) - ) - - if (!snapshot.snapshotBase64) { - throw new Error(`Saved ${entityKind} Yjs state is empty for ${entityId}`) - } - - const doc = new Y.Doc() - try { - Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) - return getEntityFields(doc, entityKind) - } finally { - doc.destroy() - } -} - -export async function applySavedEntityCurrentFieldsToRow( - entityKind: SavedEntityKind, - row: T -): Promise { - if (!row.workspaceId) { - return row - } - - const fields = await readSavedEntityFields(entityKind, row.id, row.workspaceId) - return applySavedEntityFieldsToRow(entityKind, row, fields) -} - -export async function applySavedEntityCurrentFieldsToRows( - entityKind: SavedEntityKind, - rows: T[] -): Promise { - return Promise.all(rows.map((row) => applySavedEntityCurrentFieldsToRow(entityKind, row))) -} - export async function applySavedEntityRows( entityKind: SavedEntityKind, rows: T[], diff --git a/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts b/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts index 1b5726abd..2d4f70876 100644 --- a/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts +++ b/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts @@ -25,7 +25,6 @@ import { isMonitorProviderConfigForProvider, } from '@/lib/monitors/sources' import { decryptSecret } from '@/lib/utils-server' -import { applySavedEntityCurrentFieldsToRows } from '@/lib/yjs/entity-state' import type { MonitorExecutionPayload } from '@/background/monitor-execution' import { executeProviderRequest } from '@/providers/market' import { getMarketProviderConfig } from '@/providers/market/providers' @@ -280,9 +279,7 @@ async function resolveIndicatorDefinitions( ) ) - const indicators = await applySavedEntityCurrentFieldsToRows('indicator', rows) - - indicators.forEach((row) => { + rows.forEach((row) => { definitions.set(`${row.workspaceId}:${row.id}`, { id: row.id, name: row.name, From 8fea596b55aadb23c43fa5c17d43344e04211172 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 20:32:29 -0600 Subject: [PATCH 036/284] fix(copilot): persist server tool review tokens Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../execute-copilot-server-tool/route.ts | 54 ++++++----- .../tools/client/server-tool-response.ts | 13 ++- .../copilot/tools/server/review-acceptance.ts | 89 +++++++++++++++++-- .../tradinggoose/stores/copilot/store.test.ts | 9 +- apps/tradinggoose/stores/copilot/store.ts | 12 ++- 5 files changed, 132 insertions(+), 45 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts b/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts index b389052b2..fadb53a90 100644 --- a/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts +++ b/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts @@ -13,20 +13,21 @@ import { checkWorkspaceAccess } from '@/lib/permissions/utils' const logger = createLogger('ExecuteCopilotServerToolAPI') -const ExecuteSchema = z.object({ - toolName: z.string().min(1), - payload: z.unknown().optional(), - accessLevel: z.enum(['limited', 'full']), - reviewAction: z.enum(['accept']).optional(), - reviewResult: z.unknown().optional(), - context: z - .object({ - contextEntityKind: z.enum(REVIEW_ENTITY_KINDS).optional(), - contextEntityId: z.string().optional(), - workspaceId: z.string().optional(), - }) - .optional(), -}) +const ExecuteSchema = z + .object({ + toolName: z.string().min(1), + payload: z.unknown().optional(), + reviewAction: z.enum(['accept']).optional(), + reviewToken: z.string().optional(), + context: z + .object({ + contextEntityKind: z.enum(REVIEW_ENTITY_KINDS).optional(), + contextEntityId: z.string().optional(), + workspaceId: z.string().optional(), + }) + .optional(), + }) + .strict() function readPayloadWorkspaceId(payload: unknown): string | undefined { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { @@ -67,7 +68,10 @@ export async function POST(req: NextRequest) { throw error } toolName = parsedBody.toolName - const { payload, accessLevel, context, reviewAction, reviewResult } = parsedBody + const { payload, context, reviewAction, reviewToken } = parsedBody + if (reviewAction === 'accept' && !reviewToken) { + return createBadRequestResponse('reviewToken is required to accept a server tool review') + } const payloadWorkspaceId = readPayloadWorkspaceId(payload) const contextWorkspaceId = context?.workspaceId?.trim() @@ -80,7 +84,11 @@ export async function POST(req: NextRequest) { ? { ...(context ?? {}), workspaceId: payloadWorkspaceId } : context - const [{ isToolId }, { routeExecution }, { acceptServerManagedToolReview }] = + const [ + { isToolId }, + { routeExecution }, + { acceptServerManagedToolReview, stageServerManagedToolReview }, + ] = await Promise.all([ import('@/lib/copilot/registry'), import('@/lib/copilot/tools/server/router'), @@ -90,8 +98,9 @@ export async function POST(req: NextRequest) { if (!isToolId(toolName)) { return createBadRequestResponse('Invalid request body for execute-copilot-server-tool') } + const toolId = toolName - logger.info(`[${tracker.requestId}] Executing server tool`, { toolName, reviewAction }) + logger.info(`[${tracker.requestId}] Executing server tool`, { toolName: toolId, reviewAction }) if (executionContextInput?.workspaceId) { const workspaceAccess = await checkWorkspaceAccess(executionContextInput.workspaceId, userId) if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { @@ -104,14 +113,15 @@ export async function POST(req: NextRequest) { const executionContext = { userId, - accessLevel, + accessLevel: 'limited' as const, ...executionContextInput, signal: req.signal, } - const result = - reviewAction === 'accept' - ? await acceptServerManagedToolReview(toolName, reviewResult, executionContext) - : await routeExecution(toolName, payload, executionContext) + const result = await (reviewAction === 'accept' + ? acceptServerManagedToolReview(toolId, reviewToken!, executionContext) + : routeExecution(toolId, payload, executionContext).then((toolResult) => + stageServerManagedToolReview(toolId, toolResult, executionContext) + )) try { const resultPreview = JSON.stringify(result).slice(0, 300) diff --git a/apps/tradinggoose/lib/copilot/tools/client/server-tool-response.ts b/apps/tradinggoose/lib/copilot/tools/client/server-tool-response.ts index 127f71f44..c7984bd66 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/server-tool-response.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/server-tool-response.ts @@ -1,4 +1,3 @@ -import type { CopilotAccessLevel } from '@/lib/copilot/access-policy' import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas' @@ -70,7 +69,6 @@ export function getCopilotServerToolErrorStatus(error: unknown): number | undefi export async function executeCopilotServerTool(input: { toolName: string payload?: unknown - accessLevel: CopilotAccessLevel context?: { contextEntityKind?: ReviewEntityKind contextEntityId?: string @@ -87,7 +85,6 @@ export async function executeCopilotServerTool(input: { body: JSON.stringify({ toolName: input.toolName, payload: input.payload ?? {}, - accessLevel: input.accessLevel, ...(context ? { context } : {}), }), }) @@ -103,18 +100,19 @@ export async function executeCopilotServerTool(input: { export function isCopilotServerToolReviewResult(result: unknown): result is { requiresReview: true + reviewToken: string } { return ( !!result && typeof result === 'object' && - (result as { requiresReview?: unknown }).requiresReview === true + (result as { requiresReview?: unknown }).requiresReview === true && + typeof (result as { reviewToken?: unknown }).reviewToken === 'string' ) } export async function acceptCopilotServerToolReview(input: { toolName: string - reviewResult: unknown - accessLevel: CopilotAccessLevel + reviewToken: string context?: { contextEntityKind?: ReviewEntityKind contextEntityId?: string @@ -130,9 +128,8 @@ export async function acceptCopilotServerToolReview(input: { signal: input.signal, body: JSON.stringify({ toolName: input.toolName, - accessLevel: input.accessLevel, reviewAction: 'accept', - reviewResult: input.reviewResult, + reviewToken: input.reviewToken, ...(context ? { context } : {}), }), }) diff --git a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts index 69398e961..1d5928fe2 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts @@ -1,4 +1,8 @@ -import { isToolId, type ToolId, ToolResultSchemas } from '@/lib/copilot/registry' +import { db } from '@tradinggoose/db' +import { verification } from '@tradinggoose/db/schema' +import { eq } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { type ToolId, ToolResultSchemas } from '@/lib/copilot/registry' import type { ServerToolExecutionContext } from '@/lib/copilot/tools/server/base-tool' import { acceptCustomToolDocumentReview, @@ -9,17 +13,88 @@ import { } from '@/lib/copilot/tools/server/entities' import { acceptKnowledgeBaseDocumentReview } from '@/lib/copilot/tools/server/knowledge/knowledge-base' -export async function acceptServerManagedToolReview( - toolName: string, - reviewResult: unknown, +const REVIEW_TOKEN_PREFIX = 'copilot-tool-review:' +const REVIEW_TOKEN_TTL_MS = 30 * 60 * 1000 + +export async function stageServerManagedToolReview( + toolName: ToolId, + result: unknown, + context?: ServerToolExecutionContext +) { + if ( + !result || + typeof result !== 'object' || + (result as { requiresReview?: unknown }).requiresReview !== true + ) { + return result + } + if (!context?.userId) { + throw new Error('Authenticated user is required to stage server tool review') + } + + const reviewToken = nanoid() + const now = new Date() + await db.insert(verification).values({ + id: nanoid(), + identifier: `${REVIEW_TOKEN_PREFIX}${reviewToken}`, + value: JSON.stringify({ + userId: context.userId, + toolName, + result, + }), + expiresAt: new Date(now.getTime() + REVIEW_TOKEN_TTL_MS), + createdAt: now, + updatedAt: now, + }) + + return { + ...result, + reviewToken, + } +} + +async function consumeServerManagedToolReview( + toolName: ToolId, + reviewToken: string, context?: ServerToolExecutionContext ) { - if (!isToolId(toolName)) { - throw new Error(`Unknown server tool review: ${toolName}`) + if (!context?.userId) { + throw new Error('Authenticated user is required to accept server tool review') } + const [row] = await db + .delete(verification) + .where(eq(verification.identifier, `${REVIEW_TOKEN_PREFIX}${reviewToken}`)) + .returning({ + value: verification.value, + expiresAt: verification.expiresAt, + }) + + if (!row || row.expiresAt <= new Date()) { + throw new Error('Server tool review token is invalid or expired') + } + + let staged: { userId?: unknown; toolName?: unknown; result?: unknown } + try { + staged = JSON.parse(row.value) as { userId?: unknown; toolName?: unknown; result?: unknown } + } catch { + throw new Error('Server tool review token is invalid or expired') + } + if (!staged || staged.userId !== context.userId || staged.toolName !== toolName) { + throw new Error('Server tool review token does not match this request') + } + + return staged.result +} + +export async function acceptServerManagedToolReview( + toolName: ToolId, + reviewToken: string, + context?: ServerToolExecutionContext +) { + const reviewResult = await consumeServerManagedToolReview(toolName, reviewToken, context) const parsedResult = ToolResultSchemas[toolName].parse(reviewResult) - switch (toolName as ToolId) { + switch (toolName) { case 'edit_workflow': case 'edit_workflow_block': case 'edit_workflow_variable': diff --git a/apps/tradinggoose/stores/copilot/store.test.ts b/apps/tradinggoose/stores/copilot/store.test.ts index b247d578a..17d138383 100644 --- a/apps/tradinggoose/stores/copilot/store.test.ts +++ b/apps/tradinggoose/stores/copilot/store.test.ts @@ -1299,6 +1299,7 @@ describe('copilot streaming regressions', () => { const assistantMessageId = 'assistant-message-limited-edit' const reviewResult = { requiresReview: true, + reviewToken: 'review-token-limited-edit', entityKind: 'workflow', entityId: 'wf-limited-edit', entityDocument: 'flowchart TD', @@ -2188,7 +2189,6 @@ describe('copilot streaming regressions', () => { }) expect(parseJsonRequestBody(executeRequest)).toEqual({ toolName: 'list_workflows', - accessLevel: 'limited', payload: { workspaceId: 'workspace-1', }, @@ -2889,6 +2889,7 @@ describe('copilot tool user action delegation', () => { const store = getCopilotStore(channelId) const reviewResult = { requiresReview: true, + reviewToken: 'review-token-edit-workflow-order', entityKind: 'workflow', entityId: 'wf-edit-workflow-order', entityDocument: @@ -2950,7 +2951,6 @@ describe('copilot tool user action delegation', () => { }) expect(parseJsonRequestBody(executeRequest)).toEqual({ toolName: 'edit_workflow', - accessLevel: 'limited', payload: { entityDocument: 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', @@ -2966,6 +2966,7 @@ describe('copilot tool user action delegation', () => { const store = getCopilotStore(channelId) const reviewResult = { requiresReview: true, + reviewToken: 'review-token-edit-workflow-review', entityKind: 'workflow', entityId: 'wf-edit-workflow-review', entityDocument: @@ -3035,9 +3036,8 @@ describe('copilot tool user action delegation', () => { }) expect(parseJsonRequestBody(executeRequest)).toEqual({ toolName: 'edit_workflow', - accessLevel: 'limited', reviewAction: 'accept', - reviewResult, + reviewToken: 'review-token-edit-workflow-review', }) expect(store.getState().toolCallsById[toolCallId]?.state).toBe(ClientToolCallState.success) }) @@ -3107,7 +3107,6 @@ describe('copilot tool user action delegation', () => { }) expect(parseJsonRequestBody(executeRequest)).toEqual({ toolName: 'make_api_request', - accessLevel: 'full', payload: { url: 'https://example.com/data', method: 'GET', diff --git a/apps/tradinggoose/stores/copilot/store.ts b/apps/tradinggoose/stores/copilot/store.ts index 4043a38f2..7c704418f 100644 --- a/apps/tradinggoose/stores/copilot/store.ts +++ b/apps/tradinggoose/stores/copilot/store.ts @@ -1396,19 +1396,25 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) : {}), ...(provenance?.workspaceId ? { workspaceId: provenance.workspaceId } : {}), } + const reviewResult = get().toolCallsById[id]?.result + const reviewToken = + acceptingServerReview && isCopilotServerToolReviewResult(reviewResult) + ? reviewResult.reviewToken + : undefined + if (acceptingServerReview && !reviewToken) { + throw new Error('Server tool review token is missing') + } const result = acceptingServerReview ? await acceptCopilotServerToolReview({ toolName: name, - reviewResult: get().toolCallsById[id]?.result, - accessLevel: get().accessLevel, + reviewToken: reviewToken!, context: serverContext, signal: get().abortController?.signal, }) : await executeCopilotServerTool({ toolName: name, payload: preparedArgs, - accessLevel: get().accessLevel, context: serverContext, signal: get().abortController?.signal, }) From fb8ff93625ca3595b318b7f1c25ea74174644c21 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 20:32:49 -0600 Subject: [PATCH 037/284] fix(mcp): reject reused device login approvals Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/mcp/auth.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index f1c276589..0dcd899f9 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -310,7 +310,7 @@ export async function pollMcpDeviceLogin( } if (!(await confirmMcpDeviceLoginKey(login, approvedState, options.apiKey))) { - continue + return { status: 'invalid' } } return { @@ -319,6 +319,10 @@ export async function pollMcpDeviceLogin( } } + if (isIssuedDeviceLogin(approvedState)) { + return { status: 'invalid' } + } + const issued = await issueMcpDeviceLoginKey(login, approvedState) if (!issued) { continue From a3c4ea1f9d88e22df37cccbe470fe298abd430d7 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 20:33:06 -0600 Subject: [PATCH 038/284] fix(workflows): avoid loading deployed workflow variables Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/workflows/execution-runner.test.ts | 5 +++ .../lib/workflows/execution-runner.ts | 44 ++++++++++++------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/apps/tradinggoose/lib/workflows/execution-runner.test.ts b/apps/tradinggoose/lib/workflows/execution-runner.test.ts index c7ceaa262..cffa8b399 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.test.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.test.ts @@ -470,6 +470,11 @@ describe('loadWorkflowExecutionBlueprint', () => { expect(result.workflowContext.variables).toEqual(deployedVariables) expect(result.workflowData.blocks.trigger?.subBlocks).toEqual({}) + const selectShape = (mocks.dbSelect.mock.calls as unknown[][])[0]?.[0] as Record< + string, + unknown + > + expect(Object.keys(selectShape)).toEqual(['workspaceId']) expect(loadWorkflowState).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/lib/workflows/execution-runner.ts b/apps/tradinggoose/lib/workflows/execution-runner.ts index b72ab3717..b2f23b4e4 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.ts @@ -92,19 +92,27 @@ async function resolveRequiredWorkflowExecutionContext( let workflowRecord: | { workspaceId: string | null - variables: unknown + variables?: unknown } | undefined if (needsWorkflowRecord) { - ;[workflowRecord] = await db - .select({ - workspaceId: workflowTable.workspaceId, - variables: workflowTable.variables, - }) - .from(workflowTable) - .where(eq(workflowTable.id, workflowId)) - .limit(1) + if (workflowContext?.variables === undefined) { + ;[workflowRecord] = await db + .select({ + workspaceId: workflowTable.workspaceId, + variables: workflowTable.variables, + }) + .from(workflowTable) + .where(eq(workflowTable.id, workflowId)) + .limit(1) + } else { + ;[workflowRecord] = await db + .select({ workspaceId: workflowTable.workspaceId }) + .from(workflowTable) + .where(eq(workflowTable.id, workflowId)) + .limit(1) + } } const workspaceId = providedWorkspaceId ?? workflowRecord?.workspaceId @@ -266,14 +274,16 @@ export async function loadWorkflowExecutionBlueprint(params: { : null const workflowContext = await resolveRequiredWorkflowExecutionContext( params.workflowId, - executionTarget === 'live' && - liveWorkflowState && - params.workflowContext?.variables === undefined - ? { - ...params.workflowContext, - variables: liveWorkflowState.variables, - } - : params.workflowContext + executionTarget === 'deployed' + ? { ...params.workflowContext, variables: {} } + : executionTarget === 'live' && + liveWorkflowState && + params.workflowContext?.variables === undefined + ? { + ...params.workflowContext, + variables: liveWorkflowState.variables, + } + : params.workflowContext ) const workflowData = executionTarget === 'live' From 4ca9b5155ab8f3cec52c3c1ebbc86c8a7a6a4314 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 20:47:59 -0600 Subject: [PATCH 039/284] fix(mcp): authenticate device login API keys Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/copilot/mcp/route.test.ts | 22 +++--- .../tradinggoose/app/api/copilot/mcp/route.ts | 5 +- apps/tradinggoose/lib/mcp/auth.ts | 71 ++++++++++++++++--- 3 files changed, 74 insertions(+), 24 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index bc1bd2c56..37c845bb6 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -6,14 +6,14 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockAuthenticateApiKeyFromHeader, + mockAuthenticateMcpApiKey, mockGetCopilotRuntimeToolManifest, mockGetServerToolIds, mockGetUserWorkspaces, mockRouteExecution, mockUpdateApiKeyLastUsed, } = vi.hoisted(() => ({ - mockAuthenticateApiKeyFromHeader: vi.fn(), + mockAuthenticateMcpApiKey: vi.fn(), mockGetCopilotRuntimeToolManifest: vi.fn(), mockGetServerToolIds: vi.fn(), mockGetUserWorkspaces: vi.fn(), @@ -22,10 +22,13 @@ const { })) vi.mock('@/lib/api-key/service', () => ({ - authenticateApiKeyFromHeader: (...args: unknown[]) => mockAuthenticateApiKeyFromHeader(...args), updateApiKeyLastUsed: (...args: unknown[]) => mockUpdateApiKeyLastUsed(...args), })) +vi.mock('@/lib/mcp/auth', () => ({ + authenticateMcpApiKey: (...args: unknown[]) => mockAuthenticateMcpApiKey(...args), +})) + vi.mock('@/lib/copilot/runtime-tool-manifest', () => ({ getCopilotRuntimeToolManifest: (...args: unknown[]) => mockGetCopilotRuntimeToolManifest(...args), })) @@ -53,11 +56,10 @@ function createMcpRequest(body: unknown, authorization = 'Bearer sk-tradinggoose describe('Copilot MCP route', () => { beforeEach(() => { vi.resetAllMocks() - mockAuthenticateApiKeyFromHeader.mockResolvedValue({ + mockAuthenticateMcpApiKey.mockResolvedValue({ success: true, userId: 'user-1', keyId: 'key-1', - keyType: 'personal', }) mockGetUserWorkspaces.mockResolvedValue([ { id: 'workspace-1', name: 'Research', permissions: 'admin' }, @@ -92,7 +94,7 @@ describe('Copilot MCP route', () => { expect(response.status).toBe(401) expect(body.error.message).toBe('Bearer token required') - expect(mockAuthenticateApiKeyFromHeader).not.toHaveBeenCalled() + expect(mockAuthenticateMcpApiKey).not.toHaveBeenCalled() }) it('returns initialize metadata with authenticated workspace context', async () => { @@ -102,9 +104,7 @@ describe('Copilot MCP route', () => { const body = await response.json() expect(response.headers.get('MCP-Protocol-Version')).toBe('2025-03-26') - expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('sk-tradinggoose-test', { - keyTypes: ['personal'], - }) + expect(mockAuthenticateMcpApiKey).toHaveBeenCalledWith('sk-tradinggoose-test') expect(mockUpdateApiKeyLastUsed).toHaveBeenCalledWith('key-1') expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ userId: 'user-1', autoCreate: false }) expect(body.result.capabilities).toEqual({ tools: {} }) @@ -124,9 +124,7 @@ describe('Copilot MCP route', () => { ) expect(response.status).toBe(200) - expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('sk-lowercase', { - keyTypes: ['personal'], - }) + expect(mockAuthenticateMcpApiKey).toHaveBeenCalledWith('sk-lowercase') }) it('lists only executable server copilot tools', async () => { diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index 29a7fe872..e64277653 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -1,7 +1,8 @@ import { type NextRequest, NextResponse } from 'next/server' -import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' +import { updateApiKeyLastUsed } from '@/lib/api-key/service' import { getCopilotRuntimeToolManifest } from '@/lib/copilot/runtime-tool-manifest' import { getServerToolIds, routeExecution } from '@/lib/copilot/tools/server/router' +import { authenticateMcpApiKey } from '@/lib/mcp/auth' import { getUserWorkspaces } from '@/lib/workspaces/service' export const dynamic = 'force-dynamic' @@ -72,7 +73,7 @@ async function authenticateCopilotMcpRequest( return { error: 'Bearer token required' } } - const auth = await authenticateApiKeyFromHeader(token, { keyTypes: ['personal'] }) + const auth = await authenticateMcpApiKey(token) if (!auth.success || !auth.userId) { return { error: 'Invalid Copilot MCP bearer token' } } diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 0dcd899f9..4c3eabed4 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -3,10 +3,12 @@ import { db } from '@tradinggoose/db' import { apiKey, verification } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' -import { createApiKeyMaterial, encryptApiKey } from '@/lib/api-key/service' +import { authenticateApiKey } from '@/lib/api-key/auth' +import { decryptApiKey, encryptApiKey } from '@/lib/api-key/service' const DEVICE_LOGIN_TTL_MS = 10 * 60 * 1000 const DEVICE_LOGIN_PREFIX = 'mcp:' +const MCP_API_KEY_PREFIX = 'sk-tradinggoose-mcp.' const POLL_INTERVAL_SECONDS = 2 type PendingDeviceLogin = { @@ -25,11 +27,13 @@ type ApprovedDeviceLogin = { userId: string keyId?: string apiKeyHash?: string + apiKeyEncrypted?: string } type IssuedDeviceLogin = ApprovedDeviceLogin & { keyId: string apiKeyHash: string + apiKeyEncrypted: string } type DeviceLoginState = PendingDeviceLogin | ApprovedDeviceLogin @@ -66,11 +70,21 @@ function hashValue(value: string) { return createHash('sha256').update(value).digest('hex') } +function createMcpApiKey(keyId: string) { + return `${MCP_API_KEY_PREFIX}${keyId}.${randomBytes(32).toString('base64url')}` +} + +function readMcpApiKeyId(value: string) { + const match = value.match(/^sk-tradinggoose-mcp\.([A-Za-z0-9_-]+)\.[A-Za-z0-9_-]+$/) + return match?.[1] ?? null +} + function isIssuedDeviceLogin(state: DeviceLoginState): state is IssuedDeviceLogin { return ( state.status === 'approved' && typeof state.keyId === 'string' && - typeof state.apiKeyHash === 'string' + typeof state.apiKeyHash === 'string' && + typeof state.apiKeyEncrypted === 'string' ) } @@ -90,9 +104,14 @@ function parseDeviceLoginState(value: string): DeviceLoginState | null { ) { return parsed as PendingDeviceLogin } - const approvedHasNoKey = parsed.keyId === undefined && parsed.apiKeyHash === undefined + const approvedHasNoKey = + parsed.keyId === undefined && + parsed.apiKeyHash === undefined && + parsed.apiKeyEncrypted === undefined const approvedHasIssuedKey = - typeof parsed.keyId === 'string' && typeof parsed.apiKeyHash === 'string' + typeof parsed.keyId === 'string' && + typeof parsed.apiKeyHash === 'string' && + typeof parsed.apiKeyEncrypted === 'string' if ( parsed.status === 'approved' && typeof parsed.createdAt === 'string' && @@ -159,16 +178,18 @@ async function issueMcpDeviceLoginKey( approvedState: ApprovedDeviceLogin ): Promise { const keyId = nanoid() - const createdKey = await createApiKeyMaterial(false) + const plainKey = createMcpApiKey(keyId) + const encryptedKey = (await encryptApiKey(plainKey)).encrypted const nextState = { ...approvedState, keyId, - apiKeyHash: hashValue(createdKey.key), + apiKeyHash: hashValue(plainKey), + apiKeyEncrypted: encryptedKey, } satisfies IssuedDeviceLogin return (await updateDeviceLoginState(login, nextState)) - ? { status: 'approved', apiKey: createdKey.key, expiresAt: login.expiresAt.toISOString() } + ? { status: 'approved', apiKey: plainKey, expiresAt: login.expiresAt.toISOString() } : null } @@ -182,7 +203,6 @@ async function confirmMcpDeviceLoginKey( } const now = new Date() - const encryptedKey = (await encryptApiKey(plainKey)).encrypted const confirmed = await db.transaction(async (tx) => { const [deleted] = await tx .delete(verification) @@ -200,7 +220,7 @@ async function confirmMcpDeviceLoginKey( userId: issuedState.userId, workspaceId: null, name: `TradingGoose MCP ${now.toISOString()}`, - key: encryptedKey, + key: issuedState.apiKeyEncrypted, type: 'personal', createdAt: now, updatedAt: now, @@ -320,7 +340,11 @@ export async function pollMcpDeviceLogin( } if (isIssuedDeviceLogin(approvedState)) { - return { status: 'invalid' } + return { + status: 'approved', + apiKey: (await decryptApiKey(approvedState.apiKeyEncrypted)).decrypted, + expiresAt: login.expiresAt.toISOString(), + } } const issued = await issueMcpDeviceLoginKey(login, approvedState) @@ -410,3 +434,30 @@ export async function cancelMcpDeviceLogin({ return { status: 'cancelled' } } + +export async function authenticateMcpApiKey(token: string) { + const keyId = readMcpApiKeyId(token) + if (!keyId) { + return { success: false as const } + } + + const [storedKey] = await db + .select({ + id: apiKey.id, + userId: apiKey.userId, + key: apiKey.key, + expiresAt: apiKey.expiresAt, + }) + .from(apiKey) + .where(and(eq(apiKey.id, keyId), eq(apiKey.type, 'personal'))) + .limit(1) + + if (!storedKey || (storedKey.expiresAt && storedKey.expiresAt < new Date())) { + return { success: false as const } + } + + const success = storedKey.key === token || (await authenticateApiKey(token, storedKey.key)) + return success + ? { success: true as const, userId: storedKey.userId, keyId: storedKey.id } + : { success: false as const } +} From 0835d960928bbd139146a41e4c062c975879c25f Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 20:48:16 -0600 Subject: [PATCH 040/284] fix(copilot): apply reviewed tool changes atomically Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../copilot/tools/server/review-acceptance.ts | 56 ++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts index 1d5928fe2..5922a42b5 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts @@ -53,7 +53,7 @@ export async function stageServerManagedToolReview( } } -async function consumeServerManagedToolReview( +export async function acceptServerManagedToolReview( toolName: ToolId, reviewToken: string, context?: ServerToolExecutionContext @@ -62,38 +62,44 @@ async function consumeServerManagedToolReview( throw new Error('Authenticated user is required to accept server tool review') } - const [row] = await db - .delete(verification) - .where(eq(verification.identifier, `${REVIEW_TOKEN_PREFIX}${reviewToken}`)) - .returning({ - value: verification.value, - expiresAt: verification.expiresAt, - }) + return db.transaction(async (tx) => { + const [row] = await tx + .select({ + id: verification.id, + value: verification.value, + expiresAt: verification.expiresAt, + }) + .from(verification) + .where(eq(verification.identifier, `${REVIEW_TOKEN_PREFIX}${reviewToken}`)) + .for('update') + .limit(1) - if (!row || row.expiresAt <= new Date()) { - throw new Error('Server tool review token is invalid or expired') - } + if (!row || row.expiresAt <= new Date()) { + throw new Error('Server tool review token is invalid or expired') + } - let staged: { userId?: unknown; toolName?: unknown; result?: unknown } - try { - staged = JSON.parse(row.value) as { userId?: unknown; toolName?: unknown; result?: unknown } - } catch { - throw new Error('Server tool review token is invalid or expired') - } - if (!staged || staged.userId !== context.userId || staged.toolName !== toolName) { - throw new Error('Server tool review token does not match this request') - } + let staged: { userId?: unknown; toolName?: unknown; result?: unknown } + try { + staged = JSON.parse(row.value) as { userId?: unknown; toolName?: unknown; result?: unknown } + } catch { + throw new Error('Server tool review token is invalid or expired') + } + if (!staged || staged.userId !== context.userId || staged.toolName !== toolName) { + throw new Error('Server tool review token does not match this request') + } - return staged.result + const parsedResult = ToolResultSchemas[toolName].parse(staged.result) + const result = await applyServerManagedToolReview(toolName, parsedResult, context) + await tx.delete(verification).where(eq(verification.id, row.id)) + return result + }) } -export async function acceptServerManagedToolReview( +function applyServerManagedToolReview( toolName: ToolId, - reviewToken: string, + parsedResult: unknown, context?: ServerToolExecutionContext ) { - const reviewResult = await consumeServerManagedToolReview(toolName, reviewToken, context) - const parsedResult = ToolResultSchemas[toolName].parse(reviewResult) switch (toolName) { case 'edit_workflow': case 'edit_workflow_block': From 2b94ebe0d05e7393560d8ea95445ae07ff250fdb Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 21:02:33 -0600 Subject: [PATCH 041/284] fix(copilot): prevent stale server tool review acceptance Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../execute-copilot-server-tool/route.ts | 2 +- apps/tradinggoose/lib/copilot/registry.ts | 1 + .../tools/server/entities/custom-tool.ts | 15 -- .../copilot/tools/server/entities/index.ts | 5 - .../tools/server/entities/indicator.ts | 15 -- .../tools/server/entities/mcp-server.ts | 16 -- .../copilot/tools/server/entities/shared.ts | 52 ------ .../copilot/tools/server/entities/skill.ts | 15 -- .../server/entities/workflow-variable.test.ts | 27 --- .../copilot/tools/server/entities/workflow.ts | 71 +------- .../tools/server/knowledge/knowledge-base.ts | 15 -- .../copilot/tools/server/review-acceptance.ts | 171 ++++++++++-------- .../workflow/workflow-mutation-utils.ts | 13 +- 13 files changed, 115 insertions(+), 303 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts b/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts index fadb53a90..c3c67cbb8 100644 --- a/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts +++ b/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts @@ -120,7 +120,7 @@ export async function POST(req: NextRequest) { const result = await (reviewAction === 'accept' ? acceptServerManagedToolReview(toolId, reviewToken!, executionContext) : routeExecution(toolId, payload, executionContext).then((toolResult) => - stageServerManagedToolReview(toolId, toolResult, executionContext) + stageServerManagedToolReview(toolId, payload, toolResult, executionContext) )) try { diff --git a/apps/tradinggoose/lib/copilot/registry.ts b/apps/tradinggoose/lib/copilot/registry.ts index c45d96153..5687251b0 100644 --- a/apps/tradinggoose/lib/copilot/registry.ts +++ b/apps/tradinggoose/lib/copilot/registry.ts @@ -886,6 +886,7 @@ const WorkflowPreviewEdge = z.object({ const WorkflowMutationResultShape = { requiresReview: z.literal(true).optional(), workflowState: z.unknown().optional(), + reviewBaseStateHash: z.string().optional(), preview: z .object({ blockDiff: z.object({ diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts index 2e43526da..136d25e42 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts @@ -5,7 +5,6 @@ import { listCustomTools, upsertCustomTools } from '@/lib/custom-tools/operation import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { - acceptEntityDocumentReview, buildDocumentEnvelope, type EntityCreateResult, type EntityListEntry, @@ -156,17 +155,3 @@ export const renameCustomToolServerTool: EntityServerTool = { ) }, } - -export function acceptCustomToolDocumentReview( - toolName: string, - result: unknown, - context: Parameters[0]['context'] -) { - return acceptEntityDocumentReview({ - kind: ENTITY_KIND_CUSTOM_TOOL, - toolName, - result, - context, - create: createCustomToolEntity, - }) -} diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/index.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/index.ts index a49579359..235ffbcf8 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/index.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/index.ts @@ -1,5 +1,4 @@ export { - acceptCustomToolDocumentReview, createCustomToolServerTool, editCustomToolServerTool, listCustomToolsServerTool, @@ -7,7 +6,6 @@ export { renameCustomToolServerTool, } from './custom-tool' export { - acceptIndicatorDocumentReview, createIndicatorServerTool, editIndicatorServerTool, listIndicatorsServerTool, @@ -15,7 +13,6 @@ export { renameIndicatorServerTool, } from './indicator' export { - acceptMcpServerDocumentReview, createMcpServerServerTool, editMcpServerServerTool, listMcpServersServerTool, @@ -23,7 +20,6 @@ export { renameMcpServerServerTool, } from './mcp-server' export { - acceptSkillDocumentReview, createSkillServerTool, editSkillServerTool, listSkillsServerTool, @@ -31,7 +27,6 @@ export { renameSkillServerTool, } from './skill' export { - acceptWorkflowDocumentReview, createWorkflowServerTool, editWorkflowVariableServerTool, editWorkflowBlockServerTool, diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts index c33c1e4c6..6a391f660 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts @@ -11,7 +11,6 @@ import { import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { - acceptEntityDocumentReview, buildDocumentEnvelope, type CopilotIndicatorListEntry, type EntityCreateResult, @@ -182,17 +181,3 @@ export const renameIndicatorServerTool: EntityServerTool = { ) }, } - -export function acceptIndicatorDocumentReview( - toolName: string, - result: unknown, - context: Parameters[0]['context'] -) { - return acceptEntityDocumentReview({ - kind: ENTITY_KIND_INDICATOR, - toolName, - result, - context, - create: createIndicatorEntity, - }) -} diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts index 614f5827a..5fb1ed07d 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -8,7 +8,6 @@ import type { McpTransport } from '@/lib/mcp/types' import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { - acceptEntityDocumentReview, applySavedEntityDocument, buildDocumentEnvelope, type EntityCreateResult, @@ -220,18 +219,3 @@ export const renameMcpServerServerTool: EntityServerTool = { ) }, } - -export function acceptMcpServerDocumentReview( - toolName: string, - result: unknown, - context: Parameters[0]['context'] -) { - return acceptEntityDocumentReview({ - kind: ENTITY_KIND_MCP_SERVER, - toolName, - result, - context, - create: createMcpServerEntity, - apply: applyMcpServerDocument, - }) -} diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index 8f6541ddc..9f2237cd2 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -285,56 +285,4 @@ export async function executeUpdateEntityDocumentMutation( } } -export async function acceptEntityDocumentReview(input: { - kind: SavedEntityDocumentKind - toolName: string - result: unknown - context: ServerToolExecutionContext | undefined - create: CreateEntityFromDocument - apply?: ApplyEntityDocument -}) { - const { kind, toolName, result, context, create, apply } = input - if (!result || typeof result !== 'object') { - throw new Error(`Missing review result for ${toolName}`) - } - - const reviewResult = result as EntityDocumentArgs & { - entityKind?: string - entityName?: string - preview?: unknown - success?: boolean - } - - if (reviewResult.entityKind !== kind) { - throw new Error(`Review result entityKind must be ${kind}`) - } - - const fields = parseEntityMutationDocument(kind, reviewResult) - - if (toolName.startsWith('create_')) { - const created = await create(fields, withWorkspaceArgContext(context, reviewResult)) - return { - ...reviewResult, - requiresReview: true, - success: true, - ...buildDocumentEnvelope(kind, created.entityId, created.fields), - } - } - - const entityId = requireEntityId(reviewResult, toolName) - const { workspaceId } = await verifySavedEntityContext(context, kind, entityId, 'write') - if (apply) { - await apply({ entityId, fields, workspaceId }) - } else { - await applySavedEntityDocument(kind, entityId, fields) - } - - return { - ...reviewResult, - requiresReview: true, - success: true, - ...buildDocumentEnvelope(kind, entityId, fields), - } -} - export type EntityServerTool = BaseServerTool diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts index 57fafcfb7..335a88afd 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts @@ -4,7 +4,6 @@ import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' import { listSkills, upsertSkills } from '@/lib/skills/operations' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { - acceptEntityDocumentReview, buildDocumentEnvelope, type EntityCreateResult, type EntityListEntry, @@ -108,17 +107,3 @@ export const renameSkillServerTool: EntityServerTool = { return executeUpdateEntityDocumentMutation(ENTITY_KIND_SKILL, 'rename_skill', args, context) }, } - -export function acceptSkillDocumentReview( - toolName: string, - result: unknown, - context: Parameters[0]['context'] -) { - return acceptEntityDocumentReview({ - kind: ENTITY_KIND_SKILL, - toolName, - result, - context, - create: createSkillEntity, - }) -} diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts index 0da7ca1bb..46c2a2577 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import * as Y from 'yjs' import { WORKFLOW_VARIABLE_DOCUMENT_FORMAT } from '@/lib/copilot/entity-documents' import { - acceptWorkflowDocumentReview, editWorkflowVariableServerTool, readWorkflowServerTool, } from '@/lib/copilot/tools/server/entities/workflow' @@ -175,30 +174,4 @@ describe('workflow variable server tools', () => { ) }) - it('applies accepted workflow variable reviews through workflow state persistence', async () => { - const result = await editWorkflowVariableServerTool.execute( - { - entityId: 'wf-1', - documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, - entityDocument: JSON.stringify({ - variables: [{ variableId: 'var-1', name: 'riskLimit', type: 'number', value: 25 }], - }), - }, - { userId: 'user-1', accessLevel: 'limited' } - ) - - await acceptWorkflowDocumentReview('edit_workflow_variable', result, { - userId: 'user-1', - accessLevel: 'limited', - }) - - expect(mockApplyWorkflowState).toHaveBeenCalledWith( - 'wf-1', - expect.objectContaining({ - blocks: {}, - edges: [], - }), - result.variables - ) - }) }) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index 64283f936..4fefe3097 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -20,10 +20,7 @@ import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit import { editWorkflowBlockServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow-block' import { generateCreativeWorkflowName } from '@/lib/naming' import { VariableManager } from '@/lib/variables/variable-manager' -import { - TG_MERMAID_DOCUMENT_FORMAT, - WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT, -} from '@/lib/workflows/document-format' +import { TG_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' import { readWorkflowContainerBoundaryEdgeViolation, readWorkflowEdgeScope, @@ -516,69 +513,3 @@ export const renameWorkflowServerTool: BaseServerTool<{ entityId: string; name: } export { editWorkflowServerTool, editWorkflowBlockServerTool } - -export async function acceptWorkflowDocumentReview( - toolName: string, - result: unknown, - context: ServerToolExecutionContext | undefined -) { - if ( - toolName !== 'edit_workflow' && - toolName !== 'edit_workflow_block' && - toolName !== 'edit_workflow_variable' - ) { - throw new Error(`Unsupported workflow review tool: ${toolName}`) - } - if (!result || typeof result !== 'object') { - throw new Error(`Missing review result for ${toolName}`) - } - - const reviewResult = result as { - entityKind?: string - entityId?: string - workflowState?: unknown - variables?: unknown - entityDocument?: string - documentFormat?: string - } - if (reviewResult.entityKind !== ENTITY_KIND_WORKFLOW) { - throw new Error('Review result entityKind must be workflow') - } - const workflowId = reviewResult.entityId?.trim() - if (!workflowId) { - throw new Error(`entityId is required for ${toolName}`) - } - if (toolName === 'edit_workflow_variable') { - if (!reviewResult.variables || typeof reviewResult.variables !== 'object') { - throw new Error(`variables are required for ${toolName} review acceptance`) - } - const { workflowState } = await loadWorkflowSnapshotForCopilot(workflowId, context, 'write') - await applyWorkflowState(workflowId, workflowState, reviewResult.variables as Record) - - return { - ...reviewResult, - requiresReview: true, - success: true, - } - } - if (!reviewResult.workflowState || typeof reviewResult.workflowState !== 'object') { - throw new Error(`workflowState is required for ${toolName} review acceptance`) - } - - await verifyWorkflowContext(workflowId, context, 'write') - await applyWorkflowState( - workflowId, - createWorkflowSnapshot(reviewResult.workflowState as Partial) - ) - - return { - ...reviewResult, - requiresReview: true, - success: true, - documentFormat: - reviewResult.documentFormat ?? - (toolName === 'edit_workflow' - ? WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT - : TG_MERMAID_DOCUMENT_FORMAT), - } -} diff --git a/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts index 6ecbb59e9..e6b18082b 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -15,7 +15,6 @@ import { createLogger } from '@/lib/logs/console/logger' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { getQueryStrategy, handleVectorOnlySearch } from '@/app/api/knowledge/search/utils' import { - acceptEntityDocumentReview, buildDocumentEnvelope, type EntityCreateResult, type EntityDocumentArgs, @@ -214,17 +213,3 @@ export const queryKnowledgeBaseServerTool: BaseServerTool<{ } }, } - -export function acceptKnowledgeBaseDocumentReview( - toolName: string, - result: unknown, - context: Parameters[0]['context'] -) { - return acceptEntityDocumentReview({ - kind: ENTITY_KIND_KNOWLEDGE_BASE, - toolName, - result, - context, - create: createKnowledgeBaseEntity, - }) -} diff --git a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts index 5922a42b5..c02c20ae4 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts @@ -1,23 +1,55 @@ +import { createHash } from 'crypto' import { db } from '@tradinggoose/db' import { verification } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' import { nanoid } from 'nanoid' -import { type ToolId, ToolResultSchemas } from '@/lib/copilot/registry' +import { type ToolId } from '@/lib/copilot/registry' +import { StructuredServerToolError } from '@/lib/copilot/server-tool-errors' import type { ServerToolExecutionContext } from '@/lib/copilot/tools/server/base-tool' -import { - acceptCustomToolDocumentReview, - acceptIndicatorDocumentReview, - acceptMcpServerDocumentReview, - acceptSkillDocumentReview, - acceptWorkflowDocumentReview, -} from '@/lib/copilot/tools/server/entities' -import { acceptKnowledgeBaseDocumentReview } from '@/lib/copilot/tools/server/knowledge/knowledge-base' +import { routeExecution } from '@/lib/copilot/tools/server/router' const REVIEW_TOKEN_PREFIX = 'copilot-tool-review:' const REVIEW_TOKEN_TTL_MS = 30 * 60 * 1000 +function hashValue(value: string) { + return createHash('sha256').update(value).digest('hex') +} + +function readBaseSignature(result: unknown): string { + if (!result || typeof result !== 'object' || Array.isArray(result)) { + throw new Error('Server tool review result is missing base state') + } + + const record = result as { + preview?: { documentDiff?: { before?: unknown } } + reviewBaseStateHash?: unknown + } + if (typeof record.reviewBaseStateHash === 'string' && record.reviewBaseStateHash) { + return `state:${record.reviewBaseStateHash}` + } + const before = record.preview?.documentDiff?.before + if (typeof before === 'string') { + return `document:${hashValue(before)}` + } + + throw new Error('Server tool review result is missing base state') +} + +function stripReviewMetadata(result: unknown) { + if (!result || typeof result !== 'object' || Array.isArray(result)) { + return result + } + + const { reviewBaseStateHash: _reviewBaseStateHash, ...publicResult } = result as Record< + string, + unknown + > + return publicResult +} + export async function stageServerManagedToolReview( toolName: ToolId, + payload: unknown, result: unknown, context?: ServerToolExecutionContext ) { @@ -34,13 +66,15 @@ export async function stageServerManagedToolReview( const reviewToken = nanoid() const now = new Date() + const publicResult = stripReviewMetadata(result) await db.insert(verification).values({ id: nanoid(), identifier: `${REVIEW_TOKEN_PREFIX}${reviewToken}`, value: JSON.stringify({ userId: context.userId, toolName, - result, + payload, + baseSignature: readBaseSignature(result), }), expiresAt: new Date(now.getTime() + REVIEW_TOKEN_TTL_MS), createdAt: now, @@ -48,7 +82,7 @@ export async function stageServerManagedToolReview( }) return { - ...result, + ...(publicResult as Record), reviewToken, } } @@ -62,70 +96,65 @@ export async function acceptServerManagedToolReview( throw new Error('Authenticated user is required to accept server tool review') } - return db.transaction(async (tx) => { - const [row] = await tx - .select({ - id: verification.id, - value: verification.value, - expiresAt: verification.expiresAt, - }) - .from(verification) - .where(eq(verification.identifier, `${REVIEW_TOKEN_PREFIX}${reviewToken}`)) - .for('update') - .limit(1) - - if (!row || row.expiresAt <= new Date()) { - throw new Error('Server tool review token is invalid or expired') - } + const [row] = await db + .select({ + value: verification.value, + expiresAt: verification.expiresAt, + }) + .from(verification) + .where(eq(verification.identifier, `${REVIEW_TOKEN_PREFIX}${reviewToken}`)) + .limit(1) - let staged: { userId?: unknown; toolName?: unknown; result?: unknown } - try { - staged = JSON.parse(row.value) as { userId?: unknown; toolName?: unknown; result?: unknown } - } catch { - throw new Error('Server tool review token is invalid or expired') - } - if (!staged || staged.userId !== context.userId || staged.toolName !== toolName) { - throw new Error('Server tool review token does not match this request') + if (!row || row.expiresAt <= new Date()) { + throw new Error('Server tool review token is invalid or expired') + } + + let staged: { userId?: unknown; toolName?: unknown; payload?: unknown; baseSignature?: unknown } + try { + staged = JSON.parse(row.value) as { + userId?: unknown + toolName?: unknown + payload?: unknown + baseSignature?: unknown } + } catch { + throw new Error('Server tool review token is invalid or expired') + } + if ( + !staged || + staged.userId !== context.userId || + staged.toolName !== toolName || + typeof staged.baseSignature !== 'string' + ) { + throw new Error('Server tool review token does not match this request') + } - const parsedResult = ToolResultSchemas[toolName].parse(staged.result) - const result = await applyServerManagedToolReview(toolName, parsedResult, context) - await tx.delete(verification).where(eq(verification.id, row.id)) - return result + const currentReview = await routeExecution(toolName, staged.payload, { + ...context, + accessLevel: 'limited', }) -} + if (readBaseSignature(currentReview) !== staged.baseSignature) { + throw new StructuredServerToolError({ + status: 409, + body: { + code: 'stale_server_tool_review', + error: 'This reviewed Copilot edit is stale because the target changed after review.', + hint: 'Ask Copilot to read the current target and prepare the edit again.', + retryable: true, + }, + }) + } -function applyServerManagedToolReview( - toolName: ToolId, - parsedResult: unknown, - context?: ServerToolExecutionContext -) { - switch (toolName) { - case 'edit_workflow': - case 'edit_workflow_block': - case 'edit_workflow_variable': - return acceptWorkflowDocumentReview(toolName, parsedResult, context) - case 'create_skill': - case 'edit_skill': - case 'rename_skill': - return acceptSkillDocumentReview(toolName, parsedResult, context) - case 'create_custom_tool': - case 'edit_custom_tool': - case 'rename_custom_tool': - return acceptCustomToolDocumentReview(toolName, parsedResult, context) - case 'create_indicator': - case 'edit_indicator': - case 'rename_indicator': - return acceptIndicatorDocumentReview(toolName, parsedResult, context) - case 'create_knowledge_base': - case 'edit_knowledge_base': - case 'rename_knowledge_base': - return acceptKnowledgeBaseDocumentReview(toolName, parsedResult, context) - case 'create_mcp_server': - case 'edit_mcp_server': - case 'rename_mcp_server': - return acceptMcpServerDocumentReview(toolName, parsedResult, context) - default: - throw new Error(`Server tool ${toolName} does not support review acceptance`) + const [deleted] = await db + .delete(verification) + .where(eq(verification.identifier, `${REVIEW_TOKEN_PREFIX}${reviewToken}`)) + .returning({ id: verification.id }) + if (!deleted) { + throw new Error('Server tool review token is invalid or expired') } + + return routeExecution(toolName, staged.payload, { + ...context, + accessLevel: 'full', + }) } diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts index a30e27602..2cf26d55d 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto' import * as Y from 'yjs' import { type ServerToolExecutionContext, @@ -96,6 +97,10 @@ function buildWorkflowDocumentPreviewDiff( } } +function hashWorkflowState(workflowState: WorkflowSnapshot) { + return createHash('sha256').update(stableStringifyJsonValue(workflowState)).digest('hex') +} + export async function loadBaseWorkflowState( workflowId: string, context?: ServerToolExecutionContext @@ -171,6 +176,7 @@ export function buildWorkflowMutationResult(params: { entityDocument, documentFormat: params.documentFormat, workflowState: finalWorkflowState, + reviewBaseStateHash: hashWorkflowState(baseWorkflowState), preview: { ...preview, warnings, @@ -195,6 +201,11 @@ export async function resolveWorkflowMutationResultForExecution( createWorkflowSnapshot(result.workflowState as Partial) ) - const { requiresReview: _requiresReview, preview: _preview, ...appliedResult } = result + const { + requiresReview: _requiresReview, + preview: _preview, + reviewBaseStateHash: _reviewBaseStateHash, + ...appliedResult + } = result return appliedResult } From 164ee90eee24c7899b757fd94f39761c7fb79356 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 21:03:00 -0600 Subject: [PATCH 042/284] fix(copilot): handle malformed MCP batch entries Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/copilot/mcp/route.test.ts | 20 +++++++++++++++++++ .../tradinggoose/app/api/copilot/mcp/route.ts | 11 +++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index 37c845bb6..01c7fad7e 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -166,4 +166,24 @@ describe('Copilot MCP route', () => { expect(body.result.structuredContent).toEqual({ workflows: [] }) expect(body.result.content[0].text).toBe(JSON.stringify({ workflows: [] }, null, 2)) }) + + it('returns per-entry invalid request errors for malformed batches', async () => { + const { POST } = await import('./route') + + const response = await POST(createMcpRequest([null])) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual([ + { + jsonrpc: '2.0', + id: null, + error: { + code: -32600, + message: 'Invalid JSON-RPC request', + }, + }, + ]) + expect(mockRouteExecution).not.toHaveBeenCalled() + }) }) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index e64277653..d153f10a6 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -137,7 +137,16 @@ function getToolCallParams(params: unknown) { } } -async function handleJsonRpcRequest(request: JsonRpcRequest, auth: AuthenticatedMcpUser) { +function isJsonRpcRequest(value: unknown): value is JsonRpcRequest { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +async function handleJsonRpcRequest(entry: unknown, auth: AuthenticatedMcpUser) { + if (!isJsonRpcRequest(entry)) { + return jsonRpcError(null, -32600, 'Invalid JSON-RPC request') + } + + const request = entry const id = request.id ?? null if (typeof request.method !== 'string') { return jsonRpcError(id, -32600, 'Invalid JSON-RPC request') From 4bb55c8f9bff8939620e463228a86f864039e720 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 21:27:36 -0600 Subject: [PATCH 043/284] feat(copilot): stage mutation tools for review Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/copilot/registry.ts | 24 ++++++++++--- .../copilot/tools/server/entities/workflow.ts | 27 ++++++++++++-- .../tools/server/monitor/edit-monitor.ts | 36 +++++++++++++++++-- .../server/user/set-environment-variables.ts | 28 ++++++++++++++- 4 files changed, 106 insertions(+), 9 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/registry.ts b/apps/tradinggoose/lib/copilot/registry.ts index 5687251b0..365c48848 100644 --- a/apps/tradinggoose/lib/copilot/registry.ts +++ b/apps/tradinggoose/lib/copilot/registry.ts @@ -829,9 +829,9 @@ const McpServerDocumentEnvelope = EntityDocumentEnvelopeBase.extend({ documentFormat: z.literal(MCP_SERVER_DOCUMENT_FORMAT), }) -const EditEntityDocumentResultBase = z.object({ +const DocumentDiffReviewMetadata = z.object({ requiresReview: z.literal(true).optional(), - success: z.boolean(), + reviewBaseStateHash: z.string().optional(), preview: z .object({ documentDiff: z.object({ @@ -842,7 +842,11 @@ const EditEntityDocumentResultBase = z.object({ .optional(), }) -const WorkflowMutationResult = WorkflowTargetEnvelope.extend({ +const EditEntityDocumentResultBase = DocumentDiffReviewMetadata.extend({ + success: z.boolean(), +}) + +const WorkflowMutationResult = WorkflowTargetEnvelope.merge(DocumentDiffReviewMetadata).extend({ success: z.boolean(), }) @@ -924,6 +928,17 @@ const EditWorkflowVariableResult = WorkflowVariableDocumentEnvelope.extend({ .optional(), }) +const EnvironmentVariablesMutationResult = DocumentDiffReviewMetadata.extend({ + success: z.boolean().optional(), + message: z.any().optional(), + data: z.any().optional(), + variableCount: z.number().optional(), + variableNames: z.array(z.string()).optional(), + totalVariableCount: z.number().optional(), + addedVariables: z.array(z.string()).optional(), + updatedVariables: z.array(z.string()).optional(), +}) + const ExecutionEntry = z.object({ id: z.string(), executionId: z.string(), @@ -1003,7 +1018,7 @@ export const ToolResultSchemas = { ]), set_environment_variables: z .object({ variables: z.record(z.string()) }) - .or(z.object({ message: z.any().optional(), data: z.any().optional() })), + .or(EnvironmentVariablesMutationResult), [CopilotTool.read_oauth_credentials]: z.object({ credentials: z.array( z.object({ id: z.string(), provider: z.string(), isDefault: z.boolean().optional() }) @@ -1117,6 +1132,7 @@ export const ToolResultSchemas = { .object({ success: z.boolean(), }) + .merge(DocumentDiffReviewMetadata) .merge(MonitorDocumentEnvelope), [CopilotTool.list_indicators]: IndicatorListResult, [CopilotTool.read_indicator]: IndicatorDocumentEnvelope.extend({ diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index 4fefe3097..87951195c 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -18,7 +18,6 @@ import { } from '@/lib/copilot/tools/server/base-tool' import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow' import { editWorkflowBlockServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow-block' -import { generateCreativeWorkflowName } from '@/lib/naming' import { VariableManager } from '@/lib/variables/variable-manager' import { TG_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' import { @@ -444,11 +443,23 @@ export const createWorkflowServerTool: BaseServerTool< ) const workflowId = crypto.randomUUID() const now = new Date() - const name = args.name?.trim() || generateCreativeWorkflowName() + const name = args.name?.trim() || 'New workflow' const description = typeof args.description === 'string' ? args.description : 'New workflow' const color = getStableVibrantColor(workflowId) const workflowState = createWorkflowSnapshot() + if (shouldStageServerToolMutationForReview(context)) { + return { + requiresReview: true, + success: true, + entityKind: ENTITY_KIND_WORKFLOW, + entityId: workflowId, + entityName: name, + workspaceId, + reviewBaseStateHash: workspaceId, + } + } + await db.insert(workflow).values({ id: workflowId, userId, @@ -495,6 +506,18 @@ export const renameWorkflowServerTool: BaseServerTool<{ entityId: string; name: } const current = await loadWorkflowSnapshotForCopilot(workflowId, context, 'write') + if (shouldStageServerToolMutationForReview(context)) { + return { + requiresReview: true, + success: true, + entityKind: ENTITY_KIND_WORKFLOW, + entityId: workflowId, + entityName: nextName, + workspaceId: current.workspaceId ?? undefined, + reviewBaseStateHash: `${workflowId}:${current.entityName ?? ''}`, + } + } + const updatedWorkflow = await applyWorkflowEntityName( workflowId, current.workflowState, diff --git a/apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts b/apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts index e15fd68f9..0719b8859 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts @@ -1,15 +1,21 @@ -import { getMonitorRowById } from '@/app/api/monitors/shared' +import { getMonitorRowById, toMonitorRecord } from '@/app/api/monitors/shared' import { updateMonitorForUser } from '@/app/api/monitors/update-service' import { MONITOR_DOCUMENT_FORMAT, parseMonitorDocument, + readMonitorDocumentName, + serializeMonitorDocument, } from '@/lib/copilot/monitor/monitor-documents' import { buildMonitorDocumentEnvelope, type MonitorRecord, } from '@/lib/copilot/tools/server/monitor/shared' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { + type BaseServerTool, + shouldStageServerToolMutationForReview, +} from '@/lib/copilot/tools/server/base-tool' import { createLogger } from '@/lib/logs/console/logger' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' const logger = createLogger('EditMonitorServerTool') @@ -41,6 +47,32 @@ export const editMonitorServerTool: BaseServerTool = { } const nextFields = parseMonitorDocument(args.monitorDocument) + if (shouldStageServerToolMutationForReview(context)) { + const access = await checkWorkspaceAccess(row.workflow.workspaceId, userId) + if (!access.exists || !access.hasAccess || !access.canWrite) { + throw new Error('Access denied: You do not have permission to edit this monitor') + } + + const currentMonitor = (await toMonitorRecord(row.webhook)) as MonitorRecord + const currentDocument = buildMonitorDocumentEnvelope(currentMonitor).monitorDocument + const nextDocument = serializeMonitorDocument(nextFields) + return { + requiresReview: true, + success: true, + surfaceKind: 'monitor' as const, + monitorId: args.monitorId, + monitorName: readMonitorDocumentName(nextFields), + documentFormat: MONITOR_DOCUMENT_FORMAT, + monitorDocument: nextDocument, + preview: { + documentDiff: { + before: currentDocument, + after: nextDocument, + }, + }, + } + } + const updatedMonitor = (await updateMonitorForUser({ monitorId: args.monitorId, userId, diff --git a/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts b/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts index 057b2f293..30956fa25 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto' import { db } from '@tradinggoose/db' import { environmentVariables } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' @@ -5,6 +6,7 @@ import { z } from 'zod' import { type BaseServerTool, type ServerToolExecutionContext, + shouldStageServerToolMutationForReview, throwIfServerToolAborted, } from '@/lib/copilot/tools/server/base-tool' import { createLogger } from '@/lib/logs/console/logger' @@ -16,6 +18,12 @@ interface SetEnvironmentVariablesParams { const EnvVarSchema = z.object({ variables: z.record(z.string()) }) +function hashEnvironmentVariableBase(entries: Array<[string, string | null]>): string { + return createHash('sha256') + .update(JSON.stringify(entries.sort(([left], [right]) => left.localeCompare(right)))) + .digest('hex') +} + function normalizeEnvVarInput( input: Record | Array<{ name: string; value: string }> ): Record { @@ -60,14 +68,32 @@ export const setEnvironmentVariablesServerTool: BaseServerTool row.key)) + const existingValueByKey = new Map(existingRows.map((row) => [row.key, row.value])) const added = variableEntries.filter(([key]) => !existingKeySet.has(key)).map(([key]) => key) const updated = variableEntries.filter(([key]) => existingKeySet.has(key)).map(([key]) => key) + if (shouldStageServerToolMutationForReview(context)) { + const variableNames = Object.keys(validatedVariables) + return { + requiresReview: true, + success: true, + message: `Review required for ${variableNames.length} environment variable(s): ${added.length} added, ${updated.length} updated`, + variableCount: variableNames.length, + variableNames, + totalVariableCount: existingRows.length + added.length, + addedVariables: added, + updatedVariables: updated, + reviewBaseStateHash: hashEnvironmentVariableBase( + variableEntries.map(([key]) => [key, existingValueByKey.get(key) ?? null]) + ), + } + } + await db.transaction(async (tx) => { for (const [key, val] of variableEntries) { throwIfServerToolAborted(context) From f34626b550ec302202a1afd96feee3174702598f Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 20 Jun 2026 21:45:38 -0600 Subject: [PATCH 044/284] fix(copilot): prevent sensitive tool payload exposure Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../execute-copilot-server-tool/route.ts | 10 - .../api/copilot/tools/mark-complete/route.ts | 20 -- apps/tradinggoose/components/ui/tool-call.tsx | 60 ++---- .../lib/copilot/inline-tool-call.tsx | 33 ++-- .../tools/server/other/make-api-request.ts | 3 +- .../copilot/tools/server/review-acceptance.ts | 69 ++++++- .../lib/copilot/tools/server/router.ts | 11 +- .../server/user/set-environment-variables.ts | 181 +++++++++++------- 8 files changed, 206 insertions(+), 181 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts b/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts index c3c67cbb8..b43d3fbb4 100644 --- a/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts +++ b/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts @@ -50,11 +50,6 @@ export async function POST(req: NextRequest) { } const body = await req.json() - try { - const preview = JSON.stringify(body).slice(0, 300) - logger.debug(`[${tracker.requestId}] Incoming request body preview`, { preview }) - } catch {} - let parsedBody: z.infer try { parsedBody = ExecuteSchema.parse(body) @@ -123,11 +118,6 @@ export async function POST(req: NextRequest) { stageServerManagedToolReview(toolId, payload, toolResult, executionContext) )) - try { - const resultPreview = JSON.stringify(result).slice(0, 300) - logger.debug(`[${tracker.requestId}] Server tool result preview`, { toolName, resultPreview }) - } catch {} - return NextResponse.json({ success: true, result }) } catch (error) { logger.error(`[${tracker.requestId}] Failed to execute server tool:`, error) diff --git a/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.ts b/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.ts index cb7a7741a..6d6271f68 100644 --- a/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.ts +++ b/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.ts @@ -151,27 +151,8 @@ export async function POST(req: NextRequest) { } const body = await req.json() - - // Log raw body shape for diagnostics (avoid dumping huge payloads) - try { - const bodyPreview = JSON.stringify(body).slice(0, 300) - logger.debug(`[${tracker.requestId}] Incoming mark-complete raw body preview`, { - preview: `${bodyPreview}${bodyPreview.length === 300 ? '...' : ''}`, - }) - } catch {} - const parsed = MarkCompleteSchema.parse(body) - const messagePreview = (() => { - try { - const s = - typeof parsed.message === 'string' ? parsed.message : JSON.stringify(parsed.message) - return s ? `${s.slice(0, 200)}${s.length > 200 ? '...' : ''}` : undefined - } catch { - return undefined - } - })() - logger.info(`[${tracker.requestId}] Forwarding tool mark-complete`, { userId, toolCallId: parsed.id, @@ -179,7 +160,6 @@ export async function POST(req: NextRequest) { status: parsed.status, hasMessage: parsed.message !== undefined, hasData: parsed.data !== undefined, - messagePreview, agentUrl: await getCopilotApiUrl('/api/tools/mark-complete'), }) diff --git a/apps/tradinggoose/components/ui/tool-call.tsx b/apps/tradinggoose/components/ui/tool-call.tsx index a1fe59ced..9b3f10133 100644 --- a/apps/tradinggoose/components/ui/tool-call.tsx +++ b/apps/tradinggoose/components/ui/tool-call.tsx @@ -24,6 +24,14 @@ interface ToolCallIndicatorProps { toolNames?: string[] } +const REDACTED_VALUE = '[redacted]' + +function redactUrlQuery(value: unknown): string { + const url = String(value || '') + const queryStart = url.indexOf('?') + return queryStart === -1 ? url : `${url.slice(0, queryStart)}?${REDACTED_VALUE}` +} + // Detection State Component export function ToolCallDetection({ content }: { content: string }) { return ( @@ -98,9 +106,10 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps
- {String((toolCall.parameters as any).url || '') || 'URL not provided'} + {redactUrlQuery((toolCall.parameters as any).url) || + 'URL not provided'}
@@ -111,10 +120,11 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps ? (() => { const variables = (toolCall.parameters as any).variables && - typeof (toolCall.parameters as any).variables === 'object' + typeof (toolCall.parameters as any).variables === 'object' && + !Array.isArray((toolCall.parameters as any).variables) ? (toolCall.parameters as any).variables : {} - const entries = Object.entries(variables) + const names = Object.keys(variables) return (
@@ -125,23 +135,23 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps Value
- {entries.length === 0 ? ( + {names.length === 0 ? (
No variables provided
) : (
- {entries.map(([k, v]) => ( + {names.map((name) => (
- {k} + {name}
- {String(v)} + {REDACTED_VALUE}
@@ -250,38 +260,6 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
- {toolCall.parameters && - Object.keys(toolCall.parameters).length > 0 && - (toolCall.name === 'make_api_request' || - toolCall.name === 'set_environment_variables') && ( -
-
- Parameters: -
-
- {JSON.stringify(toolCall.parameters, null, 2)} -
-
- )} - {toolCall.error && (
diff --git a/apps/tradinggoose/lib/copilot/inline-tool-call.tsx b/apps/tradinggoose/lib/copilot/inline-tool-call.tsx index 193cb5af7..0c16e832a 100644 --- a/apps/tradinggoose/lib/copilot/inline-tool-call.tsx +++ b/apps/tradinggoose/lib/copilot/inline-tool-call.tsx @@ -116,6 +116,14 @@ const ACTION_VERBS = [ 'Resumed', ] as const +const REDACTED_VALUE = '[redacted]' + +function redactUrlQuery(value: unknown): string { + const url = String(value || '') + const queryStart = url.indexOf('?') + return queryStart === -1 ? url : `${url.slice(0, queryStart)}?${REDACTED_VALUE}` +} + function splitActionVerb(text: string): [string | null, string] { for (const verb of ACTION_VERBS) { if (text.startsWith(`${verb} `)) { @@ -427,7 +435,7 @@ export function InlineToolCall({ const renderPendingDetails = () => { if (toolCall.name === 'make_api_request') { - const url = params.url || '' + const url = redactUrlQuery(params.url) const method = (params.method || '').toUpperCase() return (
@@ -460,19 +468,10 @@ export function InlineToolCall({ if (toolCall.name === 'set_environment_variables') { const variables = - params.variables && typeof params.variables === 'object' ? params.variables : {} - - // Normalize variables - handle both direct key-value and nested {name, value} format - const normalizedEntries: Array<[string, string]> = [] - Object.entries(variables).forEach(([key, value]) => { - if (typeof value === 'object' && value !== null && 'name' in value && 'value' in value) { - // Handle {name: "key", value: "val"} format - normalizedEntries.push([String((value as any).name), String((value as any).value)]) - } else { - // Handle direct key-value format - normalizedEntries.push([key, String(value)]) - } - }) + params.variables && typeof params.variables === 'object' && !Array.isArray(params.variables) + ? params.variables + : {} + const variableNames = Object.keys(variables) return (
@@ -484,11 +483,11 @@ export function InlineToolCall({ Value
- {normalizedEntries.length === 0 ? ( + {variableNames.length === 0 ? (
No variables provided
) : (
- {normalizedEntries.map(([name, value]) => ( + {variableNames.map((name) => (
- {value} + {REDACTED_VALUE}
diff --git a/apps/tradinggoose/lib/copilot/tools/server/other/make-api-request.ts b/apps/tradinggoose/lib/copilot/tools/server/other/make-api-request.ts index 3e41c6fd6..ad8c9a57e 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/other/make-api-request.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/other/make-api-request.ts @@ -89,7 +89,6 @@ export const makeApiRequestServerTool: BaseServerTool if (totalChars > CAP) { const preview = normalized.slice(0, CAP) logger.warn('API response truncated by character cap', { - url, method, totalChars, previewChars: preview.length, @@ -105,7 +104,7 @@ export const makeApiRequestServerTool: BaseServerTool note: `Response truncated to ${CAP} characters to avoid large payloads`, } } - logger.info('API request executed', { url, method, status, totalChars }) + logger.info('API request executed', { method, status, totalChars }) return { data: normalized, status, headers: respHeaders } }, } diff --git a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts index c02c20ae4..583351db6 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts @@ -7,6 +7,10 @@ import { type ToolId } from '@/lib/copilot/registry' import { StructuredServerToolError } from '@/lib/copilot/server-tool-errors' import type { ServerToolExecutionContext } from '@/lib/copilot/tools/server/base-tool' import { routeExecution } from '@/lib/copilot/tools/server/router' +import { + applyEncryptedEnvironmentVariablesForUser, + buildEnvironmentVariablesReviewPayload, +} from '@/lib/copilot/tools/server/user/set-environment-variables' const REVIEW_TOKEN_PREFIX = 'copilot-tool-review:' const REVIEW_TOKEN_TTL_MS = 30 * 60 * 1000 @@ -47,6 +51,43 @@ function stripReviewMetadata(result: unknown) { return publicResult } +async function buildStagedReviewValue( + toolName: ToolId, + payload: unknown, + result: unknown, + context: ServerToolExecutionContext & { userId: string } +) { + const staged: Record = { + userId: context.userId, + toolName, + payload, + baseSignature: readBaseSignature(result), + } + + if (toolName === 'set_environment_variables') { + const reviewPayload = await buildEnvironmentVariablesReviewPayload(payload, context) + staged.payload = reviewPayload.payload + staged.encryptedEnvironmentVariables = reviewPayload.encryptedVariables + } + + return staged +} + +function readEncryptedEnvironmentVariables(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Server tool review token is invalid or expired') + } + + return Object.fromEntries( + Object.entries(value as Record).map(([key, encrypted]) => { + if (typeof encrypted !== 'string') { + throw new Error('Server tool review token is invalid or expired') + } + return [key, encrypted] + }) + ) +} + export async function stageServerManagedToolReview( toolName: ToolId, payload: unknown, @@ -67,15 +108,14 @@ export async function stageServerManagedToolReview( const reviewToken = nanoid() const now = new Date() const publicResult = stripReviewMetadata(result) + const stagedValue = await buildStagedReviewValue(toolName, payload, result, { + ...context, + userId: context.userId, + }) await db.insert(verification).values({ id: nanoid(), identifier: `${REVIEW_TOKEN_PREFIX}${reviewToken}`, - value: JSON.stringify({ - userId: context.userId, - toolName, - payload, - baseSignature: readBaseSignature(result), - }), + value: JSON.stringify(stagedValue), expiresAt: new Date(now.getTime() + REVIEW_TOKEN_TTL_MS), createdAt: now, updatedAt: now, @@ -109,13 +149,20 @@ export async function acceptServerManagedToolReview( throw new Error('Server tool review token is invalid or expired') } - let staged: { userId?: unknown; toolName?: unknown; payload?: unknown; baseSignature?: unknown } + let staged: { + userId?: unknown + toolName?: unknown + payload?: unknown + baseSignature?: unknown + encryptedEnvironmentVariables?: unknown + } try { staged = JSON.parse(row.value) as { userId?: unknown toolName?: unknown payload?: unknown baseSignature?: unknown + encryptedEnvironmentVariables?: unknown } } catch { throw new Error('Server tool review token is invalid or expired') @@ -153,6 +200,14 @@ export async function acceptServerManagedToolReview( throw new Error('Server tool review token is invalid or expired') } + if (toolName === 'set_environment_variables') { + return applyEncryptedEnvironmentVariablesForUser( + context.userId, + readEncryptedEnvironmentVariables(staged.encryptedEnvironmentVariables), + context + ) + } + return routeExecution(toolName, staged.payload, { ...context, accessLevel: 'full', diff --git a/apps/tradinggoose/lib/copilot/tools/server/router.ts b/apps/tradinggoose/lib/copilot/tools/server/router.ts index 7a45f20b1..a703e71a6 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/router.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/router.ts @@ -185,16 +185,7 @@ export async function routeExecution( throw new Error(`Unknown server tool: ${toolName}`) } - logger.debug('Routing to tool', { - toolName, - payloadPreview: (() => { - try { - return JSON.stringify(payload).slice(0, 200) - } catch { - return undefined - } - })(), - }) + logger.debug('Routing to tool', { toolName }) let args: any try { diff --git a/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts b/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts index 30956fa25..126c0e03f 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts @@ -9,11 +9,10 @@ import { shouldStageServerToolMutationForReview, throwIfServerToolAborted, } from '@/lib/copilot/tools/server/base-tool' -import { createLogger } from '@/lib/logs/console/logger' import { encryptSecret } from '@/lib/utils-server' interface SetEnvironmentVariablesParams { - variables: Record | Array<{ name: string; value: string }> + variables: Record } const EnvVarSchema = z.object({ variables: z.record(z.string()) }) @@ -24,25 +23,109 @@ function hashEnvironmentVariableBase(entries: Array<[string, string | null]>): s .digest('hex') } -function normalizeEnvVarInput( - input: Record | Array<{ name: string; value: string }> -): Record { - if (Array.isArray(input)) { - return input.reduce( - (acc, item) => { - if (item && typeof item.name === 'string') { - acc[item.name] = String(item.value ?? '') - } - return acc - }, - {} as Record - ) - } +function normalizeEnvVarInput(input: Record | undefined): Record { return Object.fromEntries( Object.entries(input || {}).map(([k, v]) => [k, String(v ?? '')]) ) as Record } +function parseEnvironmentVariablesPayload(payload: unknown): Record { + const variables = + payload && typeof payload === 'object' && !Array.isArray(payload) + ? (payload as SetEnvironmentVariablesParams).variables + : undefined + return EnvVarSchema.parse({ variables: normalizeEnvVarInput(variables) }).variables +} + +async function readEnvironmentVariableSummary(userId: string, variableNames: string[]) { + const existingRows = await db + .select({ key: environmentVariables.key, value: environmentVariables.value }) + .from(environmentVariables) + .where(eq(environmentVariables.userId, userId)) + const existingKeySet = new Set(existingRows.map((row) => row.key)) + const existingValueByKey = new Map(existingRows.map((row) => [row.key, row.value])) + const added = variableNames.filter((key) => !existingKeySet.has(key)) + const updated = variableNames.filter((key) => existingKeySet.has(key)) + + return { existingRows, existingValueByKey, added, updated } +} + +function buildEnvironmentVariablesResult( + variableNames: string[], + summary: Awaited>, + messagePrefix: string +) { + return { + message: `${messagePrefix} ${variableNames.length} environment variable(s): ${summary.added.length} added, ${summary.updated.length} updated`, + variableCount: variableNames.length, + variableNames, + totalVariableCount: summary.existingRows.length + summary.added.length, + addedVariables: summary.added, + updatedVariables: summary.updated, + } +} + +async function encryptEnvironmentVariables( + variables: Record, + context?: ServerToolExecutionContext +): Promise> { + const encryptedVariables: Record = {} + for (const [key, value] of Object.entries(variables)) { + throwIfServerToolAborted(context) + encryptedVariables[key] = (await encryptSecret(value)).encrypted + } + return encryptedVariables +} + +async function writeEncryptedEnvironmentVariables( + userId: string, + encryptedVariables: Record, + context?: ServerToolExecutionContext +) { + await db.transaction(async (tx) => { + for (const [key, encrypted] of Object.entries(encryptedVariables)) { + throwIfServerToolAborted(context) + await tx + .insert(environmentVariables) + .values({ + id: crypto.randomUUID(), + userId, + key, + value: encrypted, + }) + .onConflictDoUpdate({ + target: [environmentVariables.userId, environmentVariables.key], + set: { + value: encrypted, + updatedAt: new Date(), + }, + }) + } + }) +} + +export async function buildEnvironmentVariablesReviewPayload( + payload: unknown, + context?: ServerToolExecutionContext +) { + const variables = parseEnvironmentVariablesPayload(payload) + return { + payload: { variables: Object.fromEntries(Object.keys(variables).map((key) => [key, ''])) }, + encryptedVariables: await encryptEnvironmentVariables(variables, context), + } +} + +export async function applyEncryptedEnvironmentVariablesForUser( + userId: string, + encryptedVariables: Record, + context?: ServerToolExecutionContext +) { + const variableNames = Object.keys(encryptedVariables) + const summary = await readEnvironmentVariableSummary(userId, variableNames) + await writeEncryptedEnvironmentVariables(userId, encryptedVariables, context) + return buildEnvironmentVariablesResult(variableNames, summary, 'Successfully processed') +} + export const setEnvironmentVariablesServerTool: BaseServerTool = { name: 'set_environment_variables', @@ -50,80 +133,30 @@ export const setEnvironmentVariablesServerTool: BaseServerTool { - const logger = createLogger('SetEnvironmentVariablesServerTool') - if (!context?.userId) { - logger.error( - 'Unauthorized attempt to set environment variables - no authenticated user context' - ) throw new Error('Authentication required') } const userId = context.userId - const { variables } = params || ({} as SetEnvironmentVariablesParams) - - const normalized = normalizeEnvVarInput(variables || {}) - const { variables: validatedVariables } = EnvVarSchema.parse({ variables: normalized }) - const variableEntries = Object.entries(validatedVariables) + const validatedVariables = parseEnvironmentVariablesPayload(params) + const variableNames = Object.keys(validatedVariables) throwIfServerToolAborted(context) - const existingRows = await db - .select({ key: environmentVariables.key, value: environmentVariables.value }) - .from(environmentVariables) - .where(eq(environmentVariables.userId, userId)) - - const existingKeySet = new Set(existingRows.map((row) => row.key)) - const existingValueByKey = new Map(existingRows.map((row) => [row.key, row.value])) - const added = variableEntries.filter(([key]) => !existingKeySet.has(key)).map(([key]) => key) - const updated = variableEntries.filter(([key]) => existingKeySet.has(key)).map(([key]) => key) + const summary = await readEnvironmentVariableSummary(userId, variableNames) if (shouldStageServerToolMutationForReview(context)) { - const variableNames = Object.keys(validatedVariables) return { requiresReview: true, success: true, - message: `Review required for ${variableNames.length} environment variable(s): ${added.length} added, ${updated.length} updated`, - variableCount: variableNames.length, - variableNames, - totalVariableCount: existingRows.length + added.length, - addedVariables: added, - updatedVariables: updated, + ...buildEnvironmentVariablesResult(variableNames, summary, 'Review required for'), reviewBaseStateHash: hashEnvironmentVariableBase( - variableEntries.map(([key]) => [key, existingValueByKey.get(key) ?? null]) + variableNames.map((key) => [key, summary.existingValueByKey.get(key) ?? null]) ), } } - await db.transaction(async (tx) => { - for (const [key, val] of variableEntries) { - throwIfServerToolAborted(context) - const { encrypted } = await encryptSecret(val) - - await tx - .insert(environmentVariables) - .values({ - id: crypto.randomUUID(), - userId, - key, - value: encrypted, - }) - .onConflictDoUpdate({ - target: [environmentVariables.userId, environmentVariables.key], - set: { - value: encrypted, - updatedAt: new Date(), - }, - }) - } - }) - - return { - message: `Successfully processed ${Object.keys(validatedVariables).length} environment variable(s): ${added.length} added, ${updated.length} updated`, - variableCount: Object.keys(validatedVariables).length, - variableNames: Object.keys(validatedVariables), - totalVariableCount: existingRows.length + added.length, - addedVariables: added, - updatedVariables: updated, - } + const encryptedVariables = await encryptEnvironmentVariables(validatedVariables, context) + await writeEncryptedEnvironmentVariables(userId, encryptedVariables, context) + return buildEnvironmentVariablesResult(variableNames, summary, 'Successfully processed') }, } From 82adab98c6d6175550a7877a04c73c3987550adf Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 11:12:01 -0600 Subject: [PATCH 045/284] docs: update project agent testing guidance Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- AGENTS.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e0f8f73cb..9f8ccc895 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,7 @@ - Do not add legacy support; updates should be clean and avoid extra project complexity. - We do not need any form of legacy support as the project is under fresh dev, do not add any form of legacy backfill path - This project does not support any legacy methods. -- Ignore all license related issues. -- Project uses `Bun` pacakge manager with turborepo. +- Project uses `Bun` pacakge manager with turborepo, find project defined scripts in `/pacakge.json` for testing. - Prefer removing lines of code over adding more lines of code to reduce project complexity. ## Planning From 3dcf4be2975faf501babd31a8a28b71f6d236aa6 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 11:12:19 -0600 Subject: [PATCH 046/284] fix(copilot): reject empty JSON-RPC batches Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/copilot/mcp/route.test.ts | 18 ++++++++++++++++++ apps/tradinggoose/app/api/copilot/mcp/route.ts | 4 ++++ 2 files changed, 22 insertions(+) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index 01c7fad7e..090d9cf42 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -186,4 +186,22 @@ describe('Copilot MCP route', () => { ]) expect(mockRouteExecution).not.toHaveBeenCalled() }) + + it('rejects empty JSON-RPC batches as invalid requests', async () => { + const { POST } = await import('./route') + + const response = await POST(createMcpRequest([])) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual({ + jsonrpc: '2.0', + id: null, + error: { + code: -32600, + message: 'Invalid JSON-RPC request', + }, + }) + expect(mockRouteExecution).not.toHaveBeenCalled() + }) }) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index d153f10a6..7f172cd8b 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -229,6 +229,10 @@ export async function POST(request: NextRequest) { } if (Array.isArray(body)) { + if (body.length === 0) { + return mcpJsonResponse(jsonRpcError(null, -32600, 'Invalid JSON-RPC request')) + } + const responses = ( await Promise.all(body.map((entry) => handleJsonRpcRequest(entry, auth))) ).filter(Boolean) From 6cbb24dc2790b367b8bd72787389137269ce92ac Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 11:12:34 -0600 Subject: [PATCH 047/284] fix(copilot): redact managed review secrets Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/copilot/entity-documents.ts | 36 +++++ .../tools/server/entities/shared.test.ts | 52 +++++++- .../copilot/tools/server/entities/shared.ts | 29 +++- .../copilot/tools/server/review-acceptance.ts | 126 +++++++++--------- .../server/user/set-environment-variables.ts | 22 --- 5 files changed, 173 insertions(+), 92 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/entity-documents.ts b/apps/tradinggoose/lib/copilot/entity-documents.ts index 321fcfb79..7d47e7ed2 100644 --- a/apps/tradinggoose/lib/copilot/entity-documents.ts +++ b/apps/tradinggoose/lib/copilot/entity-documents.ts @@ -82,6 +82,18 @@ export type EntityDocumentFields = z.infer< (typeof EntityDocumentSchemas)[K] > +const REVIEW_SECRET_PLACEHOLDER = '[redacted]' + +function redactStringRecordValues(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + + return Object.fromEntries( + Object.keys(value as Record).map((key) => [key, REVIEW_SECRET_PLACEHOLDER]) + ) +} + function normalizeEntityFields( kind: EntityDocumentKind, fields: Record | null | undefined @@ -175,6 +187,23 @@ export function parseEntityDocument( return EntityDocumentSchemas[kind].parse(normalized) as EntityDocumentFields } +function redactEntityDocumentFieldsForReview( + kind: K, + fields: Record | null | undefined +): EntityDocumentFields { + const normalized = normalizeEntityFields(kind, fields) + const redacted = + kind === 'mcp_server' + ? { + ...normalized, + headers: redactStringRecordValues(normalized.headers), + env: redactStringRecordValues(normalized.env), + } + : normalized + + return EntityDocumentSchemas[kind].parse(redacted) as EntityDocumentFields +} + export function serializeEntityDocument( kind: K, fields: Record | null | undefined @@ -184,6 +213,13 @@ export function serializeEntityDocument( return JSON.stringify(parsed, null, 2) } +export function serializeEntityDocumentForReview( + kind: K, + fields: Record | null | undefined +): string { + return JSON.stringify(redactEntityDocumentFieldsForReview(kind, fields), null, 2) +} + export function getEntityDocumentName( kind: EntityDocumentKind, fields: Record | null | undefined diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts index bc7707b6d..eb76aa586 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SKILL_DOCUMENT_FORMAT } from '@/lib/copilot/entity-documents' +import { MCP_SERVER_DOCUMENT_FORMAT, SKILL_DOCUMENT_FORMAT } from '@/lib/copilot/entity-documents' import { + buildReviewDocumentDiff, executeCreateEntityDocumentMutation, executeUpdateEntityDocumentMutation, } from './shared' @@ -104,4 +105,53 @@ describe('entity document mutation helpers', () => { 'New Skill' ) }) + + it('redacts MCP server secret values in review documents', async () => { + const result = await executeCreateEntityDocumentMutation( + 'mcp_server', + { + workspaceId: 'workspace-1', + documentFormat: MCP_SERVER_DOCUMENT_FORMAT, + entityDocument: JSON.stringify({ + name: 'Private MCP', + description: 'Uses auth', + transport: 'http', + url: 'https://mcp.example.test', + headers: { Authorization: 'Bearer secret-token' }, + command: '', + args: [], + env: { API_KEY: 'secret-env' }, + timeout: 30000, + retries: 3, + enabled: true, + }), + }, + { userId: 'user-1', accessLevel: 'limited' }, + vi.fn() + ) + const after = 'preview' in result ? result.preview.documentDiff.after : '' + const diff = buildReviewDocumentDiff( + 'mcp_server', + { + name: 'Private MCP', + description: 'Uses old auth', + transport: 'http', + url: 'https://mcp.example.test', + headers: { Authorization: 'Bearer old-secret' }, + command: '', + args: [], + env: { API_KEY: 'old-secret-env' }, + timeout: 30000, + retries: 3, + enabled: true, + }, + JSON.parse(after) + ) + + expect(after).toContain('[redacted]') + expect(after).not.toContain('secret-token') + expect(after).not.toContain('secret-env') + expect(diff.before).not.toContain('old-secret') + expect(diff.after).not.toContain('secret-token') + }) }) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index 9f2237cd2..ee924a57c 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -4,6 +4,7 @@ import { getEntityDocumentName, parseEntityDocument, serializeEntityDocument, + serializeEntityDocumentForReview, } from '@/lib/copilot/entity-documents' import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' import type { @@ -182,14 +183,28 @@ export function buildDocumentEnvelope( } } -export function buildDocumentDiff( +export function buildReviewDocumentEnvelope( + kind: SavedEntityDocumentKind, + entityId: string | undefined, + fields: Record +) { + return { + entityKind: kind, + ...(entityId ? { entityId } : {}), + entityName: getEntityDocumentName(kind, fields), + documentFormat: getEntityDocumentFormat(kind), + entityDocument: serializeEntityDocumentForReview(kind, fields), + } +} + +export function buildReviewDocumentDiff( kind: SavedEntityDocumentKind, before: Record, after: Record ) { return { - before: serializeEntityDocument(kind, before), - after: serializeEntityDocument(kind, after), + before: serializeEntityDocumentForReview(kind, before), + after: serializeEntityDocumentForReview(kind, after), } } @@ -230,11 +245,11 @@ export async function executeCreateEntityDocumentMutation( requiresReview: true, success: true, workspaceId, - ...buildDocumentEnvelope(kind, undefined, fields), + ...buildReviewDocumentEnvelope(kind, undefined, fields), preview: { documentDiff: { before: '', - after: serializeEntityDocument(kind, fields), + after: serializeEntityDocumentForReview(kind, fields), }, }, } @@ -266,9 +281,9 @@ export async function executeUpdateEntityDocumentMutation( return { requiresReview: true, success: true, - ...buildDocumentEnvelope(kind, entityId, fields), + ...buildReviewDocumentEnvelope(kind, entityId, fields), preview: { - documentDiff: buildDocumentDiff(kind, currentFields, fields), + documentDiff: buildReviewDocumentDiff(kind, currentFields, fields), }, } } diff --git a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts index 583351db6..d8660f22c 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts @@ -3,14 +3,16 @@ import { db } from '@tradinggoose/db' import { verification } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' import { nanoid } from 'nanoid' +import { + MCP_SERVER_DOCUMENT_FORMAT, + parseEntityDocument, + serializeEntityDocumentForReview, +} from '@/lib/copilot/entity-documents' import { type ToolId } from '@/lib/copilot/registry' import { StructuredServerToolError } from '@/lib/copilot/server-tool-errors' import type { ServerToolExecutionContext } from '@/lib/copilot/tools/server/base-tool' import { routeExecution } from '@/lib/copilot/tools/server/router' -import { - applyEncryptedEnvironmentVariablesForUser, - buildEnvironmentVariablesReviewPayload, -} from '@/lib/copilot/tools/server/user/set-environment-variables' +import { decryptSecret, encryptSecret } from '@/lib/utils-server' const REVIEW_TOKEN_PREFIX = 'copilot-tool-review:' const REVIEW_TOKEN_TTL_MS = 30 * 60 * 1000 @@ -39,53 +41,51 @@ function readBaseSignature(result: unknown): string { throw new Error('Server tool review result is missing base state') } -function stripReviewMetadata(result: unknown) { - if (!result || typeof result !== 'object' || Array.isArray(result)) { - return result +function redactMcpServerReviewDocument(value: unknown): unknown { + if (typeof value !== 'string') { + return value } - const { reviewBaseStateHash: _reviewBaseStateHash, ...publicResult } = result as Record< - string, - unknown - > - return publicResult + if (!value) { + return '' + } + + return serializeEntityDocumentForReview('mcp_server', parseEntityDocument('mcp_server', value)) } -async function buildStagedReviewValue( - toolName: ToolId, - payload: unknown, - result: unknown, - context: ServerToolExecutionContext & { userId: string } -) { - const staged: Record = { - userId: context.userId, - toolName, - payload, - baseSignature: readBaseSignature(result), +function redactReviewSecrets(result: unknown) { + if (!result || typeof result !== 'object' || Array.isArray(result)) { + return result } - if (toolName === 'set_environment_variables') { - const reviewPayload = await buildEnvironmentVariablesReviewPayload(payload, context) - staged.payload = reviewPayload.payload - staged.encryptedEnvironmentVariables = reviewPayload.encryptedVariables + const record = result as Record + if ( + record.entityKind !== 'mcp_server' && + record.documentFormat !== MCP_SERVER_DOCUMENT_FORMAT + ) { + return result } - return staged -} - -function readEncryptedEnvironmentVariables(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('Server tool review token is invalid or expired') + const publicResult: Record = { ...record } + publicResult.entityDocument = redactMcpServerReviewDocument(publicResult.entityDocument) + const preview = record.preview + if (preview && typeof preview === 'object' && !Array.isArray(preview)) { + const documentDiff = (preview as { documentDiff?: unknown }).documentDiff + if (documentDiff && typeof documentDiff === 'object' && !Array.isArray(documentDiff)) { + publicResult.preview = { + ...preview, + documentDiff: { + ...(documentDiff as Record), + before: redactMcpServerReviewDocument( + (documentDiff as { before?: unknown }).before + ), + after: redactMcpServerReviewDocument((documentDiff as { after?: unknown }).after), + }, + } + } } - return Object.fromEntries( - Object.entries(value as Record).map(([key, encrypted]) => { - if (typeof encrypted !== 'string') { - throw new Error('Server tool review token is invalid or expired') - } - return [key, encrypted] - }) - ) + return publicResult } export async function stageServerManagedToolReview( @@ -107,22 +107,27 @@ export async function stageServerManagedToolReview( const reviewToken = nanoid() const now = new Date() - const publicResult = stripReviewMetadata(result) - const stagedValue = await buildStagedReviewValue(toolName, payload, result, { - ...context, - userId: context.userId, - }) + const { reviewBaseStateHash: _reviewBaseStateHash, ...publicResult } = result as Record< + string, + unknown + > + const encryptedPayload = (await encryptSecret(JSON.stringify(payload ?? null))).encrypted await db.insert(verification).values({ id: nanoid(), identifier: `${REVIEW_TOKEN_PREFIX}${reviewToken}`, - value: JSON.stringify(stagedValue), + value: JSON.stringify({ + userId: context.userId, + toolName, + encryptedPayload, + baseSignature: readBaseSignature(result), + }), expiresAt: new Date(now.getTime() + REVIEW_TOKEN_TTL_MS), createdAt: now, updatedAt: now, }) return { - ...(publicResult as Record), + ...(redactReviewSecrets(publicResult) as Record), reviewToken, } } @@ -152,17 +157,15 @@ export async function acceptServerManagedToolReview( let staged: { userId?: unknown toolName?: unknown - payload?: unknown + encryptedPayload?: unknown baseSignature?: unknown - encryptedEnvironmentVariables?: unknown } try { staged = JSON.parse(row.value) as { userId?: unknown toolName?: unknown - payload?: unknown + encryptedPayload?: unknown baseSignature?: unknown - encryptedEnvironmentVariables?: unknown } } catch { throw new Error('Server tool review token is invalid or expired') @@ -176,7 +179,13 @@ export async function acceptServerManagedToolReview( throw new Error('Server tool review token does not match this request') } - const currentReview = await routeExecution(toolName, staged.payload, { + if (typeof staged.encryptedPayload !== 'string' || !staged.encryptedPayload) { + throw new Error('Server tool review token is invalid or expired') + } + + const { decrypted } = await decryptSecret(staged.encryptedPayload) + const payload = JSON.parse(decrypted) + const currentReview = await routeExecution(toolName, payload, { ...context, accessLevel: 'limited', }) @@ -200,16 +209,9 @@ export async function acceptServerManagedToolReview( throw new Error('Server tool review token is invalid or expired') } - if (toolName === 'set_environment_variables') { - return applyEncryptedEnvironmentVariablesForUser( - context.userId, - readEncryptedEnvironmentVariables(staged.encryptedEnvironmentVariables), - context - ) - } - - return routeExecution(toolName, staged.payload, { + const acceptedResult = await routeExecution(toolName, payload, { ...context, accessLevel: 'full', }) + return redactReviewSecrets(acceptedResult) } diff --git a/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts b/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts index 126c0e03f..9a61c9a1a 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts @@ -104,28 +104,6 @@ async function writeEncryptedEnvironmentVariables( }) } -export async function buildEnvironmentVariablesReviewPayload( - payload: unknown, - context?: ServerToolExecutionContext -) { - const variables = parseEnvironmentVariablesPayload(payload) - return { - payload: { variables: Object.fromEntries(Object.keys(variables).map((key) => [key, ''])) }, - encryptedVariables: await encryptEnvironmentVariables(variables, context), - } -} - -export async function applyEncryptedEnvironmentVariablesForUser( - userId: string, - encryptedVariables: Record, - context?: ServerToolExecutionContext -) { - const variableNames = Object.keys(encryptedVariables) - const summary = await readEnvironmentVariableSummary(userId, variableNames) - await writeEncryptedEnvironmentVariables(userId, encryptedVariables, context) - return buildEnvironmentVariablesResult(variableNames, summary, 'Successfully processed') -} - export const setEnvironmentVariablesServerTool: BaseServerTool = { name: 'set_environment_variables', From 12d81b4024d75b2a9d9dcb96a7f01e3195dcca60 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 11:35:38 -0600 Subject: [PATCH 048/284] feat(mcp): use personal API keys for device login Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../(auth)/mcp/authorize/page.test.tsx | 2 +- .../tradinggoose/app/api/copilot/mcp/route.ts | 2 +- apps/tradinggoose/i18n/messages/en.json | 8 +++---- apps/tradinggoose/i18n/messages/es.json | 8 +++---- apps/tradinggoose/i18n/messages/zh.json | 8 +++---- apps/tradinggoose/i18n/public-copy.test.ts | 6 ++--- apps/tradinggoose/lib/mcp/auth.ts | 24 +++++++++---------- apps/tradinggoose/lib/mcp/install-script.ts | 8 +++---- 8 files changed, 33 insertions(+), 33 deletions(-) diff --git a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx index f1bb12e25..b3eeb38ce 100644 --- a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx +++ b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx @@ -89,7 +89,7 @@ describe('MCP authorize page', () => { const markup = renderToStaticMarkup(result) expect(mockCreateMcpDeviceLoginApprovalChallenge).toHaveBeenCalledWith('login-code', 'user-1') - expect(markup).toContain('Aprobar acceso MCP') + expect(markup).toContain('Aprobar clave API personal') expect(markup).toContain('method="post"') expect(markup).toContain('action="/api/auth/mcp/authorize"') expect(markup).toContain('name="approvalToken"') diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index 7f172cd8b..1f96cbc64 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -75,7 +75,7 @@ async function authenticateCopilotMcpRequest( const auth = await authenticateMcpApiKey(token) if (!auth.success || !auth.userId) { - return { error: 'Invalid Copilot MCP bearer token' } + return { error: 'Invalid TradingGoose personal API key' } } if (auth.keyId) { diff --git a/apps/tradinggoose/i18n/messages/en.json b/apps/tradinggoose/i18n/messages/en.json index 36621c834..69c805c98 100644 --- a/apps/tradinggoose/i18n/messages/en.json +++ b/apps/tradinggoose/i18n/messages/en.json @@ -360,9 +360,9 @@ "mcp": { "eyebrow": "MCP authorization", "confirm": { - "title": "Approve MCP access", - "description": "A local TradingGoose MCP setup command is requesting an API key for this account.", - "approve": "Approve access", + "title": "Approve personal API key", + "description": "A local TradingGoose MCP setup command is requesting a personal API key for this account.", + "approve": "Approve key", "cancel": "Cancel", "terminalHint": "Only approve this if you started the setup or login command in your own terminal." }, @@ -375,7 +375,7 @@ "description": "Return to your terminal and run the TradingGoose MCP login command again." }, "approved": { - "title": "MCP login approved", + "title": "Personal API key approved", "description": "Return to your terminal to finish configuring your local agent." }, "cancelled": { diff --git a/apps/tradinggoose/i18n/messages/es.json b/apps/tradinggoose/i18n/messages/es.json index b8d5fa02c..b41e5f7c6 100644 --- a/apps/tradinggoose/i18n/messages/es.json +++ b/apps/tradinggoose/i18n/messages/es.json @@ -360,9 +360,9 @@ "mcp": { "eyebrow": "Autorización MCP", "confirm": { - "title": "Aprobar acceso MCP", - "description": "Un comando local de configuración de TradingGoose MCP solicita una clave API para esta cuenta.", - "approve": "Aprobar acceso", + "title": "Aprobar clave API personal", + "description": "Un comando local de configuración de TradingGoose MCP solicita una clave API personal para esta cuenta.", + "approve": "Aprobar clave", "cancel": "Cancelar", "terminalHint": "Aprueba esto solo si iniciaste el comando de configuración o inicio de sesión en tu propia terminal." }, @@ -375,7 +375,7 @@ "description": "Vuelve a la terminal y ejecuta de nuevo el comando de inicio de sesión de TradingGoose MCP." }, "approved": { - "title": "Inicio de sesión MCP aprobado", + "title": "Clave API personal aprobada", "description": "Vuelve a la terminal para terminar de configurar tu agente local." }, "cancelled": { diff --git a/apps/tradinggoose/i18n/messages/zh.json b/apps/tradinggoose/i18n/messages/zh.json index 501415397..300349f6b 100644 --- a/apps/tradinggoose/i18n/messages/zh.json +++ b/apps/tradinggoose/i18n/messages/zh.json @@ -360,9 +360,9 @@ "mcp": { "eyebrow": "MCP 授权", "confirm": { - "title": "批准 MCP 访问", - "description": "本地 TradingGoose MCP 设置命令正在请求为此账户创建 API 密钥。", - "approve": "批准访问", + "title": "批准个人 API 密钥", + "description": "本地 TradingGoose MCP 设置命令正在请求为此账户创建个人 API 密钥。", + "approve": "批准密钥", "cancel": "取消", "terminalHint": "仅在你自己终端中启动了设置或登录命令时才批准。" }, @@ -375,7 +375,7 @@ "description": "返回终端并重新运行 TradingGoose MCP 登录命令。" }, "approved": { - "title": "MCP 登录已批准", + "title": "个人 API 密钥已批准", "description": "返回终端完成本地代理配置。" }, "cancelled": { diff --git a/apps/tradinggoose/i18n/public-copy.test.ts b/apps/tradinggoose/i18n/public-copy.test.ts index c815fde03..f22baf2bc 100644 --- a/apps/tradinggoose/i18n/public-copy.test.ts +++ b/apps/tradinggoose/i18n/public-copy.test.ts @@ -153,9 +153,9 @@ describe('public copy', () => { getPublicCopy('en').auth.common.verifyEmail ) expect(getPublicCopy('en').auth.common.loading).toBe('Loading...') - expect(getPublicCopy('en').auth.mcp.approved.title).toBe('MCP login approved') - expect(getPublicCopy('es').auth.mcp.approved.title).toBe('Inicio de sesión MCP aprobado') - expect(getPublicCopy('zh').auth.mcp.approved.title).toBe('MCP 登录已批准') + expect(getPublicCopy('en').auth.mcp.approved.title).toBe('Personal API key approved') + expect(getPublicCopy('es').auth.mcp.approved.title).toBe('Clave API personal aprobada') + expect(getPublicCopy('zh').auth.mcp.approved.title).toBe('个人 API 密钥已批准') }) it('includes localized verification screen copy', () => { diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 4c3eabed4..f3af99569 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -8,7 +8,7 @@ import { decryptApiKey, encryptApiKey } from '@/lib/api-key/service' const DEVICE_LOGIN_TTL_MS = 10 * 60 * 1000 const DEVICE_LOGIN_PREFIX = 'mcp:' -const MCP_API_KEY_PREFIX = 'sk-tradinggoose-mcp.' +const DEVICE_PERSONAL_API_KEY_PREFIX = 'sk-tradinggoose-pat.' const POLL_INTERVAL_SECONDS = 2 type PendingDeviceLogin = { @@ -70,12 +70,12 @@ function hashValue(value: string) { return createHash('sha256').update(value).digest('hex') } -function createMcpApiKey(keyId: string) { - return `${MCP_API_KEY_PREFIX}${keyId}.${randomBytes(32).toString('base64url')}` +function createDevicePersonalApiKey(keyId: string) { + return `${DEVICE_PERSONAL_API_KEY_PREFIX}${keyId}.${randomBytes(32).toString('base64url')}` } -function readMcpApiKeyId(value: string) { - const match = value.match(/^sk-tradinggoose-mcp\.([A-Za-z0-9_-]+)\.[A-Za-z0-9_-]+$/) +function readDevicePersonalApiKeyId(value: string) { + const match = value.match(/^sk-tradinggoose-pat\.([A-Za-z0-9_-]+)\.[A-Za-z0-9_-]+$/) return match?.[1] ?? null } @@ -173,12 +173,12 @@ async function updateDeviceLoginState( return Boolean(updated) } -async function issueMcpDeviceLoginKey( +async function issueDeviceLoginPersonalApiKey( login: DeviceLogin, approvedState: ApprovedDeviceLogin ): Promise { const keyId = nanoid() - const plainKey = createMcpApiKey(keyId) + const plainKey = createDevicePersonalApiKey(keyId) const encryptedKey = (await encryptApiKey(plainKey)).encrypted const nextState = { @@ -193,7 +193,7 @@ async function issueMcpDeviceLoginKey( : null } -async function confirmMcpDeviceLoginKey( +async function confirmDeviceLoginPersonalApiKey( login: DeviceLogin, issuedState: IssuedDeviceLogin, plainKey: string @@ -219,7 +219,7 @@ async function confirmMcpDeviceLoginKey( id: issuedState.keyId, userId: issuedState.userId, workspaceId: null, - name: `TradingGoose MCP ${now.toISOString()}`, + name: `TradingGoose Personal Access ${now.toISOString()}`, key: issuedState.apiKeyEncrypted, type: 'personal', createdAt: now, @@ -329,7 +329,7 @@ export async function pollMcpDeviceLogin( return { status: 'invalid' } } - if (!(await confirmMcpDeviceLoginKey(login, approvedState, options.apiKey))) { + if (!(await confirmDeviceLoginPersonalApiKey(login, approvedState, options.apiKey))) { return { status: 'invalid' } } @@ -347,7 +347,7 @@ export async function pollMcpDeviceLogin( } } - const issued = await issueMcpDeviceLoginKey(login, approvedState) + const issued = await issueDeviceLoginPersonalApiKey(login, approvedState) if (!issued) { continue } @@ -436,7 +436,7 @@ export async function cancelMcpDeviceLogin({ } export async function authenticateMcpApiKey(token: string) { - const keyId = readMcpApiKeyId(token) + const keyId = readDevicePersonalApiKeyId(token) if (!keyId) { return { success: false as const } } diff --git a/apps/tradinggoose/lib/mcp/install-script.ts b/apps/tradinggoose/lib/mcp/install-script.ts index 43485b0a0..5baef9634 100644 --- a/apps/tradinggoose/lib/mcp/install-script.ts +++ b/apps/tradinggoose/lib/mcp/install-script.ts @@ -137,7 +137,7 @@ async function authenticate() { fail('Studio did not return an authorization URL') } - console.log('Open this URL in your browser to approve MCP access:') + console.log('Open this URL in your browser to approve a personal API key:') console.log(authorizeUrl) console.log('') @@ -177,7 +177,7 @@ async function confirmLogin(login) { }) const status = String(confirmJson?.status || '') if (status !== 'confirmed') { - fail('Studio could not confirm the delivered MCP token') + fail('Studio could not confirm the delivered personal API key') } } @@ -248,7 +248,7 @@ PowerShell: irm /mcp/login | iex Commands: - login Print a valid local MCP bearer token, authenticating when needed. + login Print a valid local personal API bearer token, authenticating when needed. setup Write MCP config, authenticating when needed. Options: @@ -367,7 +367,7 @@ POSIX shell: curl -fsSL /mcp/login | sh Commands: - login Print a valid local MCP bearer token, authenticating when needed. + login Print a valid local personal API bearer token, authenticating when needed. setup Write MCP config, authenticating when needed. Options: From adb0dd44b20ab09d622bcc3d8cc31e3b6434f7d3 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 11:55:27 -0600 Subject: [PATCH 049/284] fix(mcp): issue device login keys during approval polling Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/auth/mcp/poll/route.test.ts | 27 +-------- .../app/api/auth/mcp/poll/route.ts | 17 +++--- .../app/mcp/[[...command]]/route.test.ts | 12 ++-- apps/tradinggoose/lib/mcp/auth.ts | 57 ++++++------------- apps/tradinggoose/lib/mcp/install-script.ts | 19 +------ 5 files changed, 31 insertions(+), 101 deletions(-) diff --git a/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts index 44965c780..244d88705 100644 --- a/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts @@ -39,32 +39,7 @@ describe('MCP login poll route', () => { apiKey: 'sk-tradinggoose-token', expiresAt: '2026-06-19T12:00:00.000Z', }) - expect(mockPollMcpDeviceLogin).toHaveBeenCalledWith('login-code', 'verification-key', { - confirm: false, - apiKey: undefined, - }) - }) - - it('confirms a delivered device login token', async () => { - const { POST } = await import('./route') - - const response = await POST( - new NextRequest('https://studio.example.test/api/auth/mcp/poll', { - method: 'POST', - body: JSON.stringify({ - code: 'login-code', - verificationKey: 'verification-key', - confirm: true, - apiKey: 'sk-tradinggoose-token', - }), - }) - ) - - expect(response.status).toBe(200) - expect(mockPollMcpDeviceLogin).toHaveBeenCalledWith('login-code', 'verification-key', { - confirm: true, - apiKey: 'sk-tradinggoose-token', - }) + expect(mockPollMcpDeviceLogin).toHaveBeenCalledWith('login-code', 'verification-key') }) it('rejects malformed poll requests', async () => { diff --git a/apps/tradinggoose/app/api/auth/mcp/poll/route.ts b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts index 41611eb1a..b71a00af1 100644 --- a/apps/tradinggoose/app/api/auth/mcp/poll/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts @@ -4,12 +4,12 @@ import { pollMcpDeviceLogin } from '@/lib/mcp/auth' export const dynamic = 'force-dynamic' -const PollRequestSchema = z.object({ - code: z.string().min(1), - verificationKey: z.string().min(1), - confirm: z.boolean().optional(), - apiKey: z.string().optional(), -}) +const PollRequestSchema = z + .object({ + code: z.string().min(1), + verificationKey: z.string().min(1), + }) + .strict() export async function POST(request: NextRequest) { const parsed = PollRequestSchema.safeParse(await request.json().catch(() => null)) @@ -17,9 +17,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid MCP login poll request' }, { status: 400 }) } - const result = await pollMcpDeviceLogin(parsed.data.code, parsed.data.verificationKey, { - confirm: parsed.data.confirm === true, - apiKey: parsed.data.apiKey, - }) + const result = await pollMcpDeviceLogin(parsed.data.code, parsed.data.verificationKey) return NextResponse.json(result) } diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts index c85027f3f..936174744 100644 --- a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts @@ -41,9 +41,9 @@ describe('MCP install route', () => { expect(script).toContain("baseUrl + '/api/auth/mcp/start'") expect(script).toContain("baseUrl + '/api/auth/mcp/poll'") expect(script).toContain('const verificationKey = String(startJson?.verificationKey ||') - expect(script).toContain('return { code, verificationKey, token }') - expect(script).toContain('confirm: true') - expect(script).toContain('apiKey: login.token') + expect(script).toContain('return token') + expect(script).not.toContain('confirmLogin') + expect(script).not.toContain('confirm: true') expect(script).toContain("baseUrl + '/api/copilot/mcp'") expect(script).toContain("method: 'ping'") expect(script).toContain('async function isTokenValid(token)') @@ -67,14 +67,12 @@ describe('MCP install route', () => { expect(script).not.toContain('entityId') const printedTokenIndex = script.indexOf("console.log('Authorization: Bearer ' + token)") - const firstConfirmIndex = script.indexOf('await confirmLogin(login)') - const firstReturnTokenIndex = script.indexOf('return login.token') + const firstReturnTokenIndex = script.indexOf('return token') const setupIndex = script.indexOf("if (command === 'setup')") const configWriteIndex = script.indexOf( 'const configPath = runConfigWriter([target, mcpUrl, token])' ) - expect(printedTokenIndex).toBeGreaterThan(firstConfirmIndex) - expect(firstReturnTokenIndex).toBeGreaterThan(firstConfirmIndex) + expect(printedTokenIndex).toBeGreaterThan(firstReturnTokenIndex) expect(configWriteIndex).toBeGreaterThan(setupIndex) }) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index f3af99569..314bd2bbe 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -46,7 +46,6 @@ type DeviceLogin = { export type McpDeviceLoginPollResult = | { status: 'pending'; intervalSeconds: number; expiresAt: string } | { status: 'approved'; apiKey: string; expiresAt: string } - | { status: 'confirmed'; expiresAt: string } | { status: 'invalid' } | { status: 'expired' } @@ -188,39 +187,29 @@ async function issueDeviceLoginPersonalApiKey( apiKeyEncrypted: encryptedKey, } satisfies IssuedDeviceLogin - return (await updateDeviceLoginState(login, nextState)) - ? { status: 'approved', apiKey: plainKey, expiresAt: login.expiresAt.toISOString() } - : null -} - -async function confirmDeviceLoginPersonalApiKey( - login: DeviceLogin, - issuedState: IssuedDeviceLogin, - plainKey: string -): Promise { - if (issuedState.apiKeyHash !== hashValue(plainKey)) { - return false - } - const now = new Date() - const confirmed = await db.transaction(async (tx) => { - const [deleted] = await tx - .delete(verification) - .where(deviceLoginMatches(login, issuedState)) + const issued = await db.transaction(async (tx) => { + const [updated] = await tx + .update(verification) + .set({ + value: JSON.stringify(nextState), + updatedAt: now, + }) + .where(deviceLoginMatches(login, approvedState)) .returning({ id: verification.id }) - if (!deleted) { + if (!updated) { return null } const [createdKey] = await tx .insert(apiKey) .values({ - id: issuedState.keyId, - userId: issuedState.userId, + id: nextState.keyId, + userId: nextState.userId, workspaceId: null, name: `TradingGoose Personal Access ${now.toISOString()}`, - key: issuedState.apiKeyEncrypted, + key: nextState.apiKeyEncrypted, type: 'personal', createdAt: now, updatedAt: now, @@ -230,7 +219,9 @@ async function confirmDeviceLoginPersonalApiKey( return createdKey }) - return Boolean(confirmed) + return issued + ? { status: 'approved', apiKey: plainKey, expiresAt: login.expiresAt.toISOString() } + : null } export async function startMcpDeviceLogin(): Promise { @@ -301,8 +292,7 @@ export async function createMcpDeviceLoginApprovalChallenge(code: string, userId export async function pollMcpDeviceLogin( code: string, - verificationKey: string, - options: { confirm?: boolean; apiKey?: string } = {} + verificationKey: string ): Promise { while (true) { const login = await readDeviceLogin(code) @@ -324,21 +314,6 @@ export async function pollMcpDeviceLogin( const approvedState = login.state - if (options.confirm) { - if (!isIssuedDeviceLogin(approvedState) || !options.apiKey) { - return { status: 'invalid' } - } - - if (!(await confirmDeviceLoginPersonalApiKey(login, approvedState, options.apiKey))) { - return { status: 'invalid' } - } - - return { - status: 'confirmed', - expiresAt: login.expiresAt.toISOString(), - } - } - if (isIssuedDeviceLogin(approvedState)) { return { status: 'approved', diff --git a/apps/tradinggoose/lib/mcp/install-script.ts b/apps/tradinggoose/lib/mcp/install-script.ts index 5baef9634..24cba8e10 100644 --- a/apps/tradinggoose/lib/mcp/install-script.ts +++ b/apps/tradinggoose/lib/mcp/install-script.ts @@ -115,9 +115,7 @@ async function resolveAuthToken() { return existingToken } - const login = await authenticate() - await confirmLogin(login) - return login.token + return authenticate() } async function authenticate() { @@ -151,7 +149,7 @@ async function authenticate() { if (!token) { fail('Studio approved login without returning a token') } - return { code, verificationKey, token } + return token } if (status === 'expired') { @@ -168,19 +166,6 @@ async function authenticate() { fail('Timed out waiting for browser approval') } -async function confirmLogin(login) { - const confirmJson = await postJson(baseUrl + '/api/auth/mcp/poll', { - code: login.code, - verificationKey: login.verificationKey, - confirm: true, - apiKey: login.token, - }) - const status = String(confirmJson?.status || '') - if (status !== 'confirmed') { - fail('Studio could not confirm the delivered personal API key') - } -} - async function main() { requireFetch() From bdf6e568c612cc9970433b1fade08874a16dafd8 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 11:55:41 -0600 Subject: [PATCH 050/284] fix(workflows): preserve deployment metadata from saved state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/workflows/db-helpers.test.ts | 7 ++++++- apps/tradinggoose/lib/workflows/db-helpers.ts | 13 ++++++++++++- .../lib/yjs/server/bootstrap-review-target.ts | 2 ++ apps/tradinggoose/lib/yjs/workflow-session.ts | 5 +++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index 6cce9e19f..3f9476f77 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -1153,6 +1153,7 @@ describe('Database Helpers', () => { it('loads materialized workflow state when no Yjs state exists', async () => { const variables = { 'var-db': { id: 'var-db', name: 'risk', value: 'saved' } } const updatedAt = new Date('2026-04-06T00:05:00.000Z') + const deployedAt = new Date('2026-04-06T00:10:00.000Z') let callCount = 0 mockDb.select.mockImplementation(() => { callCount++ @@ -1160,7 +1161,9 @@ describe('Database Helpers', () => { return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ variables, updatedAt }]), + limit: vi + .fn() + .mockResolvedValue([{ variables, updatedAt, isDeployed: true, deployedAt }]), }), }), } @@ -1188,6 +1191,8 @@ describe('Database Helpers', () => { variables, direction: 'LR', lastSaved: updatedAt.getTime(), + isDeployed: true, + deployedAt: deployedAt.toISOString(), source: 'db', }) expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalled() diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 1ccd58af9..f95c2100e 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -82,6 +82,8 @@ export type PersistedWorkflowState = { parallels: Record variables: Record lastSaved: number + isDeployed?: boolean + deployedAt?: string } export async function loadWorkflowStateFromYjs( @@ -152,6 +154,8 @@ export async function loadWorkflowStateFromSavedTables( .select({ variables: workflow.variables, updatedAt: workflow.updatedAt, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt, }) .from(workflow) .where(eq(workflow.id, workflowId)) @@ -170,6 +174,8 @@ export async function loadWorkflowStateFromSavedTables( parallels: normalizedState?.parallels ?? {}, variables: (row.variables as Record) ?? {}, lastSaved: row.updatedAt?.getTime() ?? Date.now(), + isDeployed: row.isDeployed ?? false, + deployedAt: toISOStringOrUndefined(row.deployedAt), } return { @@ -982,7 +988,12 @@ export async function deployWorkflow(params: { if (!stateWithSource) { return { success: false, error: 'Failed to load workflow state' } } - const { source: _source, ...currentState } = stateWithSource + const { + source: _source, + isDeployed: _isDeployed, + deployedAt: _deployedAt, + ...currentState + } = stateWithSource const now = new Date() diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 713901745..f5694c707 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -140,6 +140,8 @@ async function bootstrapSavedEntityFromDb( loops: workflowState.loops, parallels: workflowState.parallels, lastSaved: new Date(workflowState.lastSaved).toISOString(), + isDeployed: workflowState.isDeployed, + deployedAt: workflowState.deployedAt, }), YJS_ORIGINS.SYSTEM ) diff --git a/apps/tradinggoose/lib/yjs/workflow-session.ts b/apps/tradinggoose/lib/yjs/workflow-session.ts index fe59c73c5..dd39b33d5 100644 --- a/apps/tradinggoose/lib/yjs/workflow-session.ts +++ b/apps/tradinggoose/lib/yjs/workflow-session.ts @@ -487,12 +487,15 @@ export interface PersistedDocState { parallels: Record variables: Record lastSaved: number + isDeployed?: boolean + deployedAt?: string } export function extractPersistedStateFromDoc(doc: Y.Doc): PersistedDocState { const snapshot = readWorkflowSnapshot(doc) const variables = getVariablesSnapshot(doc) const lastSaved = resolveStoredDateValue(snapshot.lastSaved)?.getTime() ?? Date.now() + const deployedAt = resolveStoredDateValue(snapshot.deployedAt)?.toISOString() return { ...(snapshot.direction !== undefined ? { direction: snapshot.direction } : {}), @@ -502,5 +505,7 @@ export function extractPersistedStateFromDoc(doc: Y.Doc): PersistedDocState { parallels: snapshot.parallels || {}, variables: variables || {}, lastSaved, + ...(snapshot.isDeployed !== undefined ? { isDeployed: snapshot.isDeployed } : {}), + ...(deployedAt ? { deployedAt } : {}), } } From 5a4b50ba4ba644612df0a3b005b069b59a65de71 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 12:43:05 -0600 Subject: [PATCH 051/284] fix(mcp): use shared personal api key auth Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/copilot/mcp/route.test.ts | 21 ++++--- .../tradinggoose/app/api/copilot/mcp/route.ts | 5 +- apps/tradinggoose/lib/mcp/auth.ts | 61 +++---------------- 3 files changed, 21 insertions(+), 66 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index 090d9cf42..0066b6a11 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -6,14 +6,14 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockAuthenticateMcpApiKey, + mockAuthenticateApiKeyFromHeader, mockGetCopilotRuntimeToolManifest, mockGetServerToolIds, mockGetUserWorkspaces, mockRouteExecution, mockUpdateApiKeyLastUsed, } = vi.hoisted(() => ({ - mockAuthenticateMcpApiKey: vi.fn(), + mockAuthenticateApiKeyFromHeader: vi.fn(), mockGetCopilotRuntimeToolManifest: vi.fn(), mockGetServerToolIds: vi.fn(), mockGetUserWorkspaces: vi.fn(), @@ -22,13 +22,10 @@ const { })) vi.mock('@/lib/api-key/service', () => ({ + authenticateApiKeyFromHeader: (...args: unknown[]) => mockAuthenticateApiKeyFromHeader(...args), updateApiKeyLastUsed: (...args: unknown[]) => mockUpdateApiKeyLastUsed(...args), })) -vi.mock('@/lib/mcp/auth', () => ({ - authenticateMcpApiKey: (...args: unknown[]) => mockAuthenticateMcpApiKey(...args), -})) - vi.mock('@/lib/copilot/runtime-tool-manifest', () => ({ getCopilotRuntimeToolManifest: (...args: unknown[]) => mockGetCopilotRuntimeToolManifest(...args), })) @@ -56,7 +53,7 @@ function createMcpRequest(body: unknown, authorization = 'Bearer sk-tradinggoose describe('Copilot MCP route', () => { beforeEach(() => { vi.resetAllMocks() - mockAuthenticateMcpApiKey.mockResolvedValue({ + mockAuthenticateApiKeyFromHeader.mockResolvedValue({ success: true, userId: 'user-1', keyId: 'key-1', @@ -94,7 +91,7 @@ describe('Copilot MCP route', () => { expect(response.status).toBe(401) expect(body.error.message).toBe('Bearer token required') - expect(mockAuthenticateMcpApiKey).not.toHaveBeenCalled() + expect(mockAuthenticateApiKeyFromHeader).not.toHaveBeenCalled() }) it('returns initialize metadata with authenticated workspace context', async () => { @@ -104,7 +101,9 @@ describe('Copilot MCP route', () => { const body = await response.json() expect(response.headers.get('MCP-Protocol-Version')).toBe('2025-03-26') - expect(mockAuthenticateMcpApiKey).toHaveBeenCalledWith('sk-tradinggoose-test') + expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('sk-tradinggoose-test', { + keyTypes: ['personal'], + }) expect(mockUpdateApiKeyLastUsed).toHaveBeenCalledWith('key-1') expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ userId: 'user-1', autoCreate: false }) expect(body.result.capabilities).toEqual({ tools: {} }) @@ -124,7 +123,9 @@ describe('Copilot MCP route', () => { ) expect(response.status).toBe(200) - expect(mockAuthenticateMcpApiKey).toHaveBeenCalledWith('sk-lowercase') + expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('sk-lowercase', { + keyTypes: ['personal'], + }) }) it('lists only executable server copilot tools', async () => { diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index 1f96cbc64..edbbc17a9 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -1,8 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' -import { updateApiKeyLastUsed } from '@/lib/api-key/service' +import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' import { getCopilotRuntimeToolManifest } from '@/lib/copilot/runtime-tool-manifest' import { getServerToolIds, routeExecution } from '@/lib/copilot/tools/server/router' -import { authenticateMcpApiKey } from '@/lib/mcp/auth' import { getUserWorkspaces } from '@/lib/workspaces/service' export const dynamic = 'force-dynamic' @@ -73,7 +72,7 @@ async function authenticateCopilotMcpRequest( return { error: 'Bearer token required' } } - const auth = await authenticateMcpApiKey(token) + const auth = await authenticateApiKeyFromHeader(token, { keyTypes: ['personal'] }) if (!auth.success || !auth.userId) { return { error: 'Invalid TradingGoose personal API key' } } diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 314bd2bbe..c924825ad 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -3,12 +3,10 @@ import { db } from '@tradinggoose/db' import { apiKey, verification } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' -import { authenticateApiKey } from '@/lib/api-key/auth' -import { decryptApiKey, encryptApiKey } from '@/lib/api-key/service' +import { createApiKeyMaterial, decryptApiKey } from '@/lib/api-key/service' const DEVICE_LOGIN_TTL_MS = 10 * 60 * 1000 const DEVICE_LOGIN_PREFIX = 'mcp:' -const DEVICE_PERSONAL_API_KEY_PREFIX = 'sk-tradinggoose-pat.' const POLL_INTERVAL_SECONDS = 2 type PendingDeviceLogin = { @@ -26,13 +24,11 @@ type ApprovedDeviceLogin = { approvedAt: string userId: string keyId?: string - apiKeyHash?: string apiKeyEncrypted?: string } type IssuedDeviceLogin = ApprovedDeviceLogin & { keyId: string - apiKeyHash: string apiKeyEncrypted: string } @@ -69,20 +65,10 @@ function hashValue(value: string) { return createHash('sha256').update(value).digest('hex') } -function createDevicePersonalApiKey(keyId: string) { - return `${DEVICE_PERSONAL_API_KEY_PREFIX}${keyId}.${randomBytes(32).toString('base64url')}` -} - -function readDevicePersonalApiKeyId(value: string) { - const match = value.match(/^sk-tradinggoose-pat\.([A-Za-z0-9_-]+)\.[A-Za-z0-9_-]+$/) - return match?.[1] ?? null -} - function isIssuedDeviceLogin(state: DeviceLoginState): state is IssuedDeviceLogin { return ( state.status === 'approved' && typeof state.keyId === 'string' && - typeof state.apiKeyHash === 'string' && typeof state.apiKeyEncrypted === 'string' ) } @@ -103,14 +89,9 @@ function parseDeviceLoginState(value: string): DeviceLoginState | null { ) { return parsed as PendingDeviceLogin } - const approvedHasNoKey = - parsed.keyId === undefined && - parsed.apiKeyHash === undefined && - parsed.apiKeyEncrypted === undefined + const approvedHasNoKey = parsed.keyId === undefined && parsed.apiKeyEncrypted === undefined const approvedHasIssuedKey = - typeof parsed.keyId === 'string' && - typeof parsed.apiKeyHash === 'string' && - typeof parsed.apiKeyEncrypted === 'string' + typeof parsed.keyId === 'string' && typeof parsed.apiKeyEncrypted === 'string' if ( parsed.status === 'approved' && typeof parsed.createdAt === 'string' && @@ -177,13 +158,14 @@ async function issueDeviceLoginPersonalApiKey( approvedState: ApprovedDeviceLogin ): Promise { const keyId = nanoid() - const plainKey = createDevicePersonalApiKey(keyId) - const encryptedKey = (await encryptApiKey(plainKey)).encrypted + const { key: plainKey, encryptedKey } = await createApiKeyMaterial(true) + if (!encryptedKey) { + throw new Error('Failed to create MCP personal API key') + } const nextState = { ...approvedState, keyId, - apiKeyHash: hashValue(plainKey), apiKeyEncrypted: encryptedKey, } satisfies IssuedDeviceLogin @@ -208,7 +190,7 @@ async function issueDeviceLoginPersonalApiKey( id: nextState.keyId, userId: nextState.userId, workspaceId: null, - name: `TradingGoose Personal Access ${now.toISOString()}`, + name: `TradingGoose MCP Access ${now.toISOString()}`, key: nextState.apiKeyEncrypted, type: 'personal', createdAt: now, @@ -409,30 +391,3 @@ export async function cancelMcpDeviceLogin({ return { status: 'cancelled' } } - -export async function authenticateMcpApiKey(token: string) { - const keyId = readDevicePersonalApiKeyId(token) - if (!keyId) { - return { success: false as const } - } - - const [storedKey] = await db - .select({ - id: apiKey.id, - userId: apiKey.userId, - key: apiKey.key, - expiresAt: apiKey.expiresAt, - }) - .from(apiKey) - .where(and(eq(apiKey.id, keyId), eq(apiKey.type, 'personal'))) - .limit(1) - - if (!storedKey || (storedKey.expiresAt && storedKey.expiresAt < new Date())) { - return { success: false as const } - } - - const success = storedKey.key === token || (await authenticateApiKey(token, storedKey.key)) - return success - ? { success: true as const, userId: storedKey.userId, keyId: storedKey.id } - : { success: false as const } -} From b87fc13968ae27822e4dc22d071121d821862211 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 12:43:22 -0600 Subject: [PATCH 052/284] fix(workflows): preserve variables during direct yjs persistence Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../workflows/[id]/duplicate/route.test.ts | 2 +- .../api/workflows/[id]/state/route.test.ts | 76 ++++------ .../app/api/workflows/[id]/state/route.ts | 41 +----- .../app/api/workflows/route.test.ts | 2 +- .../yjs/server/apply-workflow-state.test.ts | 135 ++++++++++++++++++ .../lib/yjs/server/apply-workflow-state.ts | 55 ++++++- apps/tradinggoose/lib/yjs/workflow-session.ts | 19 +++ .../tradinggoose/socket-server/routes/http.ts | 28 +--- 8 files changed, 240 insertions(+), 118 deletions(-) create mode 100644 apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts index d120e5b3b..a35744881 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts @@ -215,7 +215,7 @@ describe('Workflow Duplicate API Route', () => { lastSaved: Date.now(), source: 'db', }) - applyWorkflowStateMock.mockRejectedValueOnce(new Error('socket bridge unavailable')) + applyWorkflowStateMock.mockRejectedValueOnce(new Error('canonical Yjs unavailable')) const { POST } = await import('@/app/api/workflows/[id]/duplicate/route') const response = await POST( diff --git a/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts index 940c98d98..d6beccf2d 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts @@ -5,7 +5,6 @@ import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('Workflow State API Route', () => { - let loadWorkflowStateFromYjsMock: ReturnType let applyWorkflowStateMock: ReturnType const createRequest = (body: Record) => @@ -38,7 +37,6 @@ describe('Workflow State API Route', () => { vi.resetModules() vi.clearAllMocks() - loadWorkflowStateFromYjsMock = vi.fn().mockResolvedValue(null) applyWorkflowStateMock = vi.fn().mockResolvedValue(undefined) vi.doMock('@/lib/auth', () => ({ @@ -66,6 +64,7 @@ describe('Workflow State API Route', () => { session: { user: { id: 'user-id' } }, workflow: { id: 'workflow-id', + name: 'Workflow', workspaceId: 'workspace-id', variables: { 'db-var': { @@ -90,7 +89,6 @@ describe('Workflow State API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ ensureUniqueBlockIds: vi.fn(async (_workflowId: string, state: any) => state), ensureUniqueEdgeIds: vi.fn(async (_workflowId: string, state: any) => state), - loadWorkflowStateFromYjs: loadWorkflowStateFromYjsMock, toISOStringOrUndefined: vi.fn((value: string | number | Date | null | undefined) => value == null ? undefined : new Date(value).toISOString() ), @@ -112,24 +110,7 @@ describe('Workflow State API Route', () => { vi.clearAllMocks() }) - it('falls back to authoritative Yjs variables when the request body omits them', async () => { - loadWorkflowStateFromYjsMock.mockResolvedValueOnce({ - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - variables: { - 'live-var': { - id: 'live-var', - workflowId: 'workflow-id', - name: 'liveVar', - type: 'plain', - value: 'live value', - }, - }, - lastSaved: Date.now(), - }) - + it('preserves current Yjs variables when the request body omits them', async () => { const { PUT } = await import('@/app/api/workflows/[id]/state/route') const response = await PUT(createRequest(validStateBody), { params: Promise.resolve({ id: 'workflow-id' }), @@ -139,36 +120,39 @@ describe('Workflow State API Route', () => { expect(applyWorkflowStateMock).toHaveBeenCalledWith( 'workflow-id', expect.any(Object), - { - 'live-var': expect.objectContaining({ - name: 'liveVar', - value: 'live value', - }), - }, - undefined + undefined, + 'Workflow' ) }) - it('rejects saves without request or Yjs variables', async () => { - const { PUT } = await import('@/app/api/workflows/[id]/state/route') - const response = await PUT(createRequest(validStateBody), { - params: Promise.resolve({ id: 'workflow-id' }), - }) - - expect(response.status).toBe(409) - expect(applyWorkflowStateMock).not.toHaveBeenCalled() - }) - - it('rejects saves when authoritative Yjs variable lookup fails', async () => { - loadWorkflowStateFromYjsMock.mockRejectedValueOnce(new Error('socket bridge unavailable')) - + it('replaces variables when the request body includes them', async () => { const { PUT } = await import('@/app/api/workflows/[id]/state/route') - const response = await PUT(createRequest(validStateBody), { - params: Promise.resolve({ id: 'workflow-id' }), - }) + const variables = { + 'request-var': { + id: 'request-var', + workflowId: 'workflow-id', + name: 'requestVar', + type: 'plain', + value: 'request value', + }, + } + const response = await PUT( + createRequest({ + ...validStateBody, + variables, + }), + { + params: Promise.resolve({ id: 'workflow-id' }), + } + ) - expect(response.status).toBe(409) - expect(applyWorkflowStateMock).not.toHaveBeenCalled() + expect(response.status).toBe(200) + expect(applyWorkflowStateMock).toHaveBeenCalledWith( + 'workflow-id', + expect.any(Object), + variables, + 'Workflow' + ) }) it('returns an error when workflow state apply fails', async () => { diff --git a/apps/tradinggoose/app/api/workflows/[id]/state/route.ts b/apps/tradinggoose/app/api/workflows/[id]/state/route.ts index a6f80afa5..11074de6c 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/state/route.ts @@ -6,7 +6,6 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/custom-tools-persi import { ensureUniqueBlockIds, ensureUniqueEdgeIds, - loadWorkflowStateFromYjs, toISOStringOrUndefined, } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' @@ -113,11 +112,6 @@ const WorkflowStateSchema = z.object({ variables: z.record(z.any()).optional(), }) -type ResolvedVariables = { - value: Record | undefined - source: 'request' | 'yjs' | 'unavailable' -} - /** * PUT /api/workflows/[id]/state * Save complete workflow state to Yjs and materialize derived database tables. @@ -184,46 +178,13 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ deployedAt: toISOStringOrUndefined(state.deployedAt), } - // Preserve variables only from the request body or the authoritative Yjs - // workflow state loader. Falling back to the workflow row is unsafe when - // Next.js and the socket server run as separate processes because the row - // may lag behind newer variable edits that exist only in the socket - // server's live Yjs doc. - let resolvedVariables: ResolvedVariables = { - value: state.variables, - source: state.variables === undefined ? 'unavailable' : 'request', - } - if (resolvedVariables.value === undefined) { - try { - const yjsState = await loadWorkflowStateFromYjs(workflowId) - if (yjsState) { - resolvedVariables = { - value: yjsState.variables, - source: 'yjs', - } - } - } catch (error) { - logger.warn( - `[${requestId}] Skipping authoritative variable lookup for ${workflowId} because the Yjs bridge was unavailable`, - { error } - ) - } - } - - if (resolvedVariables.source === 'unavailable') { - return NextResponse.json( - { error: 'Failed to save workflow state', details: 'Current workflow variables are unavailable' }, - { status: 409 } - ) - } - const stateWithUniqueBlockIds = await ensureUniqueBlockIds(workflowId, workflowState as any) const persistedWorkflowState = await ensureUniqueEdgeIds(workflowId, stateWithUniqueBlockIds) await applyWorkflowState( workflowId, persistedWorkflowState as WorkflowSnapshot, - resolvedVariables.value, + state.variables, workflowData.name ) diff --git a/apps/tradinggoose/app/api/workflows/route.test.ts b/apps/tradinggoose/app/api/workflows/route.test.ts index 36dc9bb70..7685e7115 100644 --- a/apps/tradinggoose/app/api/workflows/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/route.test.ts @@ -186,7 +186,7 @@ describe('Workflow API Route', () => { }) it('rolls back the workflow row when workflow state apply fails', async () => { - applyWorkflowStateMock.mockRejectedValueOnce(new Error('socket unavailable')) + applyWorkflowStateMock.mockRejectedValueOnce(new Error('canonical Yjs unavailable')) const { POST } = await import('@/app/api/workflows/route') const response = await POST( diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts new file mode 100644 index 000000000..7491ce7b7 --- /dev/null +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment node + */ + +import * as Y from 'yjs' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + MockSocketServerBridgeError, + mockApplyWorkflowStateInSocketServer, + mockDbUpdate, + mockGetState, + mockGetRedisStorageMode, + mockSaveWorkflowToNormalizedTables, + mockStoreCanonicalState, + mockUpdateSet, + mockUpdateWhere, +} = vi.hoisted(() => { + class MockSocketServerBridgeError extends Error { + constructor() { + super('Socket server bridge failed') + this.name = 'SocketServerBridgeError' + } + } + + return { + MockSocketServerBridgeError, + mockApplyWorkflowStateInSocketServer: vi.fn(), + mockDbUpdate: vi.fn(), + mockGetState: vi.fn(), + mockGetRedisStorageMode: vi.fn(), + mockSaveWorkflowToNormalizedTables: vi.fn(), + mockStoreCanonicalState: vi.fn(), + mockUpdateSet: vi.fn(), + mockUpdateWhere: vi.fn(), + } +}) + +vi.mock('@tradinggoose/db', () => ({ + db: { + update: mockDbUpdate, + }, + workflow: { + id: 'workflow.id', + }, +})) + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((field, value) => ({ field, value })), +})) + +vi.mock('@/lib/redis', () => ({ + getRedisStorageMode: mockGetRedisStorageMode, +})) + +vi.mock('@/lib/workflows/db-helpers', () => ({ + saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, +})) + +vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ + applyWorkflowStateInSocketServer: mockApplyWorkflowStateInSocketServer, + SocketServerBridgeError: MockSocketServerBridgeError, +})) + +vi.mock('@/socket-server/yjs/persistence', () => ({ + getState: mockGetState, + storeCanonicalState: mockStoreCanonicalState, +})) + +describe('applyWorkflowState', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetRedisStorageMode.mockReturnValue('redis') + mockApplyWorkflowStateInSocketServer.mockResolvedValue(undefined) + mockGetState.mockResolvedValue(null) + mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) + mockStoreCanonicalState.mockResolvedValue(undefined) + mockUpdateWhere.mockResolvedValue(undefined) + mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }) + mockDbUpdate.mockReturnValue({ set: mockUpdateSet }) + }) + + it('preserves canonical Yjs variables during direct graph-only persistence', async () => { + mockApplyWorkflowStateInSocketServer.mockRejectedValueOnce(new TypeError('fetch failed')) + + const { applyWorkflowState } = await import('./apply-workflow-state') + const { extractPersistedStateFromDoc, getMetadataMap, setVariables } = await import( + '@/lib/yjs/workflow-session' + ) + + const existingDoc = new Y.Doc() + setVariables( + existingDoc, + { var1: { id: 'var1', workflowId: 'workflow-1', name: 'token', value: 'secret' } }, + 'test' + ) + mockGetState.mockResolvedValueOnce(Y.encodeStateAsUpdate(existingDoc)) + existingDoc.destroy() + + await applyWorkflowState( + 'workflow-1', + { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + }, + undefined, + 'Workflow Name' + ) + + expect(mockStoreCanonicalState).toHaveBeenCalledOnce() + expect(mockStoreCanonicalState.mock.calls[0][0]).toBe('workflow-1') + + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, mockStoreCanonicalState.mock.calls[0][1] as Uint8Array) + expect(extractPersistedStateFromDoc(doc)).toMatchObject({ + variables: { + var1: expect.objectContaining({ value: 'secret' }), + }, + }) + expect(getMetadataMap(doc).get('entityName')).toBe('Workflow Name') + } finally { + doc.destroy() + } + + expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledOnce() + expect(mockUpdateSet).toHaveBeenCalledWith( + expect.not.objectContaining({ + variables: expect.anything(), + }) + ) + }) +}) diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index 7b937f5b0..ada9f71a6 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -1,8 +1,55 @@ import { db, workflow } from '@tradinggoose/db' import { eq } from 'drizzle-orm' +import * as Y from 'yjs' +import { getRedisStorageMode } from '@/lib/redis' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' -import { applyWorkflowStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' -import { createWorkflowSnapshot, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { + applyWorkflowStateInSocketServer, + SocketServerBridgeError, +} from '@/lib/yjs/server/snapshot-bridge' +import { + createWorkflowSnapshot, + replaceWorkflowDocumentState, + type WorkflowSnapshot, +} from '@/lib/yjs/workflow-session' +import { getState, storeCanonicalState } from '@/socket-server/yjs/persistence' + +async function storeWorkflowStateDirectly( + workflowId: string, + workflowState: WorkflowSnapshot, + variables?: Record, + entityName?: string +) { + const doc = new Y.Doc() + try { + const existingState = await getState(workflowId) + if (existingState) { + Y.applyUpdate(doc, existingState) + } + + replaceWorkflowDocumentState(doc, workflowState, variables, entityName) + await storeCanonicalState(workflowId, Y.encodeStateAsUpdate(doc)) + } finally { + doc.destroy() + } +} + +async function applyWorkflowStateToYjs( + workflowId: string, + workflowState: WorkflowSnapshot, + variables?: Record, + entityName?: string +) { + try { + await applyWorkflowStateInSocketServer(workflowId, workflowState, variables, entityName) + } catch (error) { + if (error instanceof SocketServerBridgeError || getRedisStorageMode() !== 'redis') { + throw error + } + + await storeWorkflowStateDirectly(workflowId, workflowState, variables, entityName) + } +} export async function applyWorkflowState( workflowId: string, @@ -16,7 +63,7 @@ export async function applyWorkflowState( lastSaved: syncedAt.toISOString(), }) - await applyWorkflowStateInSocketServer(workflowId, appliedWorkflowState, variables, entityName) + await applyWorkflowStateToYjs(workflowId, appliedWorkflowState, variables, entityName) const saveResult = await saveWorkflowToNormalizedTables(workflowId, appliedWorkflowState) if (!saveResult.success) { @@ -39,7 +86,7 @@ export async function applyWorkflowEntityName( variables: Record, entityName: string ): Promise { - await applyWorkflowStateInSocketServer(workflowId, workflowState, variables, entityName) + await applyWorkflowStateToYjs(workflowId, workflowState, variables, entityName) const [updatedWorkflow] = await db .update(workflow) diff --git a/apps/tradinggoose/lib/yjs/workflow-session.ts b/apps/tradinggoose/lib/yjs/workflow-session.ts index dd39b33d5..b53c1a25f 100644 --- a/apps/tradinggoose/lib/yjs/workflow-session.ts +++ b/apps/tradinggoose/lib/yjs/workflow-session.ts @@ -371,6 +371,25 @@ export function setWorkflowState(doc: Y.Doc, state: WorkflowSnapshot, origin?: s }, origin ?? YJS_ORIGINS.SYSTEM) } +export function replaceWorkflowDocumentState( + doc: Y.Doc, + workflowState: WorkflowSnapshot, + variables?: Record, + entityName?: string +): void { + setWorkflowState(doc, workflowState, YJS_ORIGINS.SYSTEM) + + if (variables !== undefined) { + setVariables(doc, variables, YJS_ORIGINS.SYSTEM) + } + + doc.transact(() => { + const metadata = getMetadataMap(doc) + metadata.delete('reseededFromCanonical') + if (entityName) metadata.set('entityName', entityName) + }, YJS_ORIGINS.SYSTEM) +} + // --------------------------------------------------------------------------- // Block mutation helpers // --------------------------------------------------------------------------- diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index ce6e9ff18..7e161460c 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -12,12 +12,7 @@ import { getRuntimeStateFromUpdate, } from '@/lib/yjs/server/bootstrap-review-target' import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' -import { - getMetadataMap as getWorkflowMetadataMap, - setVariables, - setWorkflowState, - type WorkflowSnapshot, -} from '@/lib/yjs/workflow-session' +import { replaceWorkflowDocumentState, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' import { getMonitorRuntimeLockHealth } from '@/socket-server/monitor-runtime-lock' import { deleteSession, @@ -214,25 +209,6 @@ function parseApplyEntityStateRequest(body: unknown): ApplyEntityStateRequest { } } -function replaceWorkflowDocState( - doc: Y.Doc, - workflowState: WorkflowSnapshot, - variables?: Record, - entityName?: string -): void { - setWorkflowState(doc, workflowState, YJS_ORIGINS.SYSTEM) - - if (variables !== undefined) { - setVariables(doc, variables, YJS_ORIGINS.SYSTEM) - } - - doc.transact(() => { - const metadata = getWorkflowMetadataMap(doc) - metadata.delete('reseededFromCanonical') - if (entityName) metadata.set('entityName', entityName) - }, YJS_ORIGINS.SYSTEM) -} - function clearSessionReseededFromCanonical(doc: Y.Doc): void { doc.transact(() => { doc.getMap('metadata').delete('reseededFromCanonical') @@ -251,7 +227,7 @@ async function handleInternalYjsWorkflowApplyRequest( const doc = liveDoc ?? new Y.Doc() try { - replaceWorkflowDocState(doc, body.workflowState, body.variables, body.entityName) + replaceWorkflowDocumentState(doc, body.workflowState, body.variables, body.entityName) await storeCanonicalState(workflowId, Y.encodeStateAsUpdate(doc)) } finally { if (!liveDoc) doc.destroy() From 4d0241dd2e7b2c74309bb14083583dbfbd89ae71 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 13:19:30 -0600 Subject: [PATCH 053/284] fix(workflows): persist workflow names through Yjs state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/workflows/[id]/route.test.ts | 21 +++++++++++ .../app/api/workflows/[id]/route.ts | 37 ++++++++++++++----- apps/tradinggoose/lib/workflows/db-helpers.ts | 3 ++ .../lib/yjs/server/apply-entity-state.ts | 26 ++++++++++++- .../lib/yjs/server/bootstrap-review-target.ts | 9 ++++- 5 files changed, 84 insertions(+), 12 deletions(-) diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index 33edb2a0b..26bae6c41 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -20,6 +20,15 @@ describe('Workflow By ID API Route', () => { const mockReadWorkflowAccessContext = vi.fn() const mockDeleteYjsSessionInSocketServer = vi.fn() const mockLoadWorkflowState = vi.fn() + const mockApplyWorkflowEntityName = vi.fn() + const mockWorkflowRenameState = { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + lastSaved: Date.parse('2026-06-21T00:00:00.000Z'), + } beforeEach(() => { vi.resetModules() @@ -67,8 +76,18 @@ describe('Workflow By ID API Route', () => { mockReadWorkflowAccessContext.mockReset() mockDeleteYjsSessionInSocketServer.mockReset() mockLoadWorkflowState.mockReset() + mockApplyWorkflowEntityName.mockReset() mockDeleteYjsSessionInSocketServer.mockResolvedValue(undefined) mockLoadWorkflowState.mockResolvedValue(null) + mockApplyWorkflowEntityName.mockResolvedValue({ + id: 'workflow-123', + name: 'Updated Workflow', + workspaceId: null, + }) + + vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ + applyWorkflowEntityName: mockApplyWorkflowEntityName, + })) vi.doMock('@/lib/yjs/server/snapshot-bridge', () => ({ deleteYjsSessionInSocketServer: mockDeleteYjsSessionInSocketServer, @@ -613,6 +632,7 @@ describe('Workflow By ID API Route', () => { isOwner: true, isWorkspaceOwner: false, }) + mockLoadWorkflowState.mockResolvedValueOnce(mockWorkflowRenameState) vi.doMock('@tradinggoose/db', () => ({ db: { @@ -666,6 +686,7 @@ describe('Workflow By ID API Route', () => { isOwner: false, isWorkspaceOwner: false, }) + mockLoadWorkflowState.mockResolvedValueOnce(mockWorkflowRenameState) vi.doMock('@tradinggoose/db', () => ({ db: { diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index 788f5a912..2e8e7ec17 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -11,6 +11,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { loadWorkflowState } from '@/lib/workflows/db-helpers' import { readWorkflowAccessContext, readWorkflowById } from '@/lib/workflows/utils' +import { applyWorkflowEntityName } from '@/lib/yjs/server/apply-workflow-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' @@ -367,22 +368,40 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - // Build update object const updateData: any = { updatedAt: new Date() } - if (updates.name !== undefined) updateData.name = updates.name if (updates.description !== undefined) updateData.description = updates.description if (updates.folderId !== undefined) updateData.folderId = updates.folderId - // Update the workflow - const [updatedWorkflow] = await db - .update(workflow) - .set(updateData) - .where(eq(workflow.id, workflowId)) - .returning() + let updatedWorkflow = null + if (updates.name !== undefined) { + const workflowState = await loadWorkflowState(workflowId) + if (!workflowState) { + logger.warn(`[${requestId}] Workflow ${workflowId} is missing saved state for rename`) + return NextResponse.json({ error: 'Workflow state is missing' }, { status: 409 }) + } + + updatedWorkflow = await applyWorkflowEntityName( + workflowId, + createWorkflowSnapshot({ + ...workflowState, + lastSaved: new Date(workflowState.lastSaved).toISOString(), + }), + workflowState.variables, + updates.name + ) + } + + if (!updatedWorkflow || updates.description !== undefined || updates.folderId !== undefined) { + ;[updatedWorkflow] = await db + .update(workflow) + .set(updateData) + .where(eq(workflow.id, workflowId)) + .returning() + } const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully updated workflow ${workflowId} in ${elapsed}ms`, { - updates: updateData, + updates, }) return NextResponse.json({ workflow: updatedWorkflow }, { status: 200 }) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index f95c2100e..bd79bdb74 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -75,6 +75,7 @@ const sanitizeBlockLayout = (layout: unknown): BlockState['layout'] => { } export type PersistedWorkflowState = { + name?: string | null direction?: WorkflowDirection blocks: Record edges: any[] @@ -152,6 +153,7 @@ export async function loadWorkflowStateFromSavedTables( const [workflowRow, normalizedState] = await Promise.all([ db .select({ + name: workflow.name, variables: workflow.variables, updatedAt: workflow.updatedAt, isDeployed: workflow.isDeployed, @@ -168,6 +170,7 @@ export async function loadWorkflowStateFromSavedTables( } const savedState = { + name: row.name, blocks: normalizedState?.blocks ?? {}, edges: normalizedState?.edges ?? [], loops: normalizedState?.loops ?? {}, diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index 6e7c588fb..382562649 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -7,8 +7,15 @@ import { skill, } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' +import * as Y from 'yjs' +import { getRedisStorageMode } from '@/lib/redis' +import { seedEntitySession } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' -import { applyEntityStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { + applyEntityStateInSocketServer, + SocketServerBridgeError, +} from '@/lib/yjs/server/snapshot-bridge' +import { storeCanonicalState } from '@/socket-server/yjs/persistence' function parseObjectJson(value: unknown, fieldName: string): Record { const parsed = JSON.parse(String(value ?? '')) @@ -114,6 +121,21 @@ export async function applySavedEntityState( entityId: string, fields: Record ): Promise { - await applyEntityStateInSocketServer(entityId, entityKind, fields) + try { + await applyEntityStateInSocketServer(entityId, entityKind, fields) + } catch (error) { + if (error instanceof SocketServerBridgeError || getRedisStorageMode() !== 'redis') { + throw error + } + + const doc = new Y.Doc() + try { + seedEntitySession(doc, { entityKind, payload: fields }) + await storeCanonicalState(entityId, Y.encodeStateAsUpdate(doc)) + } finally { + doc.destroy() + } + } + await persistSavedEntityState(entityKind, entityId, fields) } diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index f5694c707..6ecb79b18 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -125,11 +125,13 @@ async function bootstrapSavedEntityFromDb( const doc = new Y.Doc() try { + let workflowName: string | null | undefined if (descriptor.entityKind === 'workflow') { const workflowState = await loadWorkflowStateFromSavedTables(descriptor.entityId) if (!workflowState) { throw new ReviewTargetBootstrapError(404, 'Workflow not found') } + workflowName = workflowState.name setWorkflowState( doc, @@ -160,7 +162,12 @@ async function bootstrapSavedEntityFromDb( }) } - getMetadataMap(doc).set('bootstrap-touch', Date.now()) + const metadata = getMetadataMap(doc) + metadata.set('bootstrap-touch', Date.now()) + metadata.set('reseededFromCanonical', true) + if (workflowName) { + metadata.set('entityName', workflowName) + } const state = Y.encodeStateAsUpdate(doc) await storeCanonicalState(descriptor.yjsSessionId, state) From fc436b4cf6e7d0c659e4c890eff62a7bf135cef6 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 14:14:24 -0600 Subject: [PATCH 054/284] fix(copilot): read saved entities from bootstrapped Yjs state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/copilot/process-contents.test.ts | 35 ++++++------------- .../lib/copilot/process-contents.ts | 33 ++++------------- .../copilot/tools/server/entities/shared.ts | 4 +-- .../lib/yjs/server/bootstrap-review-target.ts | 28 ++++++++++++++- 4 files changed, 45 insertions(+), 55 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/process-contents.test.ts b/apps/tradinggoose/lib/copilot/process-contents.test.ts index 1954a2527..cf483bcec 100644 --- a/apps/tradinggoose/lib/copilot/process-contents.test.ts +++ b/apps/tradinggoose/lib/copilot/process-contents.test.ts @@ -8,8 +8,8 @@ const mockGetBlocksMetadataExecute = vi.fn() const mockVerifyWorkflowAccess = vi.fn() const mockVerifyReviewTargetAccess = vi.fn() const mockReadBootstrappedReviewTargetSnapshot = vi.fn() +const mockReadBootstrappedSavedEntityFields = vi.fn() const mockReadWorkflowSnapshot = vi.fn() -const mockGetEntityFields = vi.fn() const mockSanitizeForCopilot = vi.fn((value) => value) const mockAnd = vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })) const mockEq = vi.fn((field: unknown, value: unknown) => ({ field, type: 'eq', value })) @@ -94,16 +94,13 @@ vi.mock('@/lib/copilot/tools/server/blocks/get-blocks-metadata', () => ({ vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ readBootstrappedReviewTargetSnapshot: mockReadBootstrappedReviewTargetSnapshot, + readBootstrappedSavedEntityFields: mockReadBootstrappedSavedEntityFields, })) vi.mock('@/lib/yjs/workflow-session', () => ({ readWorkflowSnapshot: mockReadWorkflowSnapshot, })) -vi.mock('@/lib/yjs/entity-session', () => ({ - getEntityFields: mockGetEntityFields, -})) - vi.mock('@/lib/workflows/json-sanitizer', () => ({ sanitizeForCopilot: mockSanitizeForCopilot, })) @@ -115,8 +112,8 @@ describe('processContextsServer', () => { mockVerifyWorkflowAccess.mockReset() mockVerifyReviewTargetAccess.mockReset() mockReadBootstrappedReviewTargetSnapshot.mockReset() + mockReadBootstrappedSavedEntityFields.mockReset() mockReadWorkflowSnapshot.mockReset() - mockGetEntityFields.mockReset() mockSanitizeForCopilot.mockClear() mockAnd.mockClear() mockEq.mockClear() @@ -183,15 +180,7 @@ describe('processContextsServer', () => { }) it('hydrates current entity contexts from Yjs', async () => { - const doc = new Y.Doc() - const snapshotBase64 = Buffer.from(Y.encodeStateAsUpdate(doc)).toString('base64') - doc.destroy() - mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue({ - snapshotBase64, - descriptor: {}, - runtime: { docState: 'active', replaySafe: false, reseededFromCanonical: false }, - }) - mockGetEntityFields.mockReturnValue({ + mockReadBootstrappedSavedEntityFields.mockResolvedValue({ name: 'Canonical Skill', description: 'Canonical description', content: 'Canonical content', @@ -222,15 +211,11 @@ describe('processContextsServer', () => { }, 'read' ) - expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - entityKind: 'skill', - entityId: 'skill-1', - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: 'skill-1', - }) - expect(mockGetEntityFields).toHaveBeenCalledWith(expect.any(Y.Doc), 'skill') + expect(mockReadBootstrappedSavedEntityFields).toHaveBeenCalledWith( + 'skill', + 'skill-1', + 'workspace-1' + ) expect(result).toEqual([ { type: 'current_skill', @@ -272,7 +257,7 @@ describe('processContextsServer', () => { ) expect(mockVerifyReviewTargetAccess).toHaveBeenCalled() - expect(mockReadBootstrappedReviewTargetSnapshot).not.toHaveBeenCalled() + expect(mockReadBootstrappedSavedEntityFields).not.toHaveBeenCalled() expect(result).toEqual([]) }) diff --git a/apps/tradinggoose/lib/copilot/process-contents.ts b/apps/tradinggoose/lib/copilot/process-contents.ts index 02b950823..949e0b1b3 100644 --- a/apps/tradinggoose/lib/copilot/process-contents.ts +++ b/apps/tradinggoose/lib/copilot/process-contents.ts @@ -21,8 +21,10 @@ import { createLogger } from '@/lib/logs/console/logger' import { buildWorkspaceAccessScope } from '@/lib/permissions/utils' import { escapeRegExp } from '@/lib/utils' import { sanitizeForCopilot } from '@/lib/workflows/json-sanitizer' -import { getEntityFields } from '@/lib/yjs/entity-session' -import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' +import { + readBootstrappedReviewTargetSnapshot, + readBootstrappedSavedEntityFields, +} from '@/lib/yjs/server/bootstrap-review-target' import { readWorkflowSnapshot, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' import type { ChatContext } from '@/stores/copilot/types' import { readCopilotWorkspaceEntityContext } from '@/widgets/widgets/copilot/workspace-entities' @@ -191,7 +193,7 @@ async function processEntityContext(params: { return null } - const fields = await readCopilotEntityFieldsFromYjs( + const fields = await readBootstrappedSavedEntityFields( params.entityKind, params.entityId, access.workspaceId @@ -221,29 +223,6 @@ async function processEntityContext(params: { } } -async function readCopilotEntityFieldsFromYjs( - entityKind: 'skill' | 'indicator' | 'custom_tool' | 'knowledge_base' | 'mcp_server', - entityId: string, - workspaceId: string -): Promise> { - const fields = await readBootstrappedCopilotYjsDoc( - { - workspaceId, - entityKind, - entityId, - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: entityId, - }, - (doc) => getEntityFields(doc, entityKind) - ) - if (!fields) { - throw new Error('Saved entity Yjs snapshot is empty') - } - - return fields -} - async function readBootstrappedCopilotYjsDoc( descriptor: Parameters[0], read: (doc: Y.Doc) => T @@ -513,7 +492,7 @@ async function processKnowledgeContext( return null } - const fields = await readCopilotEntityFieldsFromYjs( + const fields = await readBootstrappedSavedEntityFields( ENTITY_KIND_KNOWLEDGE_BASE, knowledgeBaseId, access.workspaceId diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index ee924a57c..f2b52e3e4 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -18,7 +18,7 @@ import { import { checkWorkspaceAccess } from '@/lib/permissions/utils' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' -import { readSavedEntityFieldsFromDb } from '@/lib/yjs/server/entity-loaders' +import { readBootstrappedSavedEntityFields } from '@/lib/yjs/server/bootstrap-review-target' export type SavedEntityDocumentKind = EntityDocumentKind export type EntityDocumentArgs = { @@ -213,7 +213,7 @@ export async function readSavedEntityDocumentFields( entityId: string, workspaceId: string ): Promise> { - return readSavedEntityFieldsFromDb(kind as SavedEntityKind, entityId, workspaceId) + return readBootstrappedSavedEntityFields(kind as SavedEntityKind, entityId, workspaceId) } export async function applySavedEntityDocument( diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 6ecb79b18..931ddb078 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -10,7 +10,7 @@ import type { ReviewTargetRuntimeState, } from '@/lib/copilot/review-sessions/types' import { loadWorkflowStateFromSavedTables } from '@/lib/workflows/db-helpers' -import { seedEntitySession } from '@/lib/yjs/entity-session' +import { getEntityFields, seedEntitySession } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { readSavedEntityFieldsFromDb, @@ -88,6 +88,32 @@ export async function readBootstrappedReviewTargetSnapshot(descriptor: ReviewTar } } +export async function readBootstrappedSavedEntityFields( + entityKind: SavedEntityKind, + entityId: string, + workspaceId: string +): Promise> { + const snapshot = await readBootstrappedReviewTargetSnapshot({ + workspaceId, + entityKind, + entityId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: entityId, + }) + if (!snapshot.snapshotBase64) { + throw new ReviewTargetBootstrapError(404, `Saved ${entityKind} ${entityId} state is missing`) + } + + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) + return getEntityFields(doc, entityKind) + } finally { + doc.destroy() + } +} + async function getExistingYjsState(sessionId: string): Promise { const [{ getExistingDocument }, { getState }] = await Promise.all([ import('@/socket-server/yjs/upstream-utils'), From d7f4850c18ad90fb73626ecb6348a07bfe994288 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 14:14:41 -0600 Subject: [PATCH 055/284] feat(editors): back entity editors with Yjs sessions Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../tradinggoose/lib/yjs/use-entity-fields.ts | 103 +++++++++-- apps/tradinggoose/widgets/events.ts | 18 +- .../widgets/utils/indicator-editor-actions.ts | 67 ++----- .../widgets/utils/skill-editor-actions.ts | 74 ++------ .../widgets/widgets/_shared/mcp/utils.ts | 23 --- .../custom-tool-editor.test.tsx | 82 +++++---- .../editor_custom_tool/custom-tool-editor.tsx | 68 +++---- .../widgets/editor_custom_tool/index.tsx | 35 ++-- .../components/indicator-editor-header.tsx | 166 +++--------------- .../components/pine-indicator-code-panel.tsx | 72 +++++--- .../editor-indicator-body.tsx | 27 ++- .../widgets/editor_indicator/index.test.tsx | 155 +++------------- .../widgets/editor_mcp/editor-mcp-body.tsx | 111 +++++++++--- .../components/skill-editor-header.tsx | 92 +--------- .../editor_skill/editor-skill-body.tsx | 55 +++--- .../widgets/editor_skill/index.test.tsx | 133 +++----------- .../editor_skill/skill-editor.test.tsx | 30 ++-- .../widgets/editor_skill/skill-editor.tsx | 119 +++++-------- 18 files changed, 537 insertions(+), 893 deletions(-) diff --git a/apps/tradinggoose/lib/yjs/use-entity-fields.ts b/apps/tradinggoose/lib/yjs/use-entity-fields.ts index c3f6857de..b3a1c1545 100644 --- a/apps/tradinggoose/lib/yjs/use-entity-fields.ts +++ b/apps/tradinggoose/lib/yjs/use-entity-fields.ts @@ -8,11 +8,82 @@ * read/write through the collaborative Yjs document when available. */ -import { useCallback, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import * as Y from 'yjs' import { getFieldsMap, replaceEntityTextField, setEntityField } from '@/lib/yjs/entity-session' +import type { SavedEntityKind } from '@/lib/yjs/entity-state' +import { bootstrapYjsProvider, type YjsProviderBootstrapResult } from '@/lib/yjs/provider' import { useYjsSubscription } from '@/lib/yjs/use-yjs-subscription' +type SavedEntityYjsSessionState = { + key: string | null + result: YjsProviderBootstrapResult | null + error: string | null +} + +export function useSavedEntityYjsSession( + entityKind: SavedEntityKind, + entityId: string | null | undefined, + workspaceId: string | null | undefined +) { + const sessionKey = entityId && workspaceId ? `${entityKind}:${workspaceId}:${entityId}` : null + const [state, setState] = useState({ + key: null, + result: null, + error: null, + }) + + useEffect(() => { + setState({ key: sessionKey, result: null, error: null }) + if (!entityId || !workspaceId || !sessionKey) return + + let active = true + let current: YjsProviderBootstrapResult | null = null + + bootstrapYjsProvider({ + workspaceId, + entityKind, + entityId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: entityId, + }) + .then((next) => { + if (!active) { + next.provider.disconnect() + next.provider.destroy() + next.doc.destroy() + return + } + current = next + setState({ key: sessionKey, result: next, error: null }) + }) + .catch((nextError) => { + if (!active) return + setState({ + key: sessionKey, + result: null, + error: nextError instanceof Error ? nextError.message : 'Failed to open entity session', + }) + }) + + return () => { + active = false + current?.provider.disconnect() + current?.provider.destroy() + current?.doc.destroy() + } + }, [entityId, entityKind, sessionKey, workspaceId]) + + const activeState = state.key === sessionKey ? state : null + + return { + doc: activeState?.result?.doc ?? null, + isLoading: Boolean(sessionKey && !activeState?.result && !activeState?.error), + error: activeState?.error ?? null, + } +} + /** * Subscribe to a single string field on the entity Yjs doc's `fields` Y.Map. * Returns [value, setter] like useState. @@ -22,25 +93,21 @@ export function useYjsStringField( doc: Y.Doc | null | undefined, key: string, fallback: string = '' -): [string, (v: string) => void] { +): [string, (v: string | ((prev: string) => string)) => void] { const subscribe = useMemo(() => { if (!doc) return (cb: () => void) => () => {} const fields = getFieldsMap(doc) return (cb: () => void) => { - const handler = (event: Y.YMapEvent) => { - if (!event.keysChanged.has(key)) return - cb() - } - fields.observe(handler) - return () => fields.unobserve(handler) + const handler = () => cb() + fields.observeDeep(handler) + return () => fields.unobserveDeep(handler) } }, [doc, key]) const extract = useCallback(() => { if (!doc) return fallback const val = getFieldsMap(doc).get(key) - // Handle Y.Text instances (for Monaco-bound fields) - if (val && typeof val === 'object' && typeof val.toString === 'function' && val.constructor?.name === 'Text') { + if (val instanceof Y.Text) { return val.toString() } return typeof val === 'string' ? val : fallback @@ -49,17 +116,25 @@ export function useYjsStringField( const value = useYjsSubscription(subscribe, extract, fallback) const setValue = useCallback( - (next: string) => { + (next: string | ((prev: string) => string)) => { if (!doc) return const currentValue = getFieldsMap(doc).get(key) + const current = + currentValue instanceof Y.Text + ? currentValue.toString() + : typeof currentValue === 'string' + ? currentValue + : fallback + const nextValue = typeof next === 'function' ? next(current) : next + if (currentValue instanceof Y.Text) { - replaceEntityTextField(doc, key, next) + replaceEntityTextField(doc, key, nextValue) return } - setEntityField(doc, key, next) + setEntityField(doc, key, nextValue) }, - [doc, key] + [doc, fallback, key] ) return [value, setValue] diff --git a/apps/tradinggoose/widgets/events.ts b/apps/tradinggoose/widgets/events.ts index 4167c5e20..7668ebce4 100644 --- a/apps/tradinggoose/widgets/events.ts +++ b/apps/tradinggoose/widgets/events.ts @@ -4,12 +4,10 @@ export const WORKFLOW_WIDGET_SELECT_WORKFLOW_EVENT = 'workflow-widgets:select-wo export const DATA_CHART_WIDGET_UPDATE_PARAMS_EVENT = 'data-chart-widgets:update-params' export const INDICATOR_WIDGET_SELECT_EVENT = 'indicator-widgets:select-indicator' export const INDICATOR_EDITOR_ACTION_EVENT = 'indicator-editor:action' -export const INDICATOR_EDITOR_STATE_EVENT = 'indicator-editor:state' export const CUSTOM_TOOL_WIDGET_SELECT_EVENT = 'custom-tool-widgets:select-tool' export const CUSTOM_TOOL_EDITOR_ACTION_EVENT = 'custom-tool-editor:action' export const SKILL_WIDGET_SELECT_EVENT = 'skill-widgets:select-skill' export const SKILL_EDITOR_ACTION_EVENT = 'skill-editor:action' -export const SKILL_EDITOR_STATE_EVENT = 'skill-editor:state' export const MCP_WIDGET_SELECT_SERVER_EVENT = 'mcp-widgets:select-server' export const MCP_EDITOR_ACTION_EVENT = 'mcp-editor:action' export const WATCHLIST_WIDGET_UPDATE_PARAMS_EVENT = 'watchlist-widgets:update-params' @@ -55,13 +53,7 @@ export type HeatmapWidgetUpdateEventDetail = { } export type IndicatorEditorActionEventDetail = { - action: 'save' | 'verify' - panelId?: string - widgetKey?: string -} - -export type IndicatorEditorStateEventDetail = { - isDirty: boolean + action: 'export' | 'save' | 'verify' panelId?: string widgetKey?: string } @@ -74,13 +66,7 @@ export type CustomToolEditorActionEventDetail = { } export type SkillEditorActionEventDetail = { - action: 'save' - panelId?: string - widgetKey?: string -} - -export type SkillEditorStateEventDetail = { - isDirty: boolean + action: 'export' | 'save' panelId?: string widgetKey?: string } diff --git a/apps/tradinggoose/widgets/utils/indicator-editor-actions.ts b/apps/tradinggoose/widgets/utils/indicator-editor-actions.ts index 2a4a6b7b6..4fb626477 100644 --- a/apps/tradinggoose/widgets/utils/indicator-editor-actions.ts +++ b/apps/tradinggoose/widgets/utils/indicator-editor-actions.ts @@ -1,15 +1,14 @@ import { useEffect, useRef } from 'react' import { INDICATOR_EDITOR_ACTION_EVENT, - INDICATOR_EDITOR_STATE_EVENT, type IndicatorEditorActionEventDetail, - type IndicatorEditorStateEventDetail, } from '@/widgets/events' import type { WidgetInstance } from '@/widgets/layout' interface UseIndicatorEditorActionsOptions { panelId?: string widget?: WidgetInstance | null + onExport?: () => void onTabChange?: (tab: 'info' | 'code') => void onSave?: () => void onVerify?: () => void @@ -18,17 +17,20 @@ interface UseIndicatorEditorActionsOptions { export function useIndicatorEditorActions({ panelId, widget, + onExport, onTabChange, onSave, onVerify, }: UseIndicatorEditorActionsOptions) { + const exportRef = useRef(onExport) + exportRef.current = onExport const saveRef = useRef(onSave) saveRef.current = onSave const verifyRef = useRef(onVerify) verifyRef.current = onVerify useEffect(() => { - if (!onTabChange && !saveRef.current && !verifyRef.current) return + if (!onTabChange && !exportRef.current && !saveRef.current && !verifyRef.current) return const handleAction = (event: Event) => { const detail = (event as CustomEvent).detail @@ -36,6 +38,11 @@ export function useIndicatorEditorActions({ if (panelId && detail.panelId && detail.panelId !== panelId) return if (widget?.key && detail.widgetKey && detail.widgetKey !== widget.key) return + if (detail.action === 'export') { + exportRef.current?.() + return + } + if (detail.action === 'save') { saveRef.current?.() return @@ -55,7 +62,7 @@ export function useIndicatorEditorActions({ } interface EmitIndicatorEditorActionOptions { - action: 'save' | 'verify' + action: 'export' | 'save' | 'verify' panelId?: string widgetKey?: string } @@ -75,55 +82,3 @@ export function emitIndicatorEditorAction({ }) ) } - -interface UseIndicatorEditorStateOptions { - panelId?: string - widget?: WidgetInstance | null - onStateChange?: (detail: IndicatorEditorStateEventDetail) => void -} - -export function useIndicatorEditorState({ - panelId, - widget, - onStateChange, -}: UseIndicatorEditorStateOptions) { - const stateChangeRef = useRef(onStateChange) - stateChangeRef.current = onStateChange - - useEffect(() => { - if (!stateChangeRef.current) return - - const handleState = (event: Event) => { - const detail = (event as CustomEvent).detail - if (!detail) return - if (panelId && detail.panelId && detail.panelId !== panelId) return - if (widget?.key && detail.widgetKey && detail.widgetKey !== widget.key) return - - stateChangeRef.current?.(detail) - } - - window.addEventListener(INDICATOR_EDITOR_STATE_EVENT, handleState as EventListener) - - return () => { - window.removeEventListener(INDICATOR_EDITOR_STATE_EVENT, handleState as EventListener) - } - }, [panelId, widget?.key]) -} - -interface EmitIndicatorEditorStateOptions extends IndicatorEditorStateEventDetail {} - -export function emitIndicatorEditorState({ - isDirty, - panelId, - widgetKey, -}: EmitIndicatorEditorStateOptions) { - window.dispatchEvent( - new CustomEvent(INDICATOR_EDITOR_STATE_EVENT, { - detail: { - isDirty, - panelId, - widgetKey, - }, - }) - ) -} diff --git a/apps/tradinggoose/widgets/utils/skill-editor-actions.ts b/apps/tradinggoose/widgets/utils/skill-editor-actions.ts index 4f7a7b0e3..f30f1967f 100644 --- a/apps/tradinggoose/widgets/utils/skill-editor-actions.ts +++ b/apps/tradinggoose/widgets/utils/skill-editor-actions.ts @@ -1,24 +1,27 @@ import { useEffect, useRef } from 'react' -import { - SKILL_EDITOR_ACTION_EVENT, - SKILL_EDITOR_STATE_EVENT, - type SkillEditorActionEventDetail, - type SkillEditorStateEventDetail, -} from '@/widgets/events' +import { SKILL_EDITOR_ACTION_EVENT, type SkillEditorActionEventDetail } from '@/widgets/events' import type { WidgetInstance } from '@/widgets/layout' interface UseSkillEditorActionsOptions { panelId?: string widget?: WidgetInstance | null + onExport?: () => void onSave?: () => void } -export function useSkillEditorActions({ panelId, widget, onSave }: UseSkillEditorActionsOptions) { +export function useSkillEditorActions({ + panelId, + widget, + onExport, + onSave, +}: UseSkillEditorActionsOptions) { + const exportRef = useRef(onExport) + exportRef.current = onExport const saveRef = useRef(onSave) saveRef.current = onSave useEffect(() => { - if (!saveRef.current) return + if (!exportRef.current && !saveRef.current) return const handleAction = (event: Event) => { const detail = (event as CustomEvent).detail @@ -26,6 +29,11 @@ export function useSkillEditorActions({ panelId, widget, onSave }: UseSkillEdito if (panelId && detail.panelId && detail.panelId !== panelId) return if (widget?.key && detail.widgetKey && detail.widgetKey !== widget.key) return + if (detail.action === 'export') { + exportRef.current?.() + return + } + if (detail.action === 'save') { saveRef.current?.() } @@ -40,7 +48,7 @@ export function useSkillEditorActions({ panelId, widget, onSave }: UseSkillEdito } interface EmitSkillEditorActionOptions { - action: 'save' + action: 'export' | 'save' panelId?: string widgetKey?: string } @@ -60,51 +68,3 @@ export function emitSkillEditorAction({ }) ) } - -interface UseSkillEditorStateOptions { - panelId?: string - widget?: WidgetInstance | null - onStateChange?: (detail: SkillEditorStateEventDetail) => void -} - -export function useSkillEditorState({ - panelId, - widget, - onStateChange, -}: UseSkillEditorStateOptions) { - const stateChangeRef = useRef(onStateChange) - stateChangeRef.current = onStateChange - - useEffect(() => { - if (!stateChangeRef.current) return - - const handleState = (event: Event) => { - const detail = (event as CustomEvent).detail - if (!detail) return - if (panelId && detail.panelId && detail.panelId !== panelId) return - if (widget?.key && detail.widgetKey && detail.widgetKey !== widget.key) return - - stateChangeRef.current?.(detail) - } - - window.addEventListener(SKILL_EDITOR_STATE_EVENT, handleState as EventListener) - - return () => { - window.removeEventListener(SKILL_EDITOR_STATE_EVENT, handleState as EventListener) - } - }, [panelId, widget?.key]) -} - -interface EmitSkillEditorStateOptions extends SkillEditorStateEventDetail {} - -export function emitSkillEditorState({ isDirty, panelId, widgetKey }: EmitSkillEditorStateOptions) { - window.dispatchEvent( - new CustomEvent(SKILL_EDITOR_STATE_EVENT, { - detail: { - isDirty, - panelId, - widgetKey, - }, - }) - ) -} diff --git a/apps/tradinggoose/widgets/widgets/_shared/mcp/utils.ts b/apps/tradinggoose/widgets/widgets/_shared/mcp/utils.ts index ea114b358..35b62ceff 100644 --- a/apps/tradinggoose/widgets/widgets/_shared/mcp/utils.ts +++ b/apps/tradinggoose/widgets/widgets/_shared/mcp/utils.ts @@ -1,6 +1,5 @@ import type { McpTransport } from '@/lib/mcp/types' import { normalizeStringArray, sanitizeRecord } from '@/lib/utils' -import type { McpServerWithStatus } from '@/stores/mcp-servers/types' import { readEntitySelectionState, resolveEntityId } from '@/widgets/utils/entity-selection' import { MCP_SERVER_DEFAULTS } from '@/widgets/utils/mcp-defaults' @@ -28,28 +27,6 @@ export const createDefaultMcpServerFormData = (): McpServerFormData => ({ env: {}, }) -export const createFormDataFromServer = ( - server: Partial -): McpServerFormData => ({ - name: server.name ?? MCP_SERVER_DEFAULTS.name, - description: server.description ?? MCP_SERVER_DEFAULTS.description, - transport: server.transport ?? 'streamable-http', - url: server.url ?? MCP_SERVER_DEFAULTS.url, - headers: - server.headers && typeof server.headers === 'object' && !Array.isArray(server.headers) - ? { ...server.headers } - : {}, - command: server.command ?? MCP_SERVER_DEFAULTS.command, - args: Array.isArray(server.args) ? [...server.args] : [], - env: - server.env && typeof server.env === 'object' && !Array.isArray(server.env) - ? { ...server.env } - : {}, - timeout: server.timeout ?? MCP_SERVER_DEFAULTS.timeout, - retries: server.retries ?? MCP_SERVER_DEFAULTS.retries, - enabled: server.enabled ?? MCP_SERVER_DEFAULTS.enabled, -}) - export const createMcpSavePayload = (formData: McpServerFormData) => ({ name: formData.name.trim(), description: formData.description.trim() || null, diff --git a/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.test.tsx b/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.test.tsx index 0373f0671..1424203c8 100644 --- a/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.test.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.test.tsx @@ -6,6 +6,8 @@ import type { MutableRefObject, ReactNode, TextareaHTMLAttributes } from 'react' import { act, createRef } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' +import { replaceEntityTextField, seedEntitySession, setEntityField } from '@/lib/yjs/entity-session' import { CustomToolEditor } from '@/widgets/widgets/editor_custom_tool/custom-tool-editor' const mockUseUpdateCustomTool = vi.fn() @@ -98,6 +100,26 @@ const readBlobText = async (blob: Blob) => reader.readAsText(blob) }) +const createCustomToolDoc = (initialValues: { + title: string + schema: unknown + code: string +}) => { + const doc = new Y.Doc() + seedEntitySession(doc, { + entityKind: 'custom_tool', + payload: { + title: initialValues.title, + schemaText: + typeof initialValues.schema === 'string' + ? initialValues.schema + : JSON.stringify(initialValues.schema, null, 2), + codeText: initialValues.code, + }, + }) + return doc +} + describe('CustomToolEditor export', () => { let container: HTMLDivElement let root: Root @@ -166,33 +188,27 @@ describe('CustomToolEditor export', () => { }, code: 'return { movers: [] }', } + const doc = createCustomToolDoc(initialValues) await act(async () => { root.render( void>} saveRef={saveRef as MutableRefObject<() => void>} + doc={doc} /> ) }) - const schemaEditor = container.querySelector( - '[data-testid="code-editor-json"]' - ) as HTMLTextAreaElement | null - expect(schemaEditor).toBeTruthy() - await act(async () => { - const valueSetter = Object.getOwnPropertyDescriptor( - HTMLTextAreaElement.prototype, - 'value' - )?.set - valueSetter?.call( - schemaEditor, + replaceEntityTextField( + doc, + 'schemaText', JSON.stringify( { type: 'function', @@ -213,8 +229,7 @@ describe('CustomToolEditor export', () => { 2 ) ) - schemaEditor!.dispatchEvent(new Event('input', { bubbles: true })) - schemaEditor!.dispatchEvent(new Event('change', { bubbles: true })) + setEntityField(doc, 'title', 'fetchTopMoversCurrent') }) await act(async () => { @@ -222,28 +237,18 @@ describe('CustomToolEditor export', () => { void>} saveRef={saveRef as MutableRefObject<() => void>} + doc={doc} /> ) }) - const codeEditor = container.querySelector( - '[data-testid="code-editor-javascript"]' - ) as HTMLTextAreaElement | null - expect(codeEditor).toBeTruthy() - await act(async () => { - const valueSetter = Object.getOwnPropertyDescriptor( - HTMLTextAreaElement.prototype, - 'value' - )?.set - valueSetter?.call(codeEditor, 'return { exported: true }') - codeEditor!.dispatchEvent(new Event('input', { bubbles: true })) - codeEditor!.dispatchEvent(new Event('change', { bubbles: true })) + replaceEntityTextField(doc, 'codeText', 'return { exported: true }') }) await act(async () => { @@ -252,7 +257,7 @@ describe('CustomToolEditor export', () => { expect(createObjectUrlSpy).toHaveBeenCalledTimes(1) expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:custom-tool-export') - expect(capturedDownloadName).toBe('Fetch-Top-Movers.json') + expect(capturedDownloadName).toBe('fetchTopMoversCurrent.json') const blob = createObjectUrlSpy.mock.calls[0]?.[0] as Blob const payload = JSON.parse(await readBlobText(blob)) @@ -267,7 +272,7 @@ describe('CustomToolEditor export', () => { workflows: [], customTools: [ { - title: 'Fetch Top Movers', + title: 'fetchTopMoversCurrent', schema: { type: 'function', function: { @@ -289,6 +294,7 @@ describe('CustomToolEditor export', () => { watchlists: [], indicators: [], }) + doc.destroy() }) it('blocks export when the current schema is invalid', async () => { @@ -311,34 +317,25 @@ describe('CustomToolEditor export', () => { }, code: 'return { movers: [] }', } + const doc = createCustomToolDoc(initialValues) await act(async () => { root.render( void>} saveRef={saveRef as MutableRefObject<() => void>} + doc={doc} /> ) }) - const schemaEditor = container.querySelector( - '[data-testid="code-editor-json"]' - ) as HTMLTextAreaElement | null - expect(schemaEditor).toBeTruthy() - await act(async () => { - const valueSetter = Object.getOwnPropertyDescriptor( - HTMLTextAreaElement.prototype, - 'value' - )?.set - valueSetter?.call(schemaEditor, '{') - schemaEditor!.dispatchEvent(new Event('input', { bubbles: true })) - schemaEditor!.dispatchEvent(new Event('change', { bubbles: true })) + replaceEntityTextField(doc, 'schemaText', '{') }) await act(async () => { @@ -347,5 +344,6 @@ describe('CustomToolEditor export', () => { expect(createObjectUrlSpy).not.toHaveBeenCalled() expect(onSectionChange).toHaveBeenCalledWith('schema') + doc.destroy() }) }) diff --git a/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.tsx b/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.tsx index b21201fe7..a707b1e96 100644 --- a/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.tsx @@ -1,5 +1,6 @@ import { type MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AlertTriangle, Code, FileJson } from 'lucide-react' +import type * as Y from 'yjs' import { createMonacoFunctionBodyDiagnosticSourceBuilder, type MonacoEditorHandle, @@ -12,9 +13,10 @@ import { exportCustomToolsAsJson } from '@/lib/custom-tools/import-export' import { CustomToolOpenAiSchema } from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' +import { useYjsStringField } from '@/lib/yjs/use-entity-fields' +import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' import { useUpdateCustomTool } from '@/hooks/queries/custom-tools' import { useWand } from '@/hooks/workflow/use-wand' -import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' import { WandPromptBar } from '@/widgets/widgets/editor_workflow/components/wand-prompt-bar/wand-prompt-bar' import { CodeEditor } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/components/code-editor/code-editor' import { useWorkspaceId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' @@ -23,17 +25,11 @@ const logger = createLogger('CustomToolEditor') export type CustomToolEditorSection = 'schema' | 'code' -interface CustomToolInitialValues { - id: string - title: string - schema: any - code: string -} - interface CustomToolEditorProps { activeSection: CustomToolEditorSection blockId: string - initialValues: CustomToolInitialValues + toolId: string + doc: Y.Doc | null onSave: () => void onSectionChange: (section: CustomToolEditorSection) => void exportRef: MutableRefObject<() => void> @@ -43,7 +39,8 @@ interface CustomToolEditorProps { export function CustomToolEditor({ activeSection, blockId, - initialValues, + toolId, + doc, onSave, onSectionChange, exportRef, @@ -51,8 +48,9 @@ export function CustomToolEditor({ }: CustomToolEditorProps) { const copy = useWorkspaceWidgetsMessages().customToolEditor const workspaceId = useWorkspaceId() - const [jsonSchema, setJsonSchema] = useState('') - const [functionCode, setFunctionCode] = useState('') + const [toolTitle] = useYjsStringField(doc, 'title') + const [jsonSchema, setJsonSchema] = useYjsStringField(doc, 'schemaText') + const [functionCode, setFunctionCode] = useYjsStringField(doc, 'codeText') const [schemaError, setSchemaError] = useState(null) const [codeError, setCodeError] = useState(null) const codeEditorRef = useRef(null) @@ -70,20 +68,9 @@ export function CustomToolEditor({ const updateToolMutation = useUpdateCustomTool() useEffect(() => { - try { - setJsonSchema( - typeof initialValues.schema === 'string' - ? initialValues.schema - : JSON.stringify(initialValues.schema, null, 2) - ) - setFunctionCode(initialValues.code || '') - setSchemaError(null) - setCodeError(null) - } catch (error) { - logger.error('Error initializing custom tool editor:', { error }) - setSchemaError(copy.validation.failedToLoadToolData) - } - }, [initialValues.code, initialValues.id, initialValues.schema]) + setSchemaError(null) + setCodeError(null) + }, [toolId]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -230,7 +217,6 @@ IMPORTANT FORMATTING RULES: onStreamChunk: (chunk) => { setFunctionCode((prev) => { const nextCode = prev + chunk - handleFunctionCodeChange(nextCode) if (codeError) { setCodeError(null) } @@ -346,6 +332,8 @@ IMPORTANT FORMATTING RULES: }, [jsonSchema, onSectionChange]) const handleSave = useCallback(async () => { + if (!doc) return + setCodeError(null) try { @@ -354,11 +342,18 @@ IMPORTANT FORMATTING RULES: return } + const title = toolTitle.trim() + if (!title) { + setSchemaError(copy.validation.failedToSave) + onSectionChange('schema') + return + } + await updateToolMutation.mutateAsync({ workspaceId, - toolId: initialValues.id, + toolId, updates: { - title: initialValues.title, + title, schema, code: functionCode || '', }, @@ -372,11 +367,12 @@ IMPORTANT FORMATTING RULES: } }, [ parseCurrentSchema, + doc, functionCode, - initialValues.id, - initialValues.title, onSave, onSectionChange, + toolTitle, + toolId, updateToolMutation, workspaceId, ]) @@ -387,7 +383,13 @@ IMPORTANT FORMATTING RULES: return } - const title = initialValues.title.trim() + const title = toolTitle.trim() + if (!title) { + setSchemaError(copy.validation.failedToSave) + onSectionChange('schema') + return + } + const fileNameBase = title .trim() @@ -413,7 +415,7 @@ IMPORTANT FORMATTING RULES: link.click() document.body.removeChild(link) URL.revokeObjectURL(blobUrl) - }, [functionCode, initialValues.title, parseCurrentSchema]) + }, [copy.validation.failedToSave, functionCode, onSectionChange, parseCurrentSchema, toolTitle]) useEffect(() => { saveRef.current = () => { diff --git a/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx b/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx index 7e5fe8ccc..0051066ff 100644 --- a/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx @@ -10,7 +10,7 @@ import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-con import { useMessages } from 'next-intl' import type { LocaleCode } from '@/i18n/utils' import { useCustomTools } from '@/hooks/queries/custom-tools' -import { useCustomToolsStore } from '@/stores/custom-tools/store' +import { useSavedEntityYjsSession } from '@/lib/yjs/use-entity-fields' import type { CustomToolDefinition } from '@/stores/custom-tools/types' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import { DEFAULT_WORKFLOW_CHANNEL_ID } from '@/stores/workflows/workflow/store-client' @@ -115,9 +115,6 @@ function EditorCustomToolWidgetBody({ const copy = useMessages().workspace.widgets.customToolEditor const workspaceId = context?.workspaceId ?? null const { data: queryTools = [], isLoading, error, refetch } = useCustomTools(workspaceId ?? '') - const storedTools = useCustomToolsStore((state) => - workspaceId ? state.getAllTools(workspaceId) : [] - ) const resolvedPairColor = (pairColor ?? 'gray') as PairColor const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) @@ -126,10 +123,7 @@ function EditorCustomToolWidgetBody({ const saveRef = useRef<() => void>(() => {}) const [activeSection, setActiveSection] = useState('schema') - const tools = useMemo( - () => sortCustomTools(queryTools.length > 0 ? queryTools : storedTools), - [queryTools, storedTools] - ) + const tools = useMemo(() => sortCustomTools(queryTools), [queryTools]) const paramsCustomToolId = resolveCustomToolId({ params }) const requestedCustomToolId = isLinkedToColorPair @@ -176,6 +170,7 @@ function EditorCustomToolWidgetBody({ const selectedTool = selectedToolId ? (tools.find((tool) => tool.id === selectedToolId) ?? null) : null + const customToolSession = useSavedEntityYjsSession('custom_tool', selectedToolId, workspaceId) useEffect(() => { if (!selectedToolId) return @@ -208,12 +203,12 @@ function EditorCustomToolWidgetBody({ ]) useEffect(() => { - if (!selectedTool?.id) { + if (!selectedToolId) { return } syncActiveSection('schema') - }, [selectedTool?.id, syncActiveSection]) + }, [selectedToolId, syncActiveSection]) useCustomToolEditorActions({ onExport: () => exportRef.current(), @@ -261,6 +256,18 @@ function EditorCustomToolWidgetBody({ return } + if (customToolSession.error) { + return + } + + if (customToolSession.isLoading) { + return ( +
+ +
+ ) + } + return ( { refetch().catch((refetchError) => { @@ -279,12 +288,6 @@ function EditorCustomToolWidgetBody({ exportRef={exportRef} saveRef={saveRef} blockId='dashboard-custom-tool-editor' - initialValues={{ - id: selectedTool.id, - title: selectedTool.title, - schema: selectedTool.schema, - code: selectedTool.code || '', - }} />
diff --git a/apps/tradinggoose/widgets/widgets/editor_indicator/components/indicator-editor-header.tsx b/apps/tradinggoose/widgets/widgets/editor_indicator/components/indicator-editor-header.tsx index 181076d6f..a7c22f78d 100644 --- a/apps/tradinggoose/widgets/widgets/editor_indicator/components/indicator-editor-header.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_indicator/components/indicator-editor-header.tsx @@ -1,20 +1,13 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' import { Check, Download, Save } from 'lucide-react' import { useLocale } from 'next-intl' -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useMessages } from 'next-intl' -import { exportIndicatorsAsJson } from '@/lib/indicators/import-export' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' -import { useIndicatorsStore } from '@/stores/indicators/store' import type { PairColor } from '@/widgets/pair-colors' -import { - emitIndicatorEditorAction, - useIndicatorEditorState, -} from '@/widgets/utils/indicator-editor-actions' +import { emitIndicatorEditorAction } from '@/widgets/utils/indicator-editor-actions' import { emitIndicatorSelectionChange } from '@/widgets/utils/indicator-selection' +import { EntityEditorHeaderButton } from '@/widgets/widgets/components/entity-editor-buttons' import { IndicatorDropdown } from '@/widgets/widgets/components/pine-indicator-dropdown' interface IndicatorEditorSelectorProps { @@ -79,25 +72,6 @@ interface IndicatorEditorActionButtonProps { pairColor?: PairColor } -const sanitizeFileNameSegment = (value: string) => - value - .trim() - .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-') - .replace(/\s+/g, '-') - -const downloadJsonFile = (fileName: string, content: string) => { - const blob = new Blob([content], { type: 'application/json;charset=utf-8' }) - const blobUrl = URL.createObjectURL(blob) - const link = document.createElement('a') - - link.href = blobUrl - link.download = fileName - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - URL.revokeObjectURL(blobUrl) -} - export function IndicatorEditorExportButton({ workspaceId, indicatorId, @@ -110,72 +84,20 @@ export function IndicatorEditorExportButton({ const resolvedPairColor = (pairColor ?? 'gray') as PairColor const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) - const [isDirty, setIsDirty] = useState(true) const resolvedIndicatorId = isLinkedToColorPair ? (pairContext?.indicatorId ?? null) : (indicatorId ?? null) - const indicator = useIndicatorsStore((state) => - workspaceId && resolvedIndicatorId - ? state.readIndicator(resolvedIndicatorId, workspaceId) - : undefined - ) - - useIndicatorEditorState({ - panelId, - widget: widgetKey ? ({ key: widgetKey } as { key: string }) : null, - onStateChange: (detail) => { - setIsDirty(detail.isDirty) - }, - }) - - useEffect(() => { - setIsDirty(true) - }, [resolvedIndicatorId, workspaceId]) - - const fileName = useMemo(() => { - if (!indicator?.name) { - return 'indicator.json' - } - - const normalized = sanitizeFileNameSegment(indicator.name) - return normalized.length > 0 ? `${normalized}.json` : 'indicator.json' - }, [indicator?.name]) - - const exportDisabled = !workspaceId || !resolvedIndicatorId || !indicator || isDirty - const tooltipText = - exportDisabled && indicator && isDirty ? copy.saveBeforeExporting : copy.exportIndicator - - const handleExport = useCallback(() => { - if (!indicator) return - - const json = exportIndicatorsAsJson({ - exportedFrom: 'indicatorEditor', - indicators: [indicator], - }) - - downloadJsonFile(fileName, json) - }, [fileName, indicator]) + const exportDisabled = !workspaceId || !resolvedIndicatorId return ( - - - - - - - {tooltipText} - + emitIndicatorEditorAction({ action: 'export', panelId, widgetKey })} + /> ) } @@ -197,33 +119,15 @@ export function IndicatorEditorSaveButton({ : (indicatorId ?? null) const saveDisabled = !workspaceId || !resolvedIndicatorId - const handleSave = () => { - emitIndicatorEditorAction({ - action: 'save', - panelId, - widgetKey, - }) - } - return ( - - - - - - - {copy.saveIndicator} - + emitIndicatorEditorAction({ action: 'save', panelId, widgetKey })} + /> ) } @@ -245,32 +149,14 @@ export function IndicatorEditorVerifyButton({ : (indicatorId ?? null) const verifyDisabled = !workspaceId || !resolvedIndicatorId - const handleVerify = () => { - emitIndicatorEditorAction({ - action: 'verify', - panelId, - widgetKey, - }) - } - return ( - - - - - - - Verify indicator - + emitIndicatorEditorAction({ action: 'verify', panelId, widgetKey })} + /> ) } diff --git a/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx b/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx index 172c6316f..084faec9f 100644 --- a/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx @@ -1,6 +1,7 @@ 'use client' import { type MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type * as Y from 'yjs' import { buildMonacoIndicatorDiagnosticSource, type MonacoEditorHandle, @@ -15,6 +16,7 @@ import { SelectValue, } from '@/components/ui/select' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { exportIndicatorsAsJson } from '@/lib/indicators/import-export' import { executeBrowserPineIndicator } from '@/lib/indicators/browser-execution' import { buildInputsMapFromMeta, inferInputMetaFromPineCode } from '@/lib/indicators/input-meta' import { PINE_CHEAT_SHEET_EXTRA_LIBS } from '@/lib/indicators/pine-cheat-sheet' @@ -22,10 +24,9 @@ import { mapMarketSeriesToBarsMs } from '@/lib/indicators/series-data' import { detectTriggerUsage } from '@/lib/indicators/trigger-detection' import { detectUnsupportedFeatures } from '@/lib/indicators/unsupported' import { generateMockMarketSeries } from '@/lib/market/mock-series' +import { useYjsStringField } from '@/lib/yjs/use-entity-fields' import { useUpdateIndicator } from '@/hooks/queries/indicators' import { useWand } from '@/hooks/workflow/use-wand' -import type { IndicatorDefinition } from '@/stores/indicators/types' -import { emitIndicatorEditorState } from '@/widgets/utils/indicator-editor-actions' import { CHEAT_SHEET_GROUPS, type CheatSheetGroup, @@ -34,13 +35,12 @@ import { WandPromptBar } from '@/widgets/widgets/editor_workflow/components/wand import { CodeEditor } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/components/code-editor/code-editor' type IndicatorCodePanelProps = { - indicator: IndicatorDefinition indicatorId: string workspaceId: string + doc: Y.Doc | null + exportRef: MutableRefObject<() => void> saveRef: MutableRefObject<() => void> verifyRef: MutableRefObject<() => void> - panelId?: string - widgetKey?: string } const PINE_WAND_PROMPT = `# Role @@ -159,17 +159,16 @@ const verifyIndicatorInBrowser = async ({ } export function IndicatorCodePanel({ - indicator, indicatorId, workspaceId, + doc, + exportRef, saveRef, verifyRef, - panelId, - widgetKey, }: IndicatorCodePanelProps) { const updateMutation = useUpdateIndicator() - - const [pineCode, setPineCode] = useState('') + const [indicatorName] = useYjsStringField(doc, 'name') + const [pineCode, setPineCode] = useYjsStringField(doc, 'pineCode') const [verifyStatus, setVerifyStatus] = useState< | { state: 'idle' } @@ -187,7 +186,6 @@ export function IndicatorCodePanel({ const codeEditorRef = useRef(null) const codeEditorHandleRef = useRef(null) - const indicatorSignatureRef = useRef('') const disallowedGlobalMessage = 'Do not use $.pine or $.data. Use globals directly (ta, input, plot, open, high, low, close, volume).' const monacoModelPath = useMemo( @@ -217,22 +215,8 @@ export function IndicatorCodePanel({ }) useEffect(() => { - if (!indicator) return - const signature = `${indicator.id}:${indicator.updatedAt ?? indicator.createdAt ?? ''}` - if (indicatorSignatureRef.current === signature) return - indicatorSignatureRef.current = signature - - setPineCode(indicator.pineCode ?? '') setVerifyStatus({ state: 'idle' }) - }, [indicator]) - - useEffect(() => { - emitIndicatorEditorState({ - isDirty: pineCode !== (indicator.pineCode ?? ''), - panelId, - widgetKey, - }) - }, [indicator.id, indicator.pineCode, panelId, pineCode, widgetKey]) + }, [doc, indicatorId]) const updateCursorState = ( value: string, @@ -274,7 +258,7 @@ export function IndicatorCodePanel({ } const handleSave = useCallback(async () => { - if (!workspaceId || !indicatorId) return + if (!workspaceId || !indicatorId || !doc) return const disallowedMessage = validateNoDollarGlobals(pineCode) if (disallowedMessage) { setVerifyStatus({ state: 'error', message: disallowedMessage }) @@ -287,6 +271,7 @@ export function IndicatorCodePanel({ workspaceId, indicatorId, updates: { + name: indicatorName, pineCode, inputMeta: inferredInputMeta ?? null, }, @@ -294,7 +279,38 @@ export function IndicatorCodePanel({ } catch (err) { console.error('Failed to update indicator', err) } - }, [workspaceId, indicatorId, updateMutation, pineCode]) + }, [workspaceId, indicatorId, doc, updateMutation, indicatorName, pineCode]) + + const handleExport = useCallback(() => { + if (!doc) return + const json = exportIndicatorsAsJson({ + exportedFrom: 'indicatorEditor', + indicators: [ + { + name: indicatorName, + pineCode, + inputMeta: inferInputMetaFromPineCode(pineCode) ?? undefined, + }, + ], + }) + const fileNameBase = + indicatorName + .trim() + .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-') + .replace(/\s+/g, '-') || 'indicator' + const blobUrl = URL.createObjectURL(new Blob([json], { type: 'application/json;charset=utf-8' })) + const link = document.createElement('a') + link.href = blobUrl + link.download = `${fileNameBase}.json` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(blobUrl) + }, [doc, indicatorName, pineCode]) + + useEffect(() => { + exportRef.current = handleExport + }, [exportRef, handleExport]) const handleVerify = useCallback(async () => { if (!workspaceId) return diff --git a/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx b/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx index 3c10d570e..9b8e8b671 100644 --- a/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx @@ -1,10 +1,10 @@ 'use client' import { useCallback, useEffect, useRef } from 'react' -import { useLocale } from 'next-intl' import { LoadingAgent } from '@/components/ui/loading-agent' import { useMessages } from 'next-intl' import { useIndicators } from '@/hooks/queries/indicators' +import { useSavedEntityYjsSession } from '@/lib/yjs/use-entity-fields' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import type { PairColor } from '@/widgets/pair-colors' import type { WidgetComponentProps } from '@/widgets/types' @@ -24,7 +24,6 @@ export function EditorIndicatorWidgetBody({ widget, onWidgetParamsChange, }: EditorIndicatorWidgetBodyProps) { - const locale = useLocale() const copy = useMessages().workspace.widgets.indicatorEditor.body const workspaceId = context?.workspaceId ?? null const { data: indicators = [], isLoading, error } = useIndicators(workspaceId ?? '') @@ -51,6 +50,7 @@ export function EditorIndicatorWidgetBody({ const indicator = indicatorId ? (workspaceIndicators.find((candidate) => candidate.id === indicatorId) ?? null) : null + const indicatorSession = useSavedEntityYjsSession('indicator', indicatorId, workspaceId) useEffect(() => { if (!indicatorId) { @@ -97,9 +97,14 @@ export function EditorIndicatorWidgetBody({ }, }) + const codeExportRef = useRef<() => void>(() => {}) const codeSaveRef = useRef<() => void>(() => {}) const codeVerifyRef = useRef<() => void>(() => {}) + const handleExport = useCallback(() => { + codeExportRef.current() + }, []) + const handleSave = useCallback(() => { codeSaveRef.current() }, []) @@ -111,6 +116,7 @@ export function EditorIndicatorWidgetBody({ useIndicatorEditorActions({ panelId, widget, + onExport: handleExport, onSave: handleSave, onVerify: handleVerify, }) @@ -153,16 +159,27 @@ export function EditorIndicatorWidgetBody({ return } + if (indicatorSession.error) { + return + } + + if (indicatorSession.isLoading) { + return ( +
+ +
+ ) + } + return (
) diff --git a/apps/tradinggoose/widgets/widgets/editor_indicator/index.test.tsx b/apps/tradinggoose/widgets/widgets/editor_indicator/index.test.tsx index 66a6d9e56..16ab83399 100644 --- a/apps/tradinggoose/widgets/widgets/editor_indicator/index.test.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_indicator/index.test.tsx @@ -6,8 +6,10 @@ import type { ReactNode } from 'react' import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useIndicatorsStore } from '@/stores/indicators/store' -import { emitIndicatorEditorState } from '@/widgets/utils/indicator-editor-actions' +import { + INDICATOR_EDITOR_ACTION_EVENT, + type IndicatorEditorActionEventDetail, +} from '@/widgets/events' import { editorIndicatorWidget } from '@/widgets/widgets/editor_indicator' vi.mock('@/components/ui/tooltip', () => ({ @@ -24,20 +26,9 @@ const reactActEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } -const readBlobText = async (blob: Blob) => - await new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(String(reader.result ?? '')) - reader.onerror = () => reject(reader.error) - reader.readAsText(blob) - }) - describe('Indicator Editor header controls', () => { let container: HTMLDivElement let root: Root - let createObjectUrlSpy: ReturnType - let revokeObjectUrlSpy: ReturnType - let clickSpy: ReturnType beforeEach(() => { vi.clearAllMocks() @@ -45,44 +36,6 @@ describe('Indicator Editor header controls', () => { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) - - useIndicatorsStore.getState().resetAll() - useIndicatorsStore.getState().setIndicators('workspace-1', [ - { - id: 'indicator-1', - workspaceId: 'workspace-1', - userId: 'user-1', - name: 'RSI Export Example', - color: '#3972F6', - pineCode: "indicator('RSI Export Example')", - inputMeta: { - Length: { - title: 'Length', - type: 'int', - defval: 14, - }, - }, - createdAt: '2026-04-08T15:30:00.000Z', - updatedAt: '2026-04-08T15:30:00.000Z', - }, - ]) - - createObjectUrlSpy = vi.fn(() => 'blob:indicator-export') - revokeObjectUrlSpy = vi.fn() - clickSpy = vi.fn() - - Object.defineProperty(globalThis.URL, 'createObjectURL', { - configurable: true, - value: createObjectUrlSpy, - }) - Object.defineProperty(globalThis.URL, 'revokeObjectURL', { - configurable: true, - value: revokeObjectUrlSpy, - }) - Object.defineProperty(HTMLAnchorElement.prototype, 'click', { - configurable: true, - value: clickSpy, - }) }) afterEach(() => { @@ -90,7 +43,6 @@ describe('Indicator Editor header controls', () => { root.unmount() }) container.remove() - useIndicatorsStore.getState().resetAll() }) it('renders Export indicator immediately left of Save indicator', async () => { @@ -132,7 +84,12 @@ describe('Indicator Editor header controls', () => { expect(buttons[1]?.hasAttribute('disabled')).toBe(true) }) - it('disables export while the editor is dirty and re-enables it when the editor becomes clean', async () => { + it('emits verify, export, and save actions for the selected indicator', async () => { + const actionSpy = vi.fn() + const handler = (event: Event) => { + actionSpy((event as CustomEvent).detail) + } + window.addEventListener(INDICATOR_EDITOR_ACTION_EVENT, handler) const header = editorIndicatorWidget.renderHeader?.({ context: { workspaceId: 'workspace-1' } as any, panelId: 'panel-1', @@ -148,90 +105,28 @@ describe('Indicator Editor header controls', () => { }) const buttons = Array.from(container.querySelectorAll('button')) - const exportButton = buttons[1] - - expect(exportButton?.hasAttribute('disabled')).toBe(true) await act(async () => { - emitIndicatorEditorState({ - isDirty: false, - panelId: 'panel-1', - widgetKey: 'editor_indicator', - }) + buttons[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + buttons[1]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + buttons[2]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) - expect(exportButton?.hasAttribute('disabled')).toBe(false) - - await act(async () => { - emitIndicatorEditorState({ - isDirty: true, - panelId: 'panel-1', - widgetKey: 'editor_indicator', - }) - }) - - expect(exportButton?.hasAttribute('disabled')).toBe(true) - }) - - it('downloads the unified export envelope for the selected indicator', async () => { - const header = editorIndicatorWidget.renderHeader?.({ - context: { workspaceId: 'workspace-1' } as any, + expect(actionSpy).toHaveBeenNthCalledWith(1, { + action: 'verify', panelId: 'panel-1', - widget: { - key: 'editor_indicator', - params: { indicatorId: 'indicator-1' }, - pairColor: 'gray', - } as any, - } as any) - - await act(async () => { - root.render(header?.right as ReactNode) + widgetKey: 'editor_indicator', }) - - const buttons = Array.from(container.querySelectorAll('button')) - const exportButton = buttons[1] - - await act(async () => { - emitIndicatorEditorState({ - isDirty: false, - panelId: 'panel-1', - widgetKey: 'editor_indicator', - }) - }) - - await act(async () => { - exportButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(actionSpy).toHaveBeenNthCalledWith(2, { + action: 'export', + panelId: 'panel-1', + widgetKey: 'editor_indicator', }) - - expect(createObjectUrlSpy).toHaveBeenCalledTimes(1) - expect(clickSpy).toHaveBeenCalledTimes(1) - expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:indicator-export') - - const blob = createObjectUrlSpy.mock.calls[0]?.[0] as Blob - const payload = JSON.parse(await readBlobText(blob)) - - expect(payload).toMatchObject({ - version: '1', - fileType: 'tradingGooseExport', - exportedFrom: 'indicatorEditor', - resourceTypes: ['indicators'], - skills: [], - workflows: [], - customTools: [], - watchlists: [], - indicators: [ - { - name: 'RSI Export Example', - pineCode: "indicator('RSI Export Example')", - inputMeta: { - Length: { - title: 'Length', - type: 'int', - defval: 14, - }, - }, - }, - ], + expect(actionSpy).toHaveBeenNthCalledWith(3, { + action: 'save', + panelId: 'panel-1', + widgetKey: 'editor_indicator', }) + window.removeEventListener(INDICATOR_EDITOR_ACTION_EVENT, handler) }) }) diff --git a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx index eebb71cdd..b8bcd7ed7 100644 --- a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx @@ -1,11 +1,21 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + type SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { LoadingAgent } from '@/components/ui/loading-agent' import { useMcpServerTest } from '@/hooks/use-mcp-server-test' import { useMcpTools } from '@/hooks/use-mcp-tools' import { useMessages } from 'next-intl' import { formatTemplate } from '@/i18n/utils' +import { getFieldsMap, setEntityField } from '@/lib/yjs/entity-session' +import { useSavedEntityYjsSession } from '@/lib/yjs/use-entity-fields' +import { useYjsSubscription } from '@/lib/yjs/use-yjs-subscription' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import { useMcpServersStore } from '@/stores/mcp-servers/store' import type { McpServerWithStatus } from '@/stores/mcp-servers/types' @@ -16,18 +26,15 @@ import { useMcpSelectionPersistence } from '@/widgets/utils/mcp-selection' import { McpServerForm } from '@/widgets/widgets/_shared/mcp/components/mcp-server-form' import { createDefaultMcpServerFormData, - createFormDataFromServer, createMcpSavePayload, type McpServerFormData, resolveMcpServerId, } from '@/widgets/widgets/_shared/mcp/utils' import { WidgetStateMessage } from '@/widgets/widgets/editor_indicator/components/widget-state-message' +import type * as Y from 'yjs' type EditorMcpWidgetBodyProps = WidgetComponentProps -const getServerName = (server?: Pick | null) => - server?.name?.trim() || '' - const formatRelativeTime = ( dateString: string | undefined, copy: { @@ -84,6 +91,52 @@ const getStatusLabel = ( return copy.disconnected } +function readMcpFormData(doc: Y.Doc | null, fallback: McpServerFormData): McpServerFormData { + if (!doc) return fallback + const fields = getFieldsMap(doc) + return { + name: fields.get('name') ?? fallback.name, + description: fields.get('description') ?? fallback.description, + transport: fields.get('transport') ?? fallback.transport, + url: fields.get('url') ?? fallback.url, + headers: fields.get('headers') ?? fallback.headers, + command: fields.get('command') ?? fallback.command, + args: fields.get('args') ?? fallback.args, + env: fields.get('env') ?? fallback.env, + timeout: fields.get('timeout') ?? fallback.timeout, + retries: fields.get('retries') ?? fallback.retries, + enabled: fields.get('enabled') ?? fallback.enabled, + } +} + +function useMcpServerYjsFormData( + doc: Y.Doc | null, + fallback: McpServerFormData +): [McpServerFormData, (next: SetStateAction) => void] { + const subscribe = useMemo(() => { + if (!doc) return (cb: () => void) => () => {} + const fields = getFieldsMap(doc) + return (cb: () => void) => { + fields.observe(cb) + return () => fields.unobserve(cb) + } + }, [doc]) + const read = useCallback(() => readMcpFormData(doc, fallback), [doc, fallback]) + const formData = useYjsSubscription(subscribe, read, fallback) + const setFormData = useCallback( + (next: SetStateAction) => { + if (!doc) return + const value = typeof next === 'function' ? next(formData) : next + for (const [key, fieldValue] of Object.entries(value)) { + setEntityField(doc, key, fieldValue) + } + }, + [doc, formData] + ) + + return [formData, setFormData] +} + const refreshServerApi = async ( serverId: string, workspaceId: string, @@ -118,12 +171,10 @@ export function EditorMcpWidgetBody({ const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) const setPairContext = useSetPairColorContext() - const [formDataState, setFormDataState] = useState(() => - createDefaultMcpServerFormData() - ) const [saveError, setSaveError] = useState(null) const initialFormDataRef = useRef(createDefaultMcpServerFormData()) const initializedServerIdRef = useRef(null) + const defaultFormData = useMemo(() => createDefaultMcpServerFormData(), []) const { servers, isLoading: isServersLoading, @@ -164,6 +215,11 @@ export function EditorMcpWidgetBody({ ? (workspaceServers.find((server) => server.id === selectedServerId) ?? null) : null const selectedServerTools = selectedServerId ? getToolsByServer(selectedServerId) : [] + const serverSession = useSavedEntityYjsSession('mcp_server', selectedServerId, workspaceId) + const [formDataState, setFormDataState] = useMcpServerYjsFormData( + serverSession.doc, + defaultFormData + ) useEffect(() => { if (!workspaceId) return @@ -174,27 +230,23 @@ export function EditorMcpWidgetBody({ }, [fetchServers, workspaceId]) useEffect(() => { - if (!selectedServer) { + if (!selectedServerId || !serverSession.doc) { initializedServerIdRef.current = null - const emptyForm = createDefaultMcpServerFormData() - initialFormDataRef.current = emptyForm - setFormDataState(emptyForm) + initialFormDataRef.current = defaultFormData clearTestResult() setSaveError(null) return } - if (initializedServerIdRef.current === selectedServer.id) { + if (initializedServerIdRef.current === selectedServerId) { return } - const nextForm = createFormDataFromServer(selectedServer) - initializedServerIdRef.current = selectedServer.id - initialFormDataRef.current = nextForm - setFormDataState(nextForm) + initializedServerIdRef.current = selectedServerId + initialFormDataRef.current = formDataState clearTestResult() setSaveError(null) - }, [clearTestResult, selectedServer]) + }, [clearTestResult, defaultFormData, formDataState, selectedServerId, serverSession.doc]) useMcpSelectionPersistence({ onWidgetParamsChange, @@ -222,20 +274,20 @@ export function EditorMcpWidgetBody({ setFormDataState(initialFormDataRef.current) clearTestResult() setSaveError(null) - }, [clearTestResult]) + }, [clearTestResult, setFormDataState]) const handleTestConnection = useCallback(async () => { if (!workspaceId || !selectedServerId || !formDataState.url?.trim()) return await testConnection({ - name: formDataState.name.trim() || getServerName(selectedServer) || copy.unnamedServer, + name: formDataState.name.trim() || copy.unnamedServer, transport: formDataState.transport, url: formDataState.url, headers: createMcpSavePayload(formDataState).headers, timeout: formDataState.timeout, workspaceId, }) - }, [formDataState, selectedServer, selectedServerId, testConnection, workspaceId]) + }, [copy.unnamedServer, formDataState, selectedServerId, testConnection, workspaceId]) const handleRefreshTools = useCallback(async () => { if (!workspaceId || !selectedServerId) return @@ -259,7 +311,7 @@ export function EditorMcpWidgetBody({ ]) const handleSave = useCallback(async () => { - if (!workspaceId || !selectedServerId) return + if (!workspaceId || !selectedServerId || !serverSession.doc) return const payload = createMcpSavePayload(formDataState) if (!payload.name) { @@ -282,6 +334,7 @@ export function EditorMcpWidgetBody({ copy.serverNameRequired, fetchServers, formDataState, + serverSession.doc, selectedServerId, updateServer, workspaceId, @@ -325,6 +378,18 @@ export function EditorMcpWidgetBody({ return } + if (serverSession.error) { + return + } + + if (serverSession.isLoading) { + return ( +
+ +
+ ) + } + const displayStatus = selectedServer.connectionStatus ?? 'disconnected' return ( @@ -333,7 +398,7 @@ export function EditorMcpWidgetBody({

- {getServerName(selectedServer) || copy.unnamedServer} + {formDataState.name.trim() || copy.unnamedServer}

| null } -const sanitizeFileNameSegment = (value: string) => - value - .trim() - .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-') - .replace(/\s+/g, '-') - -const downloadJsonFile = (fileName: string, content: string) => { - const blob = new Blob([content], { type: 'application/json;charset=utf-8' }) - const blobUrl = URL.createObjectURL(blob) - const link = document.createElement('a') - - link.href = blobUrl - link.download = fileName - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - URL.revokeObjectURL(blobUrl) -} - export function SkillEditorExportButton({ workspaceId, skillId, @@ -110,68 +86,18 @@ export function SkillEditorExportButton({ const resolvedPairColor = (pairColor ?? 'gray') as PairColor const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) - const [isDirty, setIsDirty] = useState(true) const resolvedSkillId = isLinkedToColorPair ? (pairContext?.skillId ?? null) : (skillId ?? null) - const skill = useSkillsStore((state) => - workspaceId && resolvedSkillId ? state.readSkill(resolvedSkillId, workspaceId) : undefined - ) - - useSkillEditorState({ - panelId, - widget: widgetKey ? ({ key: widgetKey } as { key: string }) : null, - onStateChange: (detail) => { - setIsDirty(detail.isDirty) - }, - }) - - useEffect(() => { - setIsDirty(true) - }, [resolvedSkillId, workspaceId]) - - const fileName = useMemo(() => { - if (!skill?.name) { - return 'skill.json' - } - - const normalized = sanitizeFileNameSegment(skill.name) - return normalized.length > 0 ? `${normalized}.json` : 'skill.json' - }, [skill?.name]) - - const exportDisabled = !workspaceId || !resolvedSkillId || !skill || isDirty - const tooltipText = - exportDisabled && skill && isDirty ? copy.saveBeforeExporting : copy.exportSkill - - const handleExport = useCallback(() => { - if (!skill) return - - const json = exportSkillsAsJson({ - exportedFrom: 'skillEditor', - skills: [skill], - }) - - downloadJsonFile(fileName, json) - }, [fileName, skill]) + const exportDisabled = !workspaceId || !resolvedSkillId return ( - - - - - - - {tooltipText} - + emitSkillEditorAction({ action: 'export', panelId, widgetKey })} + /> ) } diff --git a/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx b/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx index 10457489b..62ad6c417 100644 --- a/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx @@ -1,14 +1,14 @@ 'use client' -import { useEffect, useRef, useState } from 'react' -import { useLocale } from 'next-intl' +import { useEffect, useRef } from 'react' import { LoadingAgent } from '@/components/ui/loading-agent' import { useMessages } from 'next-intl' import { useSkills } from '@/hooks/queries/skills' +import { useSavedEntityYjsSession } from '@/lib/yjs/use-entity-fields' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import type { PairColor } from '@/widgets/pair-colors' import type { WidgetComponentProps } from '@/widgets/types' -import { emitSkillEditorState, useSkillEditorActions } from '@/widgets/utils/skill-editor-actions' +import { useSkillEditorActions } from '@/widgets/utils/skill-editor-actions' import { useSkillSelectionPersistence } from '@/widgets/utils/skill-selection' import { getSkillIdFromParams } from '@/widgets/widgets/_shared/skill/utils' import { WidgetStateMessage } from '@/widgets/widgets/editor_indicator/components/widget-state-message' @@ -24,7 +24,6 @@ export function EditorSkillWidgetBody({ widget, onWidgetParamsChange, }: EditorSkillWidgetBodyProps) { - const locale = useLocale() const copy = useMessages().workspace.widgets.skillEditor.body const workspaceId = context?.workspaceId ?? null const { data: skills = [], isLoading, error } = useSkills(workspaceId ?? '') @@ -32,8 +31,8 @@ export function EditorSkillWidgetBody({ const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) const setPairContext = useSetPairColorContext() + const exportRef = useRef<() => void>(() => {}) const saveRef = useRef<() => void>(() => {}) - const [isDirty, setIsDirty] = useState(false) const paramsSkillId = getSkillIdFromParams(params) const requestedSkillId = isLinkedToColorPair ? (pairContext?.skillId ?? null) : paramsSkillId @@ -45,6 +44,7 @@ export function EditorSkillWidgetBody({ ? normalizedRequestedSkillId : (isLinkedToColorPair ? null : (skills[0]?.id ?? null)) const skill = skillId ? (skills.find((candidate) => candidate.id === skillId) ?? null) : null + const skillSession = useSavedEntityYjsSession('skill', skillId, workspaceId) useEffect(() => { if (!skillId) { @@ -94,31 +94,10 @@ export function EditorSkillWidgetBody({ useSkillEditorActions({ panelId, widget, + onExport: () => exportRef.current(), onSave: () => saveRef.current(), }) - useEffect(() => { - emitSkillEditorState({ - isDirty, - panelId, - widgetKey: widget?.key, - }) - - return () => { - emitSkillEditorState({ - isDirty: false, - panelId, - widgetKey: widget?.key, - }) - } - }, [isDirty, panelId, widget?.key]) - - useEffect(() => { - if (!skillId || !skill) { - setIsDirty(false) - } - }, [skill, skillId]) - if (!workspaceId) { return } @@ -157,18 +136,26 @@ export function EditorSkillWidgetBody({ return } + if (skillSession.error) { + return + } + + if (skillSession.isLoading) { + return ( +
+ +
+ ) + } + return (
) diff --git a/apps/tradinggoose/widgets/widgets/editor_skill/index.test.tsx b/apps/tradinggoose/widgets/widgets/editor_skill/index.test.tsx index 7ff6460d8..969a1279d 100644 --- a/apps/tradinggoose/widgets/widgets/editor_skill/index.test.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_skill/index.test.tsx @@ -6,8 +6,7 @@ import type { ReactNode } from 'react' import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useSkillsStore } from '@/stores/skills/store' -import { emitSkillEditorState } from '@/widgets/utils/skill-editor-actions' +import { SKILL_EDITOR_ACTION_EVENT, type SkillEditorActionEventDetail } from '@/widgets/events' import { editorSkillWidget } from '@/widgets/widgets/editor_skill' vi.mock('@/components/ui/tooltip', () => ({ @@ -24,20 +23,9 @@ const reactActEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } -const readBlobText = async (blob: Blob) => - await new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(String(reader.result ?? '')) - reader.onerror = () => reject(reader.error) - reader.readAsText(blob) - }) - describe('Skill Editor header controls', () => { let container: HTMLDivElement let root: Root - let createObjectUrlSpy: ReturnType - let revokeObjectUrlSpy: ReturnType - let clickSpy: ReturnType beforeEach(() => { vi.clearAllMocks() @@ -45,37 +33,6 @@ describe('Skill Editor header controls', () => { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) - - useSkillsStore.getState().resetAll() - useSkillsStore.getState().setSkills('workspace-1', [ - { - id: 'skill-1', - workspaceId: 'workspace-1', - userId: 'user-1', - name: 'Market Research', - description: 'Investigate the market.', - content: 'Use multiple trusted sources.', - createdAt: '2026-04-06T12:00:00.000Z', - updatedAt: '2026-04-06T12:00:00.000Z', - }, - ]) - - createObjectUrlSpy = vi.fn(() => 'blob:skill-export') - revokeObjectUrlSpy = vi.fn() - clickSpy = vi.fn() - - Object.defineProperty(globalThis.URL, 'createObjectURL', { - configurable: true, - value: createObjectUrlSpy, - }) - Object.defineProperty(globalThis.URL, 'revokeObjectURL', { - configurable: true, - value: revokeObjectUrlSpy, - }) - Object.defineProperty(HTMLAnchorElement.prototype, 'click', { - configurable: true, - value: clickSpy, - }) }) afterEach(() => { @@ -83,7 +40,6 @@ describe('Skill Editor header controls', () => { root.unmount() }) container.remove() - useSkillsStore.getState().resetAll() }) it('renders Export skill immediately left of Save skill', async () => { @@ -125,7 +81,12 @@ describe('Skill Editor header controls', () => { expect(buttons[0]?.hasAttribute('disabled')).toBe(true) }) - it('disables export while the editor is dirty and re-enables it when the editor becomes clean', async () => { + it('emits export for the selected skill', async () => { + const actionSpy = vi.fn() + const handler = (event: Event) => { + actionSpy((event as CustomEvent).detail) + } + window.addEventListener(SKILL_EDITOR_ACTION_EVENT, handler) const header = editorSkillWidget.renderHeader?.({ context: { workspaceId: 'workspace-1' } as any, panelId: 'panel-1', @@ -143,40 +104,24 @@ describe('Skill Editor header controls', () => { const buttons = Array.from(container.querySelectorAll('button')) const exportButton = buttons[0] - expect(exportButton?.hasAttribute('disabled')).toBe(true) - - await act(async () => { - emitSkillEditorState({ - isDirty: false, - panelId: 'panel-1', - widgetKey: 'editor_skill', - }) - }) - - expect(exportButton?.hasAttribute('disabled')).toBe(false) - await act(async () => { - emitSkillEditorState({ - isDirty: true, - panelId: 'panel-1', - widgetKey: 'editor_skill', - }) + exportButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) - expect(exportButton?.hasAttribute('disabled')).toBe(true) - - await act(async () => { - emitSkillEditorState({ - isDirty: false, - panelId: 'panel-1', - widgetKey: 'editor_skill', - }) + expect(actionSpy).toHaveBeenCalledWith({ + action: 'export', + panelId: 'panel-1', + widgetKey: 'editor_skill', }) - - expect(exportButton?.hasAttribute('disabled')).toBe(false) + window.removeEventListener(SKILL_EDITOR_ACTION_EVENT, handler) }) - it('downloads the unified export envelope for the selected skill', async () => { + it('emits save for the selected skill', async () => { + const actionSpy = vi.fn() + const handler = (event: Event) => { + actionSpy((event as CustomEvent).detail) + } + window.addEventListener(SKILL_EDITOR_ACTION_EVENT, handler) const header = editorSkillWidget.renderHeader?.({ context: { workspaceId: 'workspace-1' } as any, panelId: 'panel-1', @@ -192,43 +137,17 @@ describe('Skill Editor header controls', () => { }) const buttons = Array.from(container.querySelectorAll('button')) - const exportButton = buttons[0] + const saveButton = buttons[1] await act(async () => { - emitSkillEditorState({ - isDirty: false, - panelId: 'panel-1', - widgetKey: 'editor_skill', - }) + saveButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) - await act(async () => { - exportButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(createObjectUrlSpy).toHaveBeenCalledTimes(1) - expect(clickSpy).toHaveBeenCalledTimes(1) - expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:skill-export') - - const blob = createObjectUrlSpy.mock.calls[0]?.[0] as Blob - const payload = JSON.parse(await readBlobText(blob)) - - expect(payload).toMatchObject({ - version: '1', - fileType: 'tradingGooseExport', - exportedFrom: 'skillEditor', - resourceTypes: ['skills'], - skills: [ - { - name: 'Market Research', - description: 'Investigate the market.', - content: 'Use multiple trusted sources.', - }, - ], - workflows: [], - customTools: [], - watchlists: [], - indicators: [], + expect(actionSpy).toHaveBeenCalledWith({ + action: 'save', + panelId: 'panel-1', + widgetKey: 'editor_skill', }) + window.removeEventListener(SKILL_EDITOR_ACTION_EVENT, handler) }) }) diff --git a/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.test.tsx b/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.test.tsx index 77f6c527d..339762afc 100644 --- a/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.test.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.test.tsx @@ -6,6 +6,8 @@ import type { MutableRefObject } from 'react' import { act, createRef } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' +import { seedEntitySession } from '@/lib/yjs/entity-session' import { SkillEditor } from '@/widgets/widgets/editor_skill/skill-editor' const mockUseUpdateSkill = vi.fn() @@ -22,7 +24,7 @@ const reactActEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } -describe('SkillEditor dirty state', () => { +describe('SkillEditor save', () => { let container: HTMLDivElement let root: Root @@ -41,11 +43,19 @@ describe('SkillEditor dirty state', () => { container.remove() }) - it('returns to a clean state after a successful save', async () => { + it('saves the current Yjs fields', async () => { const mutateAsync = vi.fn().mockResolvedValue({}) - const onDirtyChange = vi.fn() + const exportRef = createRef<() => void>() const saveRef = createRef<() => void>() + const doc = new Y.Doc() + const initialValues = { + id: 'skill-1', + name: 'Market Research', + description: 'Investigate the market.', + content: 'Use multiple trusted sources.', + } saveRef.current = () => {} + seedEntitySession(doc, { entityKind: 'skill', payload: initialValues }) mockUseUpdateSkill.mockReturnValue({ isPending: false, @@ -56,14 +66,10 @@ describe('SkillEditor dirty state', () => { root.render( void>} saveRef={saveRef as MutableRefObject<() => void>} - onDirtyChange={onDirtyChange} - initialValues={{ - id: 'skill-1', - name: 'Market Research', - description: 'Investigate the market.', - content: 'Use multiple trusted sources.', - }} + skillId='skill-1' + doc={doc} /> ) }) @@ -78,8 +84,6 @@ describe('SkillEditor dirty state', () => { nameInput!.dispatchEvent(new Event('change', { bubbles: true })) }) - expect(onDirtyChange).toHaveBeenLastCalledWith(true) - await act(async () => { saveRef.current?.() await Promise.resolve() @@ -94,6 +98,6 @@ describe('SkillEditor dirty state', () => { content: 'Use multiple trusted sources.', }, }) - expect(onDirtyChange).toHaveBeenLastCalledWith(false) + doc.destroy() }) }) diff --git a/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.tsx b/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.tsx index 93f39d603..8b0cf30f7 100644 --- a/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.tsx @@ -1,82 +1,49 @@ import { type MutableRefObject, useCallback, useEffect, useState } from 'react' import { AlertTriangle } from 'lucide-react' +import type * as Y from 'yjs' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import { formatTemplate } from '@/i18n/utils' import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' import { createLogger } from '@/lib/logs/console/logger' -import { SKILL_NAME_MAX_LENGTH } from '@/lib/skills/import-export' +import { exportSkillsAsJson, SKILL_NAME_MAX_LENGTH } from '@/lib/skills/import-export' +import { useYjsStringField } from '@/lib/yjs/use-entity-fields' import { isValidSkillName, useUpdateSkill } from '@/hooks/queries/skills' -import { useSkillsStore } from '@/stores/skills/store' const logger = createLogger('SkillEditor') -interface SkillInitialValues { - id: string - name: string - description: string - content: string -} - interface SkillEditorProps { workspaceId: string - initialValues: SkillInitialValues + skillId: string + doc: Y.Doc | null + exportRef: MutableRefObject<() => void> saveRef: MutableRefObject<() => void> - onDirtyChange?: (isDirty: boolean) => void } export function SkillEditor({ workspaceId, - initialValues, + skillId, + doc, + exportRef, saveRef, - onDirtyChange, }: SkillEditorProps) { const copy = useWorkspaceWidgetsMessages().skillEditor - const [name, setName] = useState('') - const [description, setDescription] = useState('') - const [content, setContent] = useState('') + const [name, setName] = useYjsStringField(doc, 'name') + const [description, setDescription] = useYjsStringField(doc, 'description') + const [content, setContent] = useYjsStringField(doc, 'content') const [error, setError] = useState(null) const [isSaving, setIsSaving] = useState(false) - const [savedValues, setSavedValues] = useState({ - name: '', - description: '', - content: '', - }) const updateSkillMutation = useUpdateSkill() useEffect(() => { - const nextSavedValues = { - name: initialValues.name, - description: initialValues.description, - content: initialValues.content, - } - - setName(nextSavedValues.name) - setDescription(nextSavedValues.description) - setContent(nextSavedValues.content) - setSavedValues(nextSavedValues) setError(null) - }, [initialValues.content, initialValues.description, initialValues.id, initialValues.name]) - - useEffect(() => { - onDirtyChange?.( - name !== savedValues.name || - description !== savedValues.description || - content !== savedValues.content - ) - }, [ - content, - description, - name, - onDirtyChange, - savedValues.content, - savedValues.description, - savedValues.name, - ]) + }, [doc, skillId]) const handleSave = useCallback(async () => { + if (!doc) return + const trimmedName = name.trim() const trimmedDescription = description.trim() const trimmedContent = content.trim() @@ -101,27 +68,13 @@ export function SkillEditor({ return } - const existingSkills = useSkillsStore.getState().getAllSkills(workspaceId) - const isDuplicate = existingSkills.some((skill) => { - if (skill.id === initialValues.id) { - return false - } - - return skill.name === trimmedName - }) - - if (isDuplicate) { - setError(formatTemplate(copy.validation.duplicateName, { name: trimmedName })) - return - } - setIsSaving(true) setError(null) try { await updateSkillMutation.mutateAsync({ workspaceId, - skillId: initialValues.id, + skillId, updates: { name: trimmedName, description: trimmedDescription, @@ -132,19 +85,39 @@ export function SkillEditor({ setName(trimmedName) setDescription(trimmedDescription) setContent(trimmedContent) - setSavedValues({ - name: trimmedName, - description: trimmedDescription, - content: trimmedContent, - }) } catch (saveError) { const message = saveError instanceof Error ? saveError.message : copy.validation.saveFailed - logger.error('Failed to save skill', { error: saveError, skillId: initialValues.id }) + logger.error('Failed to save skill', { error: saveError, skillId }) setError(message) } finally { setIsSaving(false) } - }, [content, description, initialValues.id, name, updateSkillMutation, workspaceId]) + }, [content, description, doc, name, skillId, updateSkillMutation, workspaceId]) + + const handleExport = useCallback(() => { + if (!doc) return + const json = exportSkillsAsJson({ + exportedFrom: 'skillEditor', + skills: [{ name, description, content }], + }) + const fileNameBase = + name + .trim() + .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-') + .replace(/\s+/g, '-') || 'skill' + const blobUrl = URL.createObjectURL(new Blob([json], { type: 'application/json;charset=utf-8' })) + const link = document.createElement('a') + link.href = blobUrl + link.download = `${fileNameBase}.json` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(blobUrl) + }, [content, description, doc, name]) + + useEffect(() => { + exportRef.current = handleExport + }, [exportRef, handleExport]) useEffect(() => { saveRef.current = () => { @@ -162,7 +135,7 @@ export function SkillEditor({ value={name} onChange={(event) => setName(event.target.value)} placeholder={copy.form.namePlaceholder} - disabled={isSaving} + disabled={!doc || isSaving} maxLength={SKILL_NAME_MAX_LENGTH} />

@@ -177,7 +150,7 @@ export function SkillEditor({ value={description} onChange={(event) => setDescription(event.target.value)} placeholder={copy.form.descriptionPlaceholder} - disabled={isSaving} + disabled={!doc || isSaving} maxLength={1024} />

@@ -189,7 +162,7 @@ export function SkillEditor({ value={content} onChange={(event) => setContent(event.target.value)} placeholder={copy.form.instructionsPlaceholder} - disabled={isSaving} + disabled={!doc || isSaving} className='min-h-[320px] resize-y font-mono text-sm' maxLength={50000} /> From c39cd33b2b21a437167fe0945bfff46e90bda95a Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 14:14:53 -0600 Subject: [PATCH 056/284] feat(workflows): export referenced skills from Yjs state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/workflows/import-export.ts | 18 +--- .../stores/workflows/json/store.test.ts | 57 ++++++++++-- .../stores/workflows/json/store.ts | 87 ++++++++++++++----- .../export-controls/export-controls.tsx | 9 -- 4 files changed, 112 insertions(+), 59 deletions(-) diff --git a/apps/tradinggoose/lib/workflows/import-export.ts b/apps/tradinggoose/lib/workflows/import-export.ts index 5fd04f285..0150ffbbe 100644 --- a/apps/tradinggoose/lib/workflows/import-export.ts +++ b/apps/tradinggoose/lib/workflows/import-export.ts @@ -265,7 +265,7 @@ function readWorkflowSkillValues( ) } -function collectWorkflowSkillIds(state: WorkflowState): string[] { +export function collectWorkflowSkillIds(state: WorkflowState): string[] { const orderedSkillIds: string[] = [] const seenSkillIds = new Set() @@ -433,22 +433,6 @@ export function createWorkflowExportFile({ }) } -export function exportWorkflowAsJson({ - workflow, - skills = [], - exportedFrom = WORKFLOW_EXPORT_SOURCE, -}: { - workflow: { - name: string - description?: string | null - state: WorkflowState - } - skills?: WorkflowSkillSource[] - exportedFrom?: string -}): string { - return JSON.stringify(createWorkflowExportFile({ workflow, skills, exportedFrom }), null, 2) -} - export function parseImportedWorkflowFile(input: unknown): { data: WorkflowTransferRecord | null errors: string[] diff --git a/apps/tradinggoose/stores/workflows/json/store.test.ts b/apps/tradinggoose/stores/workflows/json/store.test.ts index 7bb8e239a..591c6b9f2 100644 --- a/apps/tradinggoose/stores/workflows/json/store.test.ts +++ b/apps/tradinggoose/stores/workflows/json/store.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' +import { seedEntitySession } from '@/lib/yjs/entity-session' const mockGetSnapshotForWorkflow = vi.hoisted(() => vi.fn()) +const mockBootstrapYjsProvider = vi.hoisted(() => vi.fn()) const mockWorkflowRegistryState = vi.hoisted(() => ({ workflows: { 'workflow-1': { @@ -18,6 +21,10 @@ vi.mock('@/lib/yjs/workflow-session-registry', () => ({ getSnapshotForWorkflow: mockGetSnapshotForWorkflow, })) +vi.mock('@/lib/yjs/provider', () => ({ + bootstrapYjsProvider: mockBootstrapYjsProvider, +})) + vi.mock('@/stores/workflows/registry/store', () => ({ useWorkflowRegistry: { getState: () => mockWorkflowRegistryState, @@ -29,6 +36,7 @@ import { useWorkflowJsonStore } from './store' describe('workflow json store', () => { beforeEach(() => { mockGetSnapshotForWorkflow.mockReset() + mockBootstrapYjsProvider.mockReset() useWorkflowJsonStore.setState({ json: '', lastGenerated: undefined, @@ -64,20 +72,43 @@ describe('workflow json store', () => { isDeployed: false, deployedAt: undefined, }) + mockBootstrapYjsProvider.mockImplementation(async ({ entityId }) => { + const doc = new Y.Doc() + seedEntitySession(doc, { + entityKind: 'skill', + payload: { + name: ' Market Research ', + description: ' Research the market before execution. ', + content: 'Review catalysts and confirm direction.', + }, + }) + return { + doc, + provider: { + disconnect: vi.fn(), + destroy: vi.fn(), + }, + descriptor: { + workspaceId: 'workspace-1', + entityKind: 'skill', + entityId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: entityId, + }, + runtime: { + docState: 'active', + replaySafe: true, + reseededFromCanonical: false, + }, + } + }) }) - it('threads workspace skills into the workflow export payload', async () => { + it('threads Yjs-backed skills into the workflow export payload', async () => { await useWorkflowJsonStore.getState().getJson({ workflowId: 'workflow-1', channelId: 'channel-1', - workspaceSkills: [ - { - id: 'skill-1', - name: ' Market Research ', - description: ' Research the market before execution. ', - content: 'Review catalysts and confirm direction.', - }, - ], }) const payload = JSON.parse(useWorkflowJsonStore.getState().json) as { @@ -108,6 +139,14 @@ describe('workflow json store', () => { } expect(payload.resourceTypes).toEqual(['workflows', 'skills']) + expect(mockBootstrapYjsProvider).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + entityKind: 'skill', + entityId: 'skill-1', + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: 'skill-1', + }) expect(payload.skills).toEqual([ { name: 'Market Research', diff --git a/apps/tradinggoose/stores/workflows/json/store.ts b/apps/tradinggoose/stores/workflows/json/store.ts index 5020fc270..2c75aa47a 100644 --- a/apps/tradinggoose/stores/workflows/json/store.ts +++ b/apps/tradinggoose/stores/workflows/json/store.ts @@ -1,10 +1,13 @@ import { createWithEqualityFn as create } from 'zustand/traditional' import { devtools } from 'zustand/middleware' import { createLogger } from '@/lib/logs/console/logger' -import { createWorkflowExportFile } from '@/lib/workflows/import-export' +import { + collectWorkflowSkillIds, + createWorkflowExportFile, +} from '@/lib/workflows/import-export' +import { getEntityFields } from '@/lib/yjs/entity-session' +import { bootstrapYjsProvider } from '@/lib/yjs/provider' import { getSnapshotForWorkflow } from '@/lib/yjs/workflow-session-registry' -import { useSkillsStore } from '@/stores/skills/store' -import type { SkillDefinition } from '@/stores/skills/types' import { useWorkflowRegistry } from '../registry/store' const logger = createLogger('WorkflowJsonStore') @@ -12,16 +15,55 @@ const logger = createLogger('WorkflowJsonStore') export interface WorkflowJsonScope { workflowId?: string | null channelId?: string - workspaceSkills?: Array> } interface WorkflowJsonStore { json: string lastGenerated?: number - generateJson: (scope?: WorkflowJsonScope) => void + generateJson: (scope?: WorkflowJsonScope) => Promise getJson: (scope?: WorkflowJsonScope) => Promise - refreshJson: (scope?: WorkflowJsonScope) => void + refreshJson: (scope?: WorkflowJsonScope) => Promise +} + +async function readWorkflowSkillExportsFromYjs( + workflowSnapshot: NonNullable>, + workspaceId: string | null | undefined +) { + const skillIds = collectWorkflowSkillIds(workflowSnapshot) + if (skillIds.length === 0) { + return [] + } + if (!workspaceId) { + return null + } + + return Promise.all( + skillIds.map(async (skillId) => { + const session = await bootstrapYjsProvider({ + workspaceId, + entityKind: 'skill', + entityId: skillId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: skillId, + }) + + try { + const fields = getEntityFields(session.doc, 'skill') + return { + id: skillId, + name: String(fields.name ?? ''), + description: String(fields.description ?? ''), + content: String(fields.content ?? ''), + } + } finally { + session.provider.disconnect() + session.provider.destroy() + session.doc.destroy() + } + }) + ) } export const useWorkflowJsonStore = create()( @@ -30,7 +72,7 @@ export const useWorkflowJsonStore = create()( json: '', lastGenerated: undefined, - generateJson: (scope) => { + generateJson: async (scope) => { const clearJson = () => set({ json: '', @@ -68,19 +110,16 @@ export const useWorkflowJsonStore = create()( return } - const workspaceSkills = - scope?.workspaceSkills ?? - (currentWorkflow.workspaceId - ? useSkillsStore - .getState() - .getAllSkills(currentWorkflow.workspaceId) - .map((skill) => ({ - id: skill.id, - name: skill.name, - description: skill.description, - content: skill.content, - })) - : []) + const workflowSkills = await readWorkflowSkillExportsFromYjs( + workflowSnapshot, + currentWorkflow.workspaceId + ) + + if (!workflowSkills) { + logger.warn('Workflow workspace missing for skill export:', activeWorkflowId) + clearJson() + return + } const exportFile = createWorkflowExportFile({ workflow: { @@ -88,7 +127,7 @@ export const useWorkflowJsonStore = create()( description: currentWorkflow.description ?? '', state: workflowSnapshot, }, - skills: workspaceSkills, + skills: workflowSkills, }) // Convert to formatted JSON @@ -123,15 +162,15 @@ export const useWorkflowJsonStore = create()( // Scoped requests are always refreshed to avoid channel/workflow cache mismatch. // Unscoped requests keep the short cache to reduce repeated work. if (hasScope || !lastGenerated || currentTime - lastGenerated > 1000) { - get().generateJson(scope) + await get().generateJson(scope) return get().json } return json }, - refreshJson: (scope) => { - get().generateJson(scope) + refreshJson: async (scope) => { + await get().generateJson(scope) }, }), { diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/export-controls/export-controls.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/export-controls/export-controls.tsx index ddef3c815..5e16b0a8b 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/export-controls/export-controls.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/export-controls/export-controls.tsx @@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { widgetHeaderIconButtonClassName } from '@/components/widget-header-control' import { createLogger } from '@/lib/logs/console/logger' -import { useSkills } from '@/hooks/queries/skills' import { useWorkflowJsonStore } from '@/stores/workflows/json/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowEditorCopy } from '@/widgets/widgets/editor_workflow/copy' @@ -29,10 +28,6 @@ export function ExportControls({ disabled = false, variant = 'workspace' }: Expo const { getJson: readWorkflowExportJson } = useWorkflowJsonStore() const currentWorkflow = workflowId ? workflows[workflowId] : null - const workflowWorkspaceId = currentWorkflow?.workspaceId ?? null - const { data: workspaceSkills = [], refetch: refetchWorkspaceSkills } = useSkills( - workflowWorkspaceId ?? '' - ) const downloadFile = (content: string, filename: string, mimeType: string) => { try { @@ -58,13 +53,9 @@ export function ExportControls({ disabled = false, variant = 'workspace' }: Expo setIsExporting(true) try { - const refreshedSkills = workflowWorkspaceId ? await refetchWorkspaceSkills() : null - const exportWorkspaceSkills = refreshedSkills?.data ?? workspaceSkills - const jsonContent = await readWorkflowExportJson({ workflowId, channelId, - workspaceSkills: exportWorkspaceSkills, }) if (!jsonContent) { From e78ab77e8a476b78c922283c3586b8db43fa3fef Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 14:15:05 -0600 Subject: [PATCH 057/284] fix(mcp): consume device login approvals when issuing keys Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/mcp/auth.ts | 53 +++++-------------------------- 1 file changed, 8 insertions(+), 45 deletions(-) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index c924825ad..99a14dce5 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -3,7 +3,7 @@ import { db } from '@tradinggoose/db' import { apiKey, verification } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' -import { createApiKeyMaterial, decryptApiKey } from '@/lib/api-key/service' +import { createApiKeyMaterial } from '@/lib/api-key/service' const DEVICE_LOGIN_TTL_MS = 10 * 60 * 1000 const DEVICE_LOGIN_PREFIX = 'mcp:' @@ -23,13 +23,6 @@ type ApprovedDeviceLogin = { verificationKeyHash: string approvedAt: string userId: string - keyId?: string - apiKeyEncrypted?: string -} - -type IssuedDeviceLogin = ApprovedDeviceLogin & { - keyId: string - apiKeyEncrypted: string } type DeviceLoginState = PendingDeviceLogin | ApprovedDeviceLogin @@ -65,14 +58,6 @@ function hashValue(value: string) { return createHash('sha256').update(value).digest('hex') } -function isIssuedDeviceLogin(state: DeviceLoginState): state is IssuedDeviceLogin { - return ( - state.status === 'approved' && - typeof state.keyId === 'string' && - typeof state.apiKeyEncrypted === 'string' - ) -} - function deviceLoginMatches(login: DeviceLogin, state = login.state) { return and(eq(verification.id, login.id), eq(verification.value, JSON.stringify(state))) } @@ -89,16 +74,12 @@ function parseDeviceLoginState(value: string): DeviceLoginState | null { ) { return parsed as PendingDeviceLogin } - const approvedHasNoKey = parsed.keyId === undefined && parsed.apiKeyEncrypted === undefined - const approvedHasIssuedKey = - typeof parsed.keyId === 'string' && typeof parsed.apiKeyEncrypted === 'string' if ( parsed.status === 'approved' && typeof parsed.createdAt === 'string' && typeof parsed.verificationKeyHash === 'string' && typeof parsed.approvedAt === 'string' && - typeof parsed.userId === 'string' && - (approvedHasNoKey || approvedHasIssuedKey) + typeof parsed.userId === 'string' ) { return parsed as ApprovedDeviceLogin } @@ -163,35 +144,25 @@ async function issueDeviceLoginPersonalApiKey( throw new Error('Failed to create MCP personal API key') } - const nextState = { - ...approvedState, - keyId, - apiKeyEncrypted: encryptedKey, - } satisfies IssuedDeviceLogin - const now = new Date() const issued = await db.transaction(async (tx) => { - const [updated] = await tx - .update(verification) - .set({ - value: JSON.stringify(nextState), - updatedAt: now, - }) + const [deleted] = await tx + .delete(verification) .where(deviceLoginMatches(login, approvedState)) .returning({ id: verification.id }) - if (!updated) { + if (!deleted) { return null } const [createdKey] = await tx .insert(apiKey) .values({ - id: nextState.keyId, - userId: nextState.userId, + id: keyId, + userId: approvedState.userId, workspaceId: null, name: `TradingGoose MCP Access ${now.toISOString()}`, - key: nextState.apiKeyEncrypted, + key: encryptedKey, type: 'personal', createdAt: now, updatedAt: now, @@ -296,14 +267,6 @@ export async function pollMcpDeviceLogin( const approvedState = login.state - if (isIssuedDeviceLogin(approvedState)) { - return { - status: 'approved', - apiKey: (await decryptApiKey(approvedState.apiKeyEncrypted)).decrypted, - expiresAt: login.expiresAt.toISOString(), - } - } - const issued = await issueDeviceLoginPersonalApiKey(login, approvedState) if (!issued) { continue From d984f062dac1b0dc70fbd15e81a346a2274a9135 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 14:15:17 -0600 Subject: [PATCH 058/284] fix(yjs): fall back to direct state persistence on apply errors Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/yjs/server/apply-entity-state.ts | 12 ++---------- .../lib/yjs/server/apply-workflow-state.ts | 12 ++---------- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index 382562649..a3668937b 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -8,13 +8,9 @@ import { } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' import * as Y from 'yjs' -import { getRedisStorageMode } from '@/lib/redis' import { seedEntitySession } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' -import { - applyEntityStateInSocketServer, - SocketServerBridgeError, -} from '@/lib/yjs/server/snapshot-bridge' +import { applyEntityStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { storeCanonicalState } from '@/socket-server/yjs/persistence' function parseObjectJson(value: unknown, fieldName: string): Record { @@ -123,11 +119,7 @@ export async function applySavedEntityState( ): Promise { try { await applyEntityStateInSocketServer(entityId, entityKind, fields) - } catch (error) { - if (error instanceof SocketServerBridgeError || getRedisStorageMode() !== 'redis') { - throw error - } - + } catch { const doc = new Y.Doc() try { seedEntitySession(doc, { entityKind, payload: fields }) diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index ada9f71a6..2e753ee74 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -1,12 +1,8 @@ import { db, workflow } from '@tradinggoose/db' import { eq } from 'drizzle-orm' import * as Y from 'yjs' -import { getRedisStorageMode } from '@/lib/redis' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' -import { - applyWorkflowStateInSocketServer, - SocketServerBridgeError, -} from '@/lib/yjs/server/snapshot-bridge' +import { applyWorkflowStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot, replaceWorkflowDocumentState, @@ -42,11 +38,7 @@ async function applyWorkflowStateToYjs( ) { try { await applyWorkflowStateInSocketServer(workflowId, workflowState, variables, entityName) - } catch (error) { - if (error instanceof SocketServerBridgeError || getRedisStorageMode() !== 'redis') { - throw error - } - + } catch { await storeWorkflowStateDirectly(workflowId, workflowState, variables, entityName) } } From ee6fd42c714993266566dd5cab79d5bc91f7faef Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 14:15:27 -0600 Subject: [PATCH 059/284] fix(custom-tools): reject duplicate titles on update Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/custom-tools/operations.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index d9d9d3dc2..c0b9d8d24 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -72,6 +72,16 @@ export async function upsertCustomTools({ .limit(1) if (existingTool.length > 0) { + const duplicateTitle = await tx + .select({ id: customTools.id }) + .from(customTools) + .where(and(eq(customTools.workspaceId, workspaceId), eq(customTools.title, tool.title))) + .limit(1) + + if (duplicateTitle.length > 0 && duplicateTitle[0].id !== tool.id) { + throw new Error(`A tool with the title "${tool.title}" already exists in this workspace`) + } + logger.info(`[${requestId}] Updated custom tool ${tool.id}`) updatedRows.push({ ...existingTool[0], From 20ac127289fe2bc48a8648a13bc2390f0bc6eb9d Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 14:15:38 -0600 Subject: [PATCH 060/284] fix(copilot): omit generated workflow ids from review staging Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/copilot/tools/server/entities/workflow.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index 87951195c..83aa55201 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -441,25 +441,25 @@ export const createWorkflowServerTool: BaseServerTool< }, 'write' ) - const workflowId = crypto.randomUUID() - const now = new Date() const name = args.name?.trim() || 'New workflow' - const description = typeof args.description === 'string' ? args.description : 'New workflow' - const color = getStableVibrantColor(workflowId) - const workflowState = createWorkflowSnapshot() if (shouldStageServerToolMutationForReview(context)) { return { requiresReview: true, success: true, entityKind: ENTITY_KIND_WORKFLOW, - entityId: workflowId, entityName: name, workspaceId, reviewBaseStateHash: workspaceId, } } + const workflowId = crypto.randomUUID() + const now = new Date() + const description = typeof args.description === 'string' ? args.description : 'New workflow' + const color = getStableVibrantColor(workflowId) + const workflowState = createWorkflowSnapshot() + await db.insert(workflow).values({ id: workflowId, userId, From 8dfcd65a3d298b44d826e53069af712be7912b83 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 14:38:20 -0600 Subject: [PATCH 061/284] fix(copilot): require workflow variable ids in documents Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/copilot/registry.ts | 2 +- .../server/entities/workflow-variable.test.ts | 23 +++++++++++++++---- .../copilot/tools/server/entities/workflow.ts | 18 +++++++-------- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/registry.ts b/apps/tradinggoose/lib/copilot/registry.ts index 365c48848..a044cb6e6 100644 --- a/apps/tradinggoose/lib/copilot/registry.ts +++ b/apps/tradinggoose/lib/copilot/registry.ts @@ -254,7 +254,7 @@ const EditWorkflowVariableArgs = EntityTargetArgs.extend({ .string() .min(1) .describe( - 'Full `tg-workflow-variable-document-v1` JSON document for workflow variables: {"variables":[{"name":"riskLimit","type":"number","value":100}]}. This is a full replacement document; omit a variable to delete it.' + 'Full `tg-workflow-variable-document-v1` JSON document for workflow variables. Preserve existing `variableId` values from `read_workflow`; choose a new unique `variableId` only for a new variable: {"variables":[{"variableId":"var-risk-limit","name":"riskLimit","type":"number","value":100}]}. This is a full replacement document; omit a variable to delete it.' ), documentFormat: z.literal(WORKFLOW_VARIABLE_DOCUMENT_FORMAT).optional(), }).strict() diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts index 46c2a2577..d038150be 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts @@ -80,9 +80,6 @@ describe('workflow variable server tools', () => { }, }), }) - vi.stubGlobal('crypto', { - randomUUID: vi.fn(() => 'var-2'), - }) }) it('returns workflow variables through read_workflow', async () => { @@ -105,7 +102,7 @@ describe('workflow variable server tools', () => { entityDocument: JSON.stringify({ variables: [ { variableId: 'var-1', name: 'riskLimit', type: 'number', value: 25 }, - { name: 'enabled', type: 'boolean', value: true }, + { variableId: 'var-2', name: 'enabled', type: 'boolean', value: true }, ], }), }, @@ -148,7 +145,7 @@ describe('workflow variable server tools', () => { entityDocument: JSON.stringify({ variables: [ { variableId: 'var-1', name: 'riskLimit', type: 'number', value: 25 }, - { name: 'enabled', type: 'boolean', value: true }, + { variableId: 'var-2', name: 'enabled', type: 'boolean', value: true }, ], }), }, @@ -174,4 +171,20 @@ describe('workflow variable server tools', () => { ) }) + it('rejects replacement documents that omit variable ids', async () => { + await expect( + editWorkflowVariableServerTool.execute( + { + entityId: 'wf-1', + documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, + entityDocument: JSON.stringify({ + variables: [{ name: 'riskLimit', type: 'number', value: 25 }], + }), + }, + { userId: 'user-1', accessLevel: 'full' } + ) + ).rejects.toThrow() + + expect(mockApplyWorkflowState).not.toHaveBeenCalled() + }) }) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index 83aa55201..ab0901a0c 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -69,7 +69,7 @@ type WorkflowSummary = { } type WorkflowVariableDocumentEntry = { - variableId?: string + variableId: string name: string type: WorkflowVariableType value?: unknown @@ -79,7 +79,7 @@ const WorkflowVariableDocumentSchema = z .object({ variables: z.array( z.object({ - variableId: z.string().trim().min(1).optional(), + variableId: z.string().trim().min(1), name: z.string().trim().min(1), type: z.string().trim().min(1), value: z.unknown().optional(), @@ -253,13 +253,11 @@ function parseWorkflowVariableDocument(entityDocument: string): WorkflowVariable const seenVariableIds = new Set() return parsed.variables.map((variable) => { - const variableId = variable.variableId?.trim() - if (variableId) { - if (seenVariableIds.has(variableId)) { - throw new Error(`Duplicate workflow variableId: ${variableId}`) - } - seenVariableIds.add(variableId) + const variableId = variable.variableId.trim() + if (seenVariableIds.has(variableId)) { + throw new Error(`Duplicate workflow variableId: ${variableId}`) } + seenVariableIds.add(variableId) const name = variable.name.trim() if (seenNames.has(name)) { @@ -272,7 +270,7 @@ function parseWorkflowVariableDocument(entityDocument: string): WorkflowVariable } return { - ...(variableId ? { variableId } : {}), + variableId, name, type: variable.type, value: variable.value, @@ -301,7 +299,7 @@ function buildWorkflowVariablesFromDocument(input: { return Object.fromEntries( entries.map((entry) => { - const id = entry.variableId ?? crypto.randomUUID() + const id = entry.variableId return [ id, { From 560b54999d95c4119fe62dc64c37e6e9f2c0b175 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 14:39:00 -0600 Subject: [PATCH 062/284] style(copilot): wrap copilot registry assertions Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/stores/copilot/store.ts | 32 ++++++++----------- .../stores/copilot/tool-registry.test.ts | 12 +++++-- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/apps/tradinggoose/stores/copilot/store.ts b/apps/tradinggoose/stores/copilot/store.ts index 7c704418f..85525563b 100644 --- a/apps/tradinggoose/stores/copilot/store.ts +++ b/apps/tradinggoose/stores/copilot/store.ts @@ -1404,20 +1404,19 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) if (acceptingServerReview && !reviewToken) { throw new Error('Server tool review token is missing') } - const result = - acceptingServerReview - ? await acceptCopilotServerToolReview({ - toolName: name, - reviewToken: reviewToken!, - context: serverContext, - signal: get().abortController?.signal, - }) - : await executeCopilotServerTool({ - toolName: name, - payload: preparedArgs, - context: serverContext, - signal: get().abortController?.signal, - }) + const result = acceptingServerReview + ? await acceptCopilotServerToolReview({ + toolName: name, + reviewToken: reviewToken!, + context: serverContext, + signal: get().abortController?.signal, + }) + : await executeCopilotServerTool({ + toolName: name, + payload: preparedArgs, + context: serverContext, + signal: get().abortController?.signal, + }) const logicalSuccess = !result || typeof result !== 'object' || @@ -1425,10 +1424,7 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) (result as any).success !== false const currentToolCall = get().toolCallsById[id] - if ( - isToolCallCompletionProtected(currentToolCall?.state) && - !acceptingServerReview - ) { + if (isToolCallCompletionProtected(currentToolCall?.state) && !acceptingServerReview) { return } diff --git a/apps/tradinggoose/stores/copilot/tool-registry.test.ts b/apps/tradinggoose/stores/copilot/tool-registry.test.ts index ee1c15a84..535baaf36 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.test.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.test.ts @@ -136,8 +136,12 @@ describe('tool-registry', () => { it('keeps saved entity and workflow document tools off the client-staged execution path', () => { expect(ensureClientToolInstance('create_workflow', 'create-workflow-tool')).toBeUndefined() expect(ensureClientToolInstance('edit_workflow', 'edit-workflow-tool')).toBeUndefined() - expect(ensureClientToolInstance('edit_workflow_block', 'edit-workflow-block-tool')).toBeUndefined() - expect(ensureClientToolInstance('edit_workflow_variable', 'edit-workflow-variable-tool')).toBeUndefined() + expect( + ensureClientToolInstance('edit_workflow_block', 'edit-workflow-block-tool') + ).toBeUndefined() + expect( + ensureClientToolInstance('edit_workflow_variable', 'edit-workflow-variable-tool') + ).toBeUndefined() expect(ensureClientToolInstance('rename_workflow', 'rename-workflow-tool')).toBeUndefined() expect(ensureClientToolInstance('read_workflow', 'read-workflow-tool')).toBeUndefined() expect(ensureClientToolInstance('list_workflows', 'list-workflows-tool')).toBeUndefined() @@ -157,7 +161,9 @@ describe('tool-registry', () => { expect( ensureClientToolInstance('check_deployment_status', 'check-deployment-status-tool') ).toBeUndefined() - expect(ensureClientToolInstance('read_block_outputs', 'read-block-outputs-tool')).toBeUndefined() + expect( + ensureClientToolInstance('read_block_outputs', 'read-block-outputs-tool') + ).toBeUndefined() expect( ensureClientToolInstance( 'read_block_upstream_references', From cce91dbec44bf1d66b1474a042baac2f30efa6e3 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 14:39:09 -0600 Subject: [PATCH 063/284] feat(copilot): refresh state after server tool mutations Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/query-provider.tsx | 24 +++---- apps/tradinggoose/stores/copilot/store.ts | 2 +- .../stores/copilot/tool-registry.test.ts | 39 +++++++++++- .../stores/copilot/tool-registry.ts | 62 +++++++++++++++++-- 4 files changed, 108 insertions(+), 19 deletions(-) diff --git a/apps/tradinggoose/app/query-provider.tsx b/apps/tradinggoose/app/query-provider.tsx index 1d73c209e..298cf723a 100644 --- a/apps/tradinggoose/app/query-provider.tsx +++ b/apps/tradinggoose/app/query-provider.tsx @@ -1,20 +1,20 @@ 'use client' import type { ReactNode } from 'react' -import { useState } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -export function QueryProvider({ children }: { children: ReactNode }) { - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - }, - }, - }) - ) +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, + }, +}) + +export function getQueryClient() { + return queryClient +} +export function QueryProvider({ children }: { children: ReactNode }) { return {children} } diff --git a/apps/tradinggoose/stores/copilot/store.ts b/apps/tradinggoose/stores/copilot/store.ts index 85525563b..8372c2361 100644 --- a/apps/tradinggoose/stores/copilot/store.ts +++ b/apps/tradinggoose/stores/copilot/store.ts @@ -1448,7 +1448,7 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) ) if (logicalSuccess) { - await handleCopilotServerToolSuccess(name) + await handleCopilotServerToolSuccess(name, result, serverContext) } const completionMessage = diff --git a/apps/tradinggoose/stores/copilot/tool-registry.test.ts b/apps/tradinggoose/stores/copilot/tool-registry.test.ts index 535baaf36..ada4c9406 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.test.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.test.ts @@ -1,15 +1,24 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getQueryClient } from '@/app/query-provider' +import { skillsKeys } from '@/hooks/queries/skills' +import { workflowKeys } from '@/hooks/queries/workflows' import { createExecutionContext, ensureClientToolInstance, getToolInterruptDisplays, + handleCopilotServerToolSuccess, isGatedTool, prepareCopilotToolArgs, } from '@/stores/copilot/tool-registry' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' describe('tool-registry', () => { const toolCallId = 'tool-registry-edit-workflow' + beforeEach(() => { + vi.restoreAllMocks() + }) + it('keeps workflow edit tools server-managed while exposing review interrupts from metadata', () => { expect(ensureClientToolInstance('edit_workflow', toolCallId)).toBeUndefined() expect(getToolInterruptDisplays('edit_workflow', toolCallId)).toBeDefined() @@ -171,4 +180,32 @@ describe('tool-registry', () => { ) ).toBeUndefined() }) + + it('refreshes workflow registry and list query after server-managed workflow mutations', async () => { + const loadWorkflows = vi + .spyOn(useWorkflowRegistry.getState(), 'loadWorkflows') + .mockResolvedValue(undefined) + const invalidateQueries = vi + .spyOn(getQueryClient(), 'invalidateQueries') + .mockResolvedValue(undefined) + + await handleCopilotServerToolSuccess('create_workflow', { workspaceId: 'workspace-1' }) + + expect(loadWorkflows).toHaveBeenCalledWith({ workspaceId: 'workspace-1' }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: workflowKeys.list('workspace-1'), + }) + }) + + it('invalidates saved-entity list queries after server-managed saved-entity mutations', async () => { + const invalidateQueries = vi + .spyOn(getQueryClient(), 'invalidateQueries') + .mockResolvedValue(undefined) + + await handleCopilotServerToolSuccess('edit_skill', { workspaceId: 'workspace-1' }) + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: skillsKeys.list('workspace-1'), + }) + }) }) diff --git a/apps/tradinggoose/stores/copilot/tool-registry.ts b/apps/tradinggoose/stores/copilot/tool-registry.ts index a93b46673..8b6f00262 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.ts @@ -17,8 +17,16 @@ import { SERVER_TOOL_METADATA } from '@/lib/copilot/tools/client/server-tool-met import { DeployWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/deploy-workflow' import { RunWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/run-workflow' import { createLogger } from '@/lib/logs/console/logger' +import { getQueryClient } from '@/app/query-provider' +import { customToolsKeys } from '@/hooks/queries/custom-tools' +import { indicatorKeys } from '@/hooks/queries/indicators' +import { knowledgeKeys } from '@/hooks/queries/knowledge' +import { skillsKeys } from '@/hooks/queries/skills' +import { workflowKeys } from '@/hooks/queries/workflows' import type { CopilotToolExecutionProvenance } from '@/stores/copilot/types' +import { useMcpServersStore } from '@/stores/mcp-servers/store' import { useEnvironmentStore } from '@/stores/settings/environment/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('CopilotToolRegistry') @@ -272,15 +280,59 @@ export function prepareCopilotToolArgs( return ToolArgSchemas[toolName].parse(clonedArgs) as Record } -export async function handleCopilotServerToolSuccess(toolName: string | undefined): Promise { - if (toolName !== CopilotTool.set_environment_variables) { - return +type ServerToolSuccessContext = { + workspaceId?: string +} + +function readResultWorkspaceId(result: unknown, context?: ServerToolSuccessContext) { + if (result && typeof result === 'object' && !Array.isArray(result)) { + const workspaceId = (result as { workspaceId?: unknown }).workspaceId + if (typeof workspaceId === 'string' && workspaceId.trim()) { + return workspaceId + } } + return context?.workspaceId +} + +export async function handleCopilotServerToolSuccess( + toolName: string | undefined, + result?: unknown, + context?: ServerToolSuccessContext +): Promise { + const workspaceId = readResultWorkspaceId(result, context) try { - await useEnvironmentStore.getState().loadEnvironmentVariables() + if (toolName === CopilotTool.set_environment_variables) { + await useEnvironmentStore.getState().loadEnvironmentVariables() + return + } + + if (!workspaceId || !toolName || !/^(create|edit|rename)_/.test(toolName)) { + return + } + + const queryClient = getQueryClient() + if (toolName === CopilotTool.create_workflow || toolName === CopilotTool.rename_workflow) { + await Promise.all([ + useWorkflowRegistry.getState().loadWorkflows({ workspaceId }), + queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId) }), + ]) + } else if (toolName.endsWith('_skill')) { + await queryClient.invalidateQueries({ queryKey: skillsKeys.list(workspaceId) }) + } else if (toolName.endsWith('_custom_tool')) { + await queryClient.invalidateQueries({ queryKey: customToolsKeys.list(workspaceId) }) + } else if (toolName.endsWith('_indicator')) { + await queryClient.invalidateQueries({ queryKey: indicatorKeys.list(workspaceId) }) + } else if (toolName.endsWith('_knowledge_base')) { + await queryClient.invalidateQueries({ queryKey: knowledgeKeys.list(workspaceId) }) + } else if (toolName.endsWith('_mcp_server')) { + await useMcpServersStore.getState().fetchServers(workspaceId) + } } catch (error) { - logger.warn('Failed to refresh environment store after setting variables', { error }) + logger.warn('Failed to refresh client state after server-managed tool success', { + toolName, + error, + }) } } From 420d525609e34d170ed997709f3a94ffe54c260e Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 15:05:14 -0600 Subject: [PATCH 064/284] fix(copilot): serialize server tool review acceptance Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../copilot/tools/server/review-acceptance.ts | 82 ++++++++++++------- 1 file changed, 53 insertions(+), 29 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts index d8660f22c..3037766ef 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts @@ -1,21 +1,31 @@ import { createHash } from 'crypto' import { db } from '@tradinggoose/db' import { verification } from '@tradinggoose/db/schema' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import { MCP_SERVER_DOCUMENT_FORMAT, parseEntityDocument, serializeEntityDocumentForReview, } from '@/lib/copilot/entity-documents' -import { type ToolId } from '@/lib/copilot/registry' +import type { ToolId } from '@/lib/copilot/registry' import { StructuredServerToolError } from '@/lib/copilot/server-tool-errors' import type { ServerToolExecutionContext } from '@/lib/copilot/tools/server/base-tool' import { routeExecution } from '@/lib/copilot/tools/server/router' +import { createLogger } from '@/lib/logs/console/logger' import { decryptSecret, encryptSecret } from '@/lib/utils-server' const REVIEW_TOKEN_PREFIX = 'copilot-tool-review:' const REVIEW_TOKEN_TTL_MS = 30 * 60 * 1000 +const logger = createLogger('ServerToolReviewAcceptance') + +type StagedServerToolReview = { + userId?: unknown + toolName?: unknown + encryptedPayload?: unknown + baseSignature?: unknown + reviewClaimId?: unknown +} function hashValue(value: string) { return createHash('sha256').update(value).digest('hex') @@ -59,10 +69,7 @@ function redactReviewSecrets(result: unknown) { } const record = result as Record - if ( - record.entityKind !== 'mcp_server' && - record.documentFormat !== MCP_SERVER_DOCUMENT_FORMAT - ) { + if (record.entityKind !== 'mcp_server' && record.documentFormat !== MCP_SERVER_DOCUMENT_FORMAT) { return result } @@ -76,9 +83,7 @@ function redactReviewSecrets(result: unknown) { ...preview, documentDiff: { ...(documentDiff as Record), - before: redactMcpServerReviewDocument( - (documentDiff as { before?: unknown }).before - ), + before: redactMcpServerReviewDocument((documentDiff as { before?: unknown }).before), after: redactMcpServerReviewDocument((documentDiff as { after?: unknown }).after), }, } @@ -154,19 +159,9 @@ export async function acceptServerManagedToolReview( throw new Error('Server tool review token is invalid or expired') } - let staged: { - userId?: unknown - toolName?: unknown - encryptedPayload?: unknown - baseSignature?: unknown - } + let staged: StagedServerToolReview try { - staged = JSON.parse(row.value) as { - userId?: unknown - toolName?: unknown - encryptedPayload?: unknown - baseSignature?: unknown - } + staged = JSON.parse(row.value) as StagedServerToolReview } catch { throw new Error('Server tool review token is invalid or expired') } @@ -182,6 +177,9 @@ export async function acceptServerManagedToolReview( if (typeof staged.encryptedPayload !== 'string' || !staged.encryptedPayload) { throw new Error('Server tool review token is invalid or expired') } + if (typeof staged.reviewClaimId === 'string') { + throw new Error('Server tool review token is already being accepted') + } const { decrypted } = await decryptSecret(staged.encryptedPayload) const payload = JSON.parse(decrypted) @@ -201,17 +199,43 @@ export async function acceptServerManagedToolReview( }) } - const [deleted] = await db - .delete(verification) - .where(eq(verification.identifier, `${REVIEW_TOKEN_PREFIX}${reviewToken}`)) + const identifier = `${REVIEW_TOKEN_PREFIX}${reviewToken}` + const claimed = { ...staged, reviewClaimId: nanoid() } + const claimedValue = JSON.stringify(claimed) + const [claimedRow] = await db + .update(verification) + .set({ value: claimedValue, updatedAt: new Date() }) + .where(and(eq(verification.identifier, identifier), eq(verification.value, row.value))) .returning({ id: verification.id }) - if (!deleted) { + if (!claimedRow) { throw new Error('Server tool review token is invalid or expired') } - const acceptedResult = await routeExecution(toolName, payload, { - ...context, - accessLevel: 'full', - }) + let acceptedResult: unknown + try { + acceptedResult = await routeExecution(toolName, payload, { + ...context, + accessLevel: 'full', + }) + } catch (error) { + await db + .update(verification) + .set({ value: row.value, updatedAt: new Date() }) + .where(and(eq(verification.identifier, identifier), eq(verification.value, claimedValue))) + .catch((restoreError) => { + logger.warn('Failed to restore server tool review token after acceptance failure', { + error: restoreError, + toolName, + }) + }) + throw error + } + + await db + .delete(verification) + .where(and(eq(verification.identifier, identifier), eq(verification.value, claimedValue))) + .catch((error) => { + logger.warn('Failed to delete accepted server tool review token', { error, toolName }) + }) return redactReviewSecrets(acceptedResult) } From db6b07c6b67d1d16df86a052689d951961f7b7c8 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 15:05:33 -0600 Subject: [PATCH 065/284] fix(workflows): persist normalized workflow state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../yjs/server/apply-workflow-state.test.ts | 74 +++++++++++++------ .../lib/yjs/server/apply-workflow-state.ts | 30 +++++++- 2 files changed, 79 insertions(+), 25 deletions(-) diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts index 7491ce7b7..a2646466d 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts @@ -2,35 +2,30 @@ * @vitest-environment node */ -import * as Y from 'yjs' import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' const { - MockSocketServerBridgeError, mockApplyWorkflowStateInSocketServer, mockDbUpdate, + mockEnsureUniqueBlockIds, + mockEnsureUniqueEdgeIds, mockGetState, - mockGetRedisStorageMode, mockSaveWorkflowToNormalizedTables, mockStoreCanonicalState, + mockUpdateReturning, mockUpdateSet, mockUpdateWhere, } = vi.hoisted(() => { - class MockSocketServerBridgeError extends Error { - constructor() { - super('Socket server bridge failed') - this.name = 'SocketServerBridgeError' - } - } - return { - MockSocketServerBridgeError, mockApplyWorkflowStateInSocketServer: vi.fn(), mockDbUpdate: vi.fn(), + mockEnsureUniqueBlockIds: vi.fn(), + mockEnsureUniqueEdgeIds: vi.fn(), mockGetState: vi.fn(), - mockGetRedisStorageMode: vi.fn(), mockSaveWorkflowToNormalizedTables: vi.fn(), mockStoreCanonicalState: vi.fn(), + mockUpdateReturning: vi.fn(), mockUpdateSet: vi.fn(), mockUpdateWhere: vi.fn(), } @@ -49,17 +44,14 @@ vi.mock('drizzle-orm', () => ({ eq: vi.fn((field, value) => ({ field, value })), })) -vi.mock('@/lib/redis', () => ({ - getRedisStorageMode: mockGetRedisStorageMode, -})) - vi.mock('@/lib/workflows/db-helpers', () => ({ + ensureUniqueBlockIds: mockEnsureUniqueBlockIds, + ensureUniqueEdgeIds: mockEnsureUniqueEdgeIds, saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, })) vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ applyWorkflowStateInSocketServer: mockApplyWorkflowStateInSocketServer, - SocketServerBridgeError: MockSocketServerBridgeError, })) vi.mock('@/socket-server/yjs/persistence', () => ({ @@ -70,18 +62,36 @@ vi.mock('@/socket-server/yjs/persistence', () => ({ describe('applyWorkflowState', () => { beforeEach(() => { vi.clearAllMocks() - mockGetRedisStorageMode.mockReturnValue('redis') mockApplyWorkflowStateInSocketServer.mockResolvedValue(undefined) + mockEnsureUniqueBlockIds.mockImplementation(async (_workflowId, state) => state) + mockEnsureUniqueEdgeIds.mockImplementation(async (_workflowId, state) => state) mockGetState.mockResolvedValue(null) mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) mockStoreCanonicalState.mockResolvedValue(undefined) - mockUpdateWhere.mockResolvedValue(undefined) + mockUpdateReturning.mockResolvedValue([{ id: 'workflow-1' }]) + mockUpdateWhere.mockReturnValue({ returning: mockUpdateReturning }) mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }) mockDbUpdate.mockReturnValue({ set: mockUpdateSet }) }) - it('preserves canonical Yjs variables during direct graph-only persistence', async () => { + it('publishes the normalized workflow state to Yjs and DB while preserving existing variables', async () => { mockApplyWorkflowStateInSocketServer.mockRejectedValueOnce(new TypeError('fetch failed')) + mockEnsureUniqueBlockIds.mockImplementationOnce(async () => ({ + blocks: { + 'normalized-block': { + id: 'normalized-block', + type: 'agent', + name: 'Agent', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + })) const { applyWorkflowState } = await import('./apply-workflow-state') const { extractPersistedStateFromDoc, getMetadataMap, setVariables } = await import( @@ -100,7 +110,17 @@ describe('applyWorkflowState', () => { await applyWorkflowState( 'workflow-1', { - blocks: {}, + blocks: { + 'input-block': { + id: 'input-block', + type: 'agent', + name: 'Input Agent', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + }, + }, edges: [], loops: {}, parallels: {}, @@ -116,16 +136,28 @@ describe('applyWorkflowState', () => { try { Y.applyUpdate(doc, mockStoreCanonicalState.mock.calls[0][1] as Uint8Array) expect(extractPersistedStateFromDoc(doc)).toMatchObject({ + blocks: { + 'normalized-block': expect.objectContaining({ id: 'normalized-block' }), + }, variables: { var1: expect.objectContaining({ value: 'secret' }), }, }) + expect(extractPersistedStateFromDoc(doc).blocks).not.toHaveProperty('input-block') expect(getMetadataMap(doc).get('entityName')).toBe('Workflow Name') } finally { doc.destroy() } expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledOnce() + expect(mockSaveWorkflowToNormalizedTables.mock.calls[0][1]).toMatchObject({ + blocks: { + 'normalized-block': expect.objectContaining({ id: 'normalized-block' }), + }, + }) + expect(mockStoreCanonicalState.mock.invocationCallOrder[0]).toBeLessThan( + mockSaveWorkflowToNormalizedTables.mock.invocationCallOrder[0] + ) expect(mockUpdateSet).toHaveBeenCalledWith( expect.not.objectContaining({ variables: expect.anything(), diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index 2e753ee74..423daf550 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -1,7 +1,11 @@ import { db, workflow } from '@tradinggoose/db' import { eq } from 'drizzle-orm' import * as Y from 'yjs' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' +import { + ensureUniqueBlockIds, + ensureUniqueEdgeIds, + saveWorkflowToNormalizedTables, +} from '@/lib/workflows/db-helpers' import { applyWorkflowStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot, @@ -55,14 +59,27 @@ export async function applyWorkflowState( lastSaved: syncedAt.toISOString(), }) - await applyWorkflowStateToYjs(workflowId, appliedWorkflowState, variables, entityName) + const normalizedWorkflowState = await ensureUniqueEdgeIds( + workflowId, + await ensureUniqueBlockIds(workflowId, appliedWorkflowState) + ) + const { deployedAt, ...storedStateFields } = normalizedWorkflowState + const storedWorkflowState = createWorkflowSnapshot({ + ...storedStateFields, + lastSaved: syncedAt.toISOString(), + ...(deployedAt + ? { deployedAt: typeof deployedAt === 'string' ? deployedAt : deployedAt.toISOString() } + : {}), + }) - const saveResult = await saveWorkflowToNormalizedTables(workflowId, appliedWorkflowState) + await applyWorkflowStateToYjs(workflowId, storedWorkflowState, variables, entityName) + + const saveResult = await saveWorkflowToNormalizedTables(workflowId, storedWorkflowState) if (!saveResult.success) { throw new Error(saveResult.error || 'Failed to materialize workflow state') } - await db + const [updatedWorkflow] = await db .update(workflow) .set({ lastSynced: syncedAt, @@ -70,6 +87,11 @@ export async function applyWorkflowState( ...(variables === undefined ? {} : { variables }), }) .where(eq(workflow.id, workflowId)) + .returning({ id: workflow.id }) + + if (!updatedWorkflow) { + throw new Error('Workflow not found') + } } export async function applyWorkflowEntityName( From 62580b139876e8f8b1a757dd4a32bade2f11354b Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 15:26:16 -0600 Subject: [PATCH 066/284] fix(auth): preserve locale on invalid MCP authorization Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts | 6 ++++-- apps/tradinggoose/app/api/auth/mcp/authorize/route.ts | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts index bba37be4e..f994e1b57 100644 --- a/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts @@ -103,11 +103,13 @@ describe('MCP authorize route', () => { it('rejects malformed confirmation submissions before auth mutation', async () => { const { POST } = await import('./route') - const response = await POST(createAuthorizeRequest({ action: 'approve', code: 'login-code' })) + const response = await POST( + createAuthorizeRequest({ action: 'approve', code: 'login-code', locale: 'es' }) + ) expect(response.status).toBe(307) expect(response.headers.get('location')).toBe( - 'https://studio.example.test/en/mcp/authorize?status=invalid' + 'https://studio.example.test/es/mcp/authorize?status=invalid' ) expect(mockApproveMcpDeviceLogin).not.toHaveBeenCalled() expect(mockCancelMcpDeviceLogin).not.toHaveBeenCalled() diff --git a/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts b/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts index ff78a296e..f88f38d13 100644 --- a/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts @@ -26,6 +26,8 @@ export async function POST(request: NextRequest) { const action = formData?.get('action') const approvalToken = formData?.get('approvalToken') const code = formData?.get('code') + const localeValue = formData?.get('locale') + const locale = normalizeLocaleCode(typeof localeValue === 'string' ? localeValue : undefined) if ( (action !== 'approve' && action !== 'cancel') || @@ -34,11 +36,9 @@ export async function POST(request: NextRequest) { typeof code !== 'string' || !code ) { - return redirectToAuthorizeStatus(request, 'en', 'invalid') + return redirectToAuthorizeStatus(request, locale, 'invalid') } - const localeValue = formData?.get('locale') - const locale = typeof localeValue === 'string' ? localeValue : 'en' const session = await getSession(request.headers) if (!session?.user?.id) { return redirectToLogin(request, locale, code) From 5c7c3c69bf40beb19db608327475c39f0b01c575 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 15:27:29 -0600 Subject: [PATCH 067/284] fix(copilot): reject stale accepted server tool reviews Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/copilot/registry.ts | 6 ++- .../lib/copilot/tools/server/base-tool.ts | 35 ++++++++++++-- .../copilot/tools/server/entities/shared.ts | 11 +++++ .../copilot/tools/server/entities/workflow.ts | 14 +++++- .../tools/server/monitor/edit-monitor.ts | 22 +++++---- .../copilot/tools/server/review-acceptance.ts | 46 ++++++------------- .../server/user/set-environment-variables.ts | 15 +++--- .../workflow/workflow-mutation-utils.ts | 10 ++-- 8 files changed, 100 insertions(+), 59 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/registry.ts b/apps/tradinggoose/lib/copilot/registry.ts index a044cb6e6..038d7dc53 100644 --- a/apps/tradinggoose/lib/copilot/registry.ts +++ b/apps/tradinggoose/lib/copilot/registry.ts @@ -849,6 +849,9 @@ const EditEntityDocumentResultBase = DocumentDiffReviewMetadata.extend({ const WorkflowMutationResult = WorkflowTargetEnvelope.merge(DocumentDiffReviewMetadata).extend({ success: z.boolean(), }) +const WorkflowCreateMutationResult = WorkflowMutationResult.extend({ + entityId: z.string().optional(), +}) const CustomToolDocumentMutationResult = EditEntityDocumentResultBase.merge( CustomToolDocumentEnvelope.extend({ @@ -917,6 +920,7 @@ const EditWorkflowResult = WorkflowGraphDocumentEnvelope.extend(WorkflowMutation const EditWorkflowBlockResult = WorkflowDocumentEnvelope.extend(WorkflowMutationResultShape) const EditWorkflowVariableResult = WorkflowVariableDocumentEnvelope.extend({ requiresReview: z.literal(true).optional(), + reviewBaseStateHash: z.string().optional(), success: z.boolean().optional(), preview: z .object({ @@ -973,7 +977,7 @@ export const ToolResultSchemas = { id: z.string(), }), [CopilotTool.read_workflow]: WorkflowReadDocumentEnvelope, - create_workflow: WorkflowMutationResult, + create_workflow: WorkflowCreateMutationResult, [CopilotTool.list_workflows]: GenericEntityListResult.extend({ entityKind: z.literal('workflow'), }), diff --git a/apps/tradinggoose/lib/copilot/tools/server/base-tool.ts b/apps/tradinggoose/lib/copilot/tools/server/base-tool.ts index 3d29856f3..dc0db84c4 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/base-tool.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/base-tool.ts @@ -1,13 +1,14 @@ -import { - type CopilotAccessLevel, - shouldRequireToolApproval, -} from '@/lib/copilot/access-policy' +import { createHash } from 'crypto' +import { type CopilotAccessLevel, shouldRequireToolApproval } from '@/lib/copilot/access-policy' import type { ToolId } from '@/lib/copilot/registry' import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' +import { StructuredServerToolError } from '@/lib/copilot/server-tool-errors' +import { stableStringifyJsonValue } from '@/lib/json/stable' export interface ServerToolExecutionContext { userId: string accessLevel?: CopilotAccessLevel + acceptedReviewBaseStateHash?: string contextEntityKind?: ReviewEntityKind contextEntityId?: string workspaceId?: string @@ -64,6 +65,32 @@ export function shouldStageServerToolMutationForReview( return shouldRequireToolApproval(context.accessLevel, true) } +export function hashServerToolReviewBase(value: unknown): string { + return createHash('sha256').update(stableStringifyJsonValue(value)).digest('hex') +} + +export function assertAcceptedServerToolReviewBase( + context: ServerToolExecutionContext | undefined, + currentBaseStateHash: string +): void { + if ( + !context?.acceptedReviewBaseStateHash || + context.acceptedReviewBaseStateHash === currentBaseStateHash + ) { + return + } + + throw new StructuredServerToolError({ + status: 409, + body: { + code: 'stale_server_tool_review', + error: 'This reviewed Copilot edit is stale because the target changed after review.', + hint: 'Ask Copilot to read the current target and prepare the edit again.', + retryable: true, + }, + }) +} + export interface BaseServerTool { name: ToolId execute(args: TArgs, context?: ServerToolExecutionContext): Promise diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index f2b52e3e4..e94f5a59c 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -12,6 +12,8 @@ import type { ServerToolExecutionContext, } from '@/lib/copilot/tools/server/base-tool' import { + assertAcceptedServerToolReviewBase, + hashServerToolReviewBase, shouldStageServerToolMutationForReview, withWorkspaceArgContext, } from '@/lib/copilot/tools/server/base-tool' @@ -245,6 +247,7 @@ export async function executeCreateEntityDocumentMutation( requiresReview: true, success: true, workspaceId, + reviewBaseStateHash: hashServerToolReviewBase({ kind, workspaceId }), ...buildReviewDocumentEnvelope(kind, undefined, fields), preview: { documentDiff: { @@ -281,6 +284,7 @@ export async function executeUpdateEntityDocumentMutation( return { requiresReview: true, success: true, + reviewBaseStateHash: hashServerToolReviewBase(currentFields), ...buildReviewDocumentEnvelope(kind, entityId, fields), preview: { documentDiff: buildReviewDocumentDiff(kind, currentFields, fields), @@ -288,6 +292,13 @@ export async function executeUpdateEntityDocumentMutation( } } + if (context?.acceptedReviewBaseStateHash) { + assertAcceptedServerToolReviewBase( + context, + hashServerToolReviewBase(await readSavedEntityDocumentFields(kind, entityId, workspaceId)) + ) + } + if (apply) { await apply({ entityId, fields, workspaceId }) } else { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index ab0901a0c..4c670d027 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -13,6 +13,8 @@ import type { ServerToolExecutionContext, } from '@/lib/copilot/tools/server/base-tool' import { + assertAcceptedServerToolReviewBase, + hashServerToolReviewBase, shouldStageServerToolMutationForReview, withWorkspaceArgContext, } from '@/lib/copilot/tools/server/base-tool' @@ -388,6 +390,7 @@ export const editWorkflowVariableServerTool: BaseServerTool< entityDocument: args.entityDocument, }) const nextDocument = serializeWorkflowVariableDocument(nextVariables) + const currentVariablesBaseHash = hashServerToolReviewBase(variables) if (shouldStageServerToolMutationForReview(context)) { const currentDocument = serializeWorkflowVariableDocument(variables) @@ -400,6 +403,7 @@ export const editWorkflowVariableServerTool: BaseServerTool< documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, entityDocument: nextDocument, variables: nextVariables, + reviewBaseStateHash: currentVariablesBaseHash, preview: { documentDiff: { before: currentDocument, @@ -409,6 +413,7 @@ export const editWorkflowVariableServerTool: BaseServerTool< } } + assertAcceptedServerToolReviewBase(context, currentVariablesBaseHash) await applyWorkflowState(workflowId, workflowState, nextVariables) return { success: true, @@ -448,7 +453,7 @@ export const createWorkflowServerTool: BaseServerTool< entityKind: ENTITY_KIND_WORKFLOW, entityName: name, workspaceId, - reviewBaseStateHash: workspaceId, + reviewBaseStateHash: hashServerToolReviewBase({ workspaceId }), } } @@ -504,6 +509,10 @@ export const renameWorkflowServerTool: BaseServerTool<{ entityId: string; name: } const current = await loadWorkflowSnapshotForCopilot(workflowId, context, 'write') + const currentNameBaseHash = hashServerToolReviewBase({ + workflowId, + entityName: current.entityName ?? '', + }) if (shouldStageServerToolMutationForReview(context)) { return { requiresReview: true, @@ -512,10 +521,11 @@ export const renameWorkflowServerTool: BaseServerTool<{ entityId: string; name: entityId: workflowId, entityName: nextName, workspaceId: current.workspaceId ?? undefined, - reviewBaseStateHash: `${workflowId}:${current.entityName ?? ''}`, + reviewBaseStateHash: currentNameBaseHash, } } + assertAcceptedServerToolReviewBase(context, currentNameBaseHash) const updatedWorkflow = await applyWorkflowEntityName( workflowId, current.workflowState, diff --git a/apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts b/apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts index 0719b8859..135fdbaf5 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts @@ -1,5 +1,3 @@ -import { getMonitorRowById, toMonitorRecord } from '@/app/api/monitors/shared' -import { updateMonitorForUser } from '@/app/api/monitors/update-service' import { MONITOR_DOCUMENT_FORMAT, parseMonitorDocument, @@ -7,15 +5,19 @@ import { serializeMonitorDocument, } from '@/lib/copilot/monitor/monitor-documents' import { - buildMonitorDocumentEnvelope, - type MonitorRecord, -} from '@/lib/copilot/tools/server/monitor/shared' -import { + assertAcceptedServerToolReviewBase, type BaseServerTool, + hashServerToolReviewBase, shouldStageServerToolMutationForReview, } from '@/lib/copilot/tools/server/base-tool' +import { + buildMonitorDocumentEnvelope, + type MonitorRecord, +} from '@/lib/copilot/tools/server/monitor/shared' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' +import { getMonitorRowById, toMonitorRecord } from '@/app/api/monitors/shared' +import { updateMonitorForUser } from '@/app/api/monitors/update-service' const logger = createLogger('EditMonitorServerTool') @@ -47,14 +49,16 @@ export const editMonitorServerTool: BaseServerTool = { } const nextFields = parseMonitorDocument(args.monitorDocument) + const currentMonitor = (await toMonitorRecord(row.webhook)) as MonitorRecord + const currentDocument = buildMonitorDocumentEnvelope(currentMonitor).monitorDocument + const reviewBaseStateHash = hashServerToolReviewBase(currentDocument) + if (shouldStageServerToolMutationForReview(context)) { const access = await checkWorkspaceAccess(row.workflow.workspaceId, userId) if (!access.exists || !access.hasAccess || !access.canWrite) { throw new Error('Access denied: You do not have permission to edit this monitor') } - const currentMonitor = (await toMonitorRecord(row.webhook)) as MonitorRecord - const currentDocument = buildMonitorDocumentEnvelope(currentMonitor).monitorDocument const nextDocument = serializeMonitorDocument(nextFields) return { requiresReview: true, @@ -64,6 +68,7 @@ export const editMonitorServerTool: BaseServerTool = { monitorName: readMonitorDocumentName(nextFields), documentFormat: MONITOR_DOCUMENT_FORMAT, monitorDocument: nextDocument, + reviewBaseStateHash, preview: { documentDiff: { before: currentDocument, @@ -73,6 +78,7 @@ export const editMonitorServerTool: BaseServerTool = { } } + assertAcceptedServerToolReviewBase(context, reviewBaseStateHash) const updatedMonitor = (await updateMonitorForUser({ monitorId: args.monitorId, userId, diff --git a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts index 3037766ef..366845005 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts @@ -1,4 +1,3 @@ -import { createHash } from 'crypto' import { db } from '@tradinggoose/db' import { verification } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' @@ -9,8 +8,10 @@ import { serializeEntityDocumentForReview, } from '@/lib/copilot/entity-documents' import type { ToolId } from '@/lib/copilot/registry' -import { StructuredServerToolError } from '@/lib/copilot/server-tool-errors' -import type { ServerToolExecutionContext } from '@/lib/copilot/tools/server/base-tool' +import { + assertAcceptedServerToolReviewBase, + type ServerToolExecutionContext, +} from '@/lib/copilot/tools/server/base-tool' import { routeExecution } from '@/lib/copilot/tools/server/router' import { createLogger } from '@/lib/logs/console/logger' import { decryptSecret, encryptSecret } from '@/lib/utils-server' @@ -23,29 +24,18 @@ type StagedServerToolReview = { userId?: unknown toolName?: unknown encryptedPayload?: unknown - baseSignature?: unknown + baseStateHash?: unknown reviewClaimId?: unknown } -function hashValue(value: string) { - return createHash('sha256').update(value).digest('hex') -} - -function readBaseSignature(result: unknown): string { +function readBaseStateHash(result: unknown): string { if (!result || typeof result !== 'object' || Array.isArray(result)) { throw new Error('Server tool review result is missing base state') } - const record = result as { - preview?: { documentDiff?: { before?: unknown } } - reviewBaseStateHash?: unknown - } + const record = result as { reviewBaseStateHash?: unknown } if (typeof record.reviewBaseStateHash === 'string' && record.reviewBaseStateHash) { - return `state:${record.reviewBaseStateHash}` - } - const before = record.preview?.documentDiff?.before - if (typeof before === 'string') { - return `document:${hashValue(before)}` + return record.reviewBaseStateHash } throw new Error('Server tool review result is missing base state') @@ -124,7 +114,7 @@ export async function stageServerManagedToolReview( userId: context.userId, toolName, encryptedPayload, - baseSignature: readBaseSignature(result), + baseStateHash: readBaseStateHash(result), }), expiresAt: new Date(now.getTime() + REVIEW_TOKEN_TTL_MS), createdAt: now, @@ -169,7 +159,7 @@ export async function acceptServerManagedToolReview( !staged || staged.userId !== context.userId || staged.toolName !== toolName || - typeof staged.baseSignature !== 'string' + typeof staged.baseStateHash !== 'string' ) { throw new Error('Server tool review token does not match this request') } @@ -187,17 +177,10 @@ export async function acceptServerManagedToolReview( ...context, accessLevel: 'limited', }) - if (readBaseSignature(currentReview) !== staged.baseSignature) { - throw new StructuredServerToolError({ - status: 409, - body: { - code: 'stale_server_tool_review', - error: 'This reviewed Copilot edit is stale because the target changed after review.', - hint: 'Ask Copilot to read the current target and prepare the edit again.', - retryable: true, - }, - }) - } + assertAcceptedServerToolReviewBase( + { ...context, acceptedReviewBaseStateHash: staged.baseStateHash }, + readBaseStateHash(currentReview) + ) const identifier = `${REVIEW_TOKEN_PREFIX}${reviewToken}` const claimed = { ...staged, reviewClaimId: nanoid() } @@ -216,6 +199,7 @@ export async function acceptServerManagedToolReview( acceptedResult = await routeExecution(toolName, payload, { ...context, accessLevel: 'full', + acceptedReviewBaseStateHash: staged.baseStateHash, }) } catch (error) { await db diff --git a/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts b/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts index 9a61c9a1a..d59f3909e 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts @@ -1,10 +1,11 @@ -import { createHash } from 'crypto' import { db } from '@tradinggoose/db' import { environmentVariables } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' import { z } from 'zod' import { + assertAcceptedServerToolReviewBase, type BaseServerTool, + hashServerToolReviewBase, type ServerToolExecutionContext, shouldStageServerToolMutationForReview, throwIfServerToolAborted, @@ -18,9 +19,7 @@ interface SetEnvironmentVariablesParams { const EnvVarSchema = z.object({ variables: z.record(z.string()) }) function hashEnvironmentVariableBase(entries: Array<[string, string | null]>): string { - return createHash('sha256') - .update(JSON.stringify(entries.sort(([left], [right]) => left.localeCompare(right)))) - .digest('hex') + return hashServerToolReviewBase(entries.sort(([left], [right]) => left.localeCompare(right))) } function normalizeEnvVarInput(input: Record | undefined): Record { @@ -121,18 +120,20 @@ export const setEnvironmentVariablesServerTool: BaseServerTool [key, summary.existingValueByKey.get(key) ?? null]) + ) if (shouldStageServerToolMutationForReview(context)) { return { requiresReview: true, success: true, ...buildEnvironmentVariablesResult(variableNames, summary, 'Review required for'), - reviewBaseStateHash: hashEnvironmentVariableBase( - variableNames.map((key) => [key, summary.existingValueByKey.get(key) ?? null]) - ), + reviewBaseStateHash, } } + assertAcceptedServerToolReviewBase(context, reviewBaseStateHash) const encryptedVariables = await encryptEnvironmentVariables(validatedVariables, context) await writeEncryptedEnvironmentVariables(userId, encryptedVariables, context) return buildEnvironmentVariablesResult(variableNames, summary, 'Successfully processed') diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts index 2cf26d55d..8db409bdf 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts @@ -1,6 +1,7 @@ -import { createHash } from 'crypto' import * as Y from 'yjs' import { + assertAcceptedServerToolReviewBase, + hashServerToolReviewBase, type ServerToolExecutionContext, shouldStageServerToolMutationForReview, } from '@/lib/copilot/tools/server/base-tool' @@ -97,10 +98,6 @@ function buildWorkflowDocumentPreviewDiff( } } -function hashWorkflowState(workflowState: WorkflowSnapshot) { - return createHash('sha256').update(stableStringifyJsonValue(workflowState)).digest('hex') -} - export async function loadBaseWorkflowState( workflowId: string, context?: ServerToolExecutionContext @@ -176,7 +173,7 @@ export function buildWorkflowMutationResult(params: { entityDocument, documentFormat: params.documentFormat, workflowState: finalWorkflowState, - reviewBaseStateHash: hashWorkflowState(baseWorkflowState), + reviewBaseStateHash: hashServerToolReviewBase(baseWorkflowState), preview: { ...preview, warnings, @@ -196,6 +193,7 @@ export async function resolveWorkflowMutationResultForExecution( return result } + assertAcceptedServerToolReviewBase(context, result.reviewBaseStateHash) await applyWorkflowState( result.entityId, createWorkflowSnapshot(result.workflowState as Partial) From b3f224c75d0ec50c74efcfe9af5520a7d6466fab Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 16:02:44 -0600 Subject: [PATCH 068/284] fix(yjs): keep saved entity sessions noncanonical Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/copilot/tools/server/entities/shared.ts | 9 +++++++-- .../lib/yjs/server/apply-entity-state.ts | 13 ++++++++++--- .../lib/yjs/server/bootstrap-review-target.ts | 6 +++++- apps/tradinggoose/socket-server/routes/http.ts | 2 +- apps/tradinggoose/socket-server/yjs/ws-handler.ts | 5 ++++- 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index e94f5a59c..df978d3bd 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -19,7 +19,10 @@ import { } from '@/lib/copilot/tools/server/base-tool' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import type { SavedEntityKind } from '@/lib/yjs/entity-state' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { + applySavedEntityState, + applySavedEntityStateToYjs, +} from '@/lib/yjs/server/apply-entity-state' import { readBootstrappedSavedEntityFields } from '@/lib/yjs/server/bootstrap-review-target' export type SavedEntityDocumentKind = EntityDocumentKind @@ -299,7 +302,9 @@ export async function executeUpdateEntityDocumentMutation( ) } - if (apply) { + if (context?.acceptedReviewBaseStateHash) { + await applySavedEntityStateToYjs(kind as SavedEntityKind, entityId, fields) + } else if (apply) { await apply({ entityId, fields, workspaceId }) } else { await applySavedEntityDocument(kind, entityId, fields) diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index a3668937b..673ffd48a 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -11,7 +11,7 @@ import * as Y from 'yjs' import { seedEntitySession } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { applyEntityStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' -import { storeCanonicalState } from '@/socket-server/yjs/persistence' +import { storeState } from '@/socket-server/yjs/persistence' function parseObjectJson(value: unknown, fieldName: string): Record { const parsed = JSON.parse(String(value ?? '')) @@ -112,7 +112,7 @@ async function persistSavedEntityState( } } -export async function applySavedEntityState( +export async function applySavedEntityStateToYjs( entityKind: SavedEntityKind, entityId: string, fields: Record @@ -123,11 +123,18 @@ export async function applySavedEntityState( const doc = new Y.Doc() try { seedEntitySession(doc, { entityKind, payload: fields }) - await storeCanonicalState(entityId, Y.encodeStateAsUpdate(doc)) + await storeState(entityId, Y.encodeStateAsUpdate(doc)) } finally { doc.destroy() } } +} +export async function applySavedEntityState( + entityKind: SavedEntityKind, + entityId: string, + fields: Record +): Promise { + await applySavedEntityStateToYjs(entityKind, entityId, fields) await persistSavedEntityState(entityKind, entityId, fields) } diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 931ddb078..64455917d 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -27,6 +27,7 @@ import { import { getState as getPersistedYjsState, storeCanonicalState, + storeState, } from '@/socket-server/yjs/persistence' export class ReviewTargetBootstrapError extends Error { @@ -195,7 +196,10 @@ async function bootstrapSavedEntityFromDb( metadata.set('entityName', workflowName) } const state = Y.encodeStateAsUpdate(doc) - await storeCanonicalState(descriptor.yjsSessionId, state) + await (descriptor.entityKind === 'workflow' ? storeCanonicalState : storeState)( + descriptor.yjsSessionId, + state + ) return { descriptor, diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index 7e161460c..15f7710ba 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -264,7 +264,7 @@ async function handleInternalYjsEntityApplyRequest( payload: body.fields, }) clearSessionReseededFromCanonical(doc) - await storeCanonicalState(entityId, Y.encodeStateAsUpdate(doc)) + await storeState(entityId, Y.encodeStateAsUpdate(doc)) } finally { if (!liveDoc) doc.destroy() } diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.ts index d46891c17..3ee5f5689 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.ts @@ -114,7 +114,10 @@ async function authenticateAndPrepareUpgrade( return { userId, resolvedSessionId: pathSessionId, - canonical: descriptor.reviewSessionId === null && descriptor.entityId !== null, + canonical: + descriptor.reviewSessionId === null && + descriptor.entityKind === 'workflow' && + descriptor.entityId !== null, } } From 2813eda033a3548d93625e4b851a56289328d534 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 16:02:52 -0600 Subject: [PATCH 069/284] fix(workflows): preserve deployment metadata from live state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/copilot/tools/server/entities/workflow.ts | 8 +++++++- .../tradinggoose/lib/workflows/db-helpers.test.ts | 8 +++++++- apps/tradinggoose/lib/workflows/db-helpers.ts | 15 ++++++++++++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index 4c670d027..988457cdd 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -197,6 +197,8 @@ export async function loadWorkflowSnapshotForCopilot( id: workflow.id, name: workflow.name, workspaceId: workflow.workspaceId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt, }) .from(workflow) .where(eq(workflow.id, workflowId)) @@ -226,7 +228,11 @@ export async function loadWorkflowSnapshotForCopilot( workflowId, entityName: workflowRow.name ?? undefined, workspaceId: workflowRow.workspaceId ?? null, - workflowState: readWorkflowSnapshot(doc), + workflowState: { + ...readWorkflowSnapshot(doc), + isDeployed: workflowRow.isDeployed ?? false, + deployedAt: workflowRow.deployedAt?.toISOString(), + }, variables: getVariablesSnapshot(doc), } } finally { diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index 3f9476f77..07c2213c8 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -1139,6 +1139,13 @@ describe('Database Helpers', () => { mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( buildWorkflowSnapshotResponseFromState(yjsState, yjsVariables) ) + mockDb.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ isDeployed: false, deployedAt: null }]), + }), + }), + }) const result = await dbHelpers.loadWorkflowState(mockWorkflowId) @@ -1147,7 +1154,6 @@ describe('Database Helpers', () => { variables: yjsVariables, source: 'yjs', }) - expect(mockDb.select).not.toHaveBeenCalled() }) it('loads materialized workflow state when no Yjs state exists', async () => { diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index bd79bdb74..800a9ed1e 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -134,7 +134,20 @@ export async function loadWorkflowState( try { const liveState = await loadWorkflowStateFromYjs(workflowId) if (liveState) { - return { ...liveState, source: 'yjs' } + const [workflowDeploymentState] = await db + .select({ isDeployed: workflow.isDeployed, deployedAt: workflow.deployedAt }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + if (!workflowDeploymentState) { + return null + } + return { + ...liveState, + isDeployed: workflowDeploymentState.isDeployed ?? false, + deployedAt: toISOStringOrUndefined(workflowDeploymentState.deployedAt), + source: 'yjs', + } } } catch (error) { logger.warn( From ffebf07e063b306d47e7aada40b83e7a0973993b Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 16:32:16 -0600 Subject: [PATCH 070/284] fix(yjs): separate saved entity draft persistence Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/mcp/servers/[id]/route.ts | 4 +- .../tradinggoose/app/api/mcp/servers/route.ts | 4 +- .../tools/server/entities/shared.test.ts | 52 ++++++++++++++++--- .../copilot/tools/server/entities/shared.ts | 8 +-- apps/tradinggoose/lib/knowledge/service.ts | 8 +-- .../lib/skills/operations.test.ts | 13 ++--- apps/tradinggoose/lib/yjs/entity-state.ts | 4 +- .../lib/yjs/server/apply-entity-state.ts | 6 +-- 8 files changed, 69 insertions(+), 30 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts index ecbb6bb3c..d7913481c 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts @@ -8,7 +8,7 @@ import { mcpService } from '@/lib/mcp/service' import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' import { UpdateMcpServerSchema } from '../schema' const logger = createLogger('McpServerAPI') @@ -90,7 +90,7 @@ export const PATCH = withMcpAuth('write')( updatedAt: new Date(), } - await applySavedEntityState( + await applySavedEntityPersistedState( 'mcp_server', nextServer.id, savedEntityRowToFields('mcp_server', nextServer) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index f6b0528ae..c45162f42 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -9,7 +9,7 @@ import type { McpTransport } from '@/lib/mcp/types' import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { CreateMcpServerSchema } from './schema' @@ -114,7 +114,7 @@ export const POST = withMcpAuth('write')( .returning() try { - await applySavedEntityState( + await applySavedEntityPersistedState( 'mcp_server', server.id, savedEntityRowToFields('mcp_server', server) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts index eb76aa586..1014c9285 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts @@ -1,14 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { MCP_SERVER_DOCUMENT_FORMAT, SKILL_DOCUMENT_FORMAT } from '@/lib/copilot/entity-documents' +import { hashServerToolReviewBase } from '@/lib/copilot/tools/server/base-tool' import { buildReviewDocumentDiff, executeCreateEntityDocumentMutation, executeUpdateEntityDocumentMutation, } from './shared' -const mockApplySavedEntityState = vi.hoisted(() => vi.fn()) +const { mockApplySavedEntityDraftState, mockApplySavedEntityPersistedState } = vi.hoisted(() => ({ + mockApplySavedEntityDraftState: vi.fn(), + mockApplySavedEntityPersistedState: vi.fn(), +})) const mockCheckWorkspaceAccess = vi.hoisted(() => vi.fn()) -const mockReadBootstrappedReviewTargetSnapshot = vi.hoisted(() => vi.fn()) +const mockReadBootstrappedSavedEntityFields = vi.hoisted(() => vi.fn()) const mockVerifyReviewTargetAccess = vi.hoisted(() => vi.fn()) vi.mock('@/lib/permissions/utils', () => ({ @@ -20,12 +24,14 @@ vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ })) vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ - applySavedEntityState: (...args: unknown[]) => mockApplySavedEntityState(...args), + applySavedEntityDraftState: (...args: unknown[]) => mockApplySavedEntityDraftState(...args), + applySavedEntityPersistedState: (...args: unknown[]) => + mockApplySavedEntityPersistedState(...args), })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ - readBootstrappedReviewTargetSnapshot: (...args: unknown[]) => - mockReadBootstrappedReviewTargetSnapshot(...args), + readBootstrappedSavedEntityFields: (...args: unknown[]) => + mockReadBootstrappedSavedEntityFields(...args), })) describe('entity document mutation helpers', () => { @@ -68,12 +74,44 @@ describe('entity document mutation helpers', () => { }) expect(result).not.toHaveProperty('requiresReview') expect(result).not.toHaveProperty('preview') - expect(mockApplySavedEntityState).toHaveBeenCalledWith('skill', 'skill-1', { + expect(mockApplySavedEntityPersistedState).toHaveBeenCalledWith('skill', 'skill-1', { name: 'Updated Skill', description: 'Updated description', content: 'Use the updated process.', }) - expect(mockReadBootstrappedReviewTargetSnapshot).not.toHaveBeenCalled() + expect(mockReadBootstrappedSavedEntityFields).not.toHaveBeenCalled() + }) + + it('accepts reviewed updates into the saved-entity draft without persisting', async () => { + const currentFields = { + name: 'Existing Skill', + description: 'Existing description', + content: 'Use the existing process.', + } + const nextFields = { + name: 'Updated Skill', + description: 'Updated description', + content: 'Use the updated process.', + } + mockReadBootstrappedSavedEntityFields.mockResolvedValue(currentFields) + + await executeUpdateEntityDocumentMutation( + 'skill', + 'edit_skill', + { + entityId: 'skill-1', + documentFormat: SKILL_DOCUMENT_FORMAT, + entityDocument: JSON.stringify(nextFields), + }, + { + userId: 'user-1', + accessLevel: 'full', + acceptedReviewBaseStateHash: hashServerToolReviewBase(currentFields), + } + ) + + expect(mockApplySavedEntityDraftState).toHaveBeenCalledWith('skill', 'skill-1', nextFields) + expect(mockApplySavedEntityPersistedState).not.toHaveBeenCalled() }) it('keeps Studio create mutations in review mode', async () => { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index df978d3bd..da07a4b84 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -20,8 +20,8 @@ import { import { checkWorkspaceAccess } from '@/lib/permissions/utils' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { - applySavedEntityState, - applySavedEntityStateToYjs, + applySavedEntityDraftState, + applySavedEntityPersistedState, } from '@/lib/yjs/server/apply-entity-state' import { readBootstrappedSavedEntityFields } from '@/lib/yjs/server/bootstrap-review-target' @@ -226,7 +226,7 @@ export async function applySavedEntityDocument( entityId: string, fields: Record ): Promise { - await applySavedEntityState(kind as SavedEntityKind, entityId, fields) + await applySavedEntityPersistedState(kind as SavedEntityKind, entityId, fields) } export async function executeCreateEntityDocumentMutation( @@ -303,7 +303,7 @@ export async function executeUpdateEntityDocumentMutation( } if (context?.acceptedReviewBaseStateHash) { - await applySavedEntityStateToYjs(kind as SavedEntityKind, entityId, fields) + await applySavedEntityDraftState(kind as SavedEntityKind, entityId, fields) } else if (apply) { await apply({ entityId, fields, workspaceId }) } else { diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index 138bcc8ae..1bd609aa8 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -22,7 +22,7 @@ import type { import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/permissions/utils' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('KnowledgeBaseService') @@ -118,7 +118,7 @@ export async function createKnowledgeBase( } try { - await applySavedEntityState( + await applySavedEntityPersistedState( ENTITY_KIND_KNOWLEDGE_BASE, created.id, savedEntityRowToFields(ENTITY_KIND_KNOWLEDGE_BASE, created) @@ -349,7 +349,7 @@ export async function copyKnowledgeBaseToWorkspace( } try { - await applySavedEntityState( + await applySavedEntityPersistedState( ENTITY_KIND_KNOWLEDGE_BASE, copied.id, savedEntityRowToFields(ENTITY_KIND_KNOWLEDGE_BASE, copied) @@ -395,7 +395,7 @@ export async function applyKnowledgeBaseMetadata( throw new Error(`Knowledge base ${knowledgeBaseId} not found`) } - await applySavedEntityState(ENTITY_KIND_KNOWLEDGE_BASE, knowledgeBaseId, fields) + await applySavedEntityPersistedState(ENTITY_KIND_KNOWLEDGE_BASE, knowledgeBaseId, fields) logger.info(`[${requestId}] Applied knowledge base metadata through Yjs: ${knowledgeBaseId}`) diff --git a/apps/tradinggoose/lib/skills/operations.test.ts b/apps/tradinggoose/lib/skills/operations.test.ts index 662c8b966..15a6272f7 100644 --- a/apps/tradinggoose/lib/skills/operations.test.ts +++ b/apps/tradinggoose/lib/skills/operations.test.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockTransaction, mockNanoid, mockApplySavedEntityState } = vi.hoisted(() => ({ +const { mockTransaction, mockNanoid, mockApplySavedEntityPersistedState } = vi.hoisted(() => ({ mockTransaction: vi.fn(), mockNanoid: vi.fn(), - mockApplySavedEntityState: vi.fn(), + mockApplySavedEntityPersistedState: vi.fn(), })) vi.mock('@tradinggoose/db', () => ({ @@ -32,7 +32,8 @@ vi.mock('nanoid', () => ({ })) vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ - applySavedEntityState: (...args: unknown[]) => mockApplySavedEntityState(...args), + applySavedEntityPersistedState: (...args: unknown[]) => + mockApplySavedEntityPersistedState(...args), })) import { importSkills } from '@/lib/skills/operations' @@ -148,13 +149,13 @@ describe('skills import operations', () => { ]) expect(result.importedCount).toBe(2) expect(result.renamedCount).toBe(1) - expect(mockApplySavedEntityState).toHaveBeenCalledTimes(2) - expect(mockApplySavedEntityState).toHaveBeenCalledWith('skill', 'skill-b', { + expect(mockApplySavedEntityPersistedState).toHaveBeenCalledTimes(2) + expect(mockApplySavedEntityPersistedState).toHaveBeenCalledWith('skill', 'skill-b', { name: 'Execution Plan (imported) 1', description: 'Create the execution plan.', content: 'Follow the checklist.', }) - expect(mockApplySavedEntityState).toHaveBeenCalledWith('skill', 'skill-a', { + expect(mockApplySavedEntityPersistedState).toHaveBeenCalledWith('skill', 'skill-a', { name: 'Market Research', description: 'Research the market.', content: 'Review catalysts.', diff --git a/apps/tradinggoose/lib/yjs/entity-state.ts b/apps/tradinggoose/lib/yjs/entity-state.ts index 04e73f2f0..48b570cec 100644 --- a/apps/tradinggoose/lib/yjs/entity-state.ts +++ b/apps/tradinggoose/lib/yjs/entity-state.ts @@ -1,5 +1,5 @@ import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' export type SavedEntityKind = Exclude @@ -71,7 +71,7 @@ export async function applySavedEntityRows( try { await Promise.all( rows.map((row) => - applySavedEntityState(entityKind, row.id, savedEntityRowToFields(entityKind, row)) + applySavedEntityPersistedState(entityKind, row.id, savedEntityRowToFields(entityKind, row)) ) ) } catch (error) { diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index 673ffd48a..b0eea1631 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -112,7 +112,7 @@ async function persistSavedEntityState( } } -export async function applySavedEntityStateToYjs( +export async function applySavedEntityDraftState( entityKind: SavedEntityKind, entityId: string, fields: Record @@ -130,11 +130,11 @@ export async function applySavedEntityStateToYjs( } } -export async function applySavedEntityState( +export async function applySavedEntityPersistedState( entityKind: SavedEntityKind, entityId: string, fields: Record ): Promise { - await applySavedEntityStateToYjs(entityKind, entityId, fields) + await applySavedEntityDraftState(entityKind, entityId, fields) await persistSavedEntityState(entityKind, entityId, fields) } From e60fe7cf8bc5d44b98a5b45c9448591281739aca Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 16:32:30 -0600 Subject: [PATCH 071/284] fix(workflows): handle missing normalized saved state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/workflows/db-helpers.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 800a9ed1e..2f68c386d 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -181,13 +181,16 @@ export async function loadWorkflowStateFromSavedTables( if (!row) { return null } + if (!normalizedState) { + return null + } const savedState = { name: row.name, - blocks: normalizedState?.blocks ?? {}, - edges: normalizedState?.edges ?? [], - loops: normalizedState?.loops ?? {}, - parallels: normalizedState?.parallels ?? {}, + blocks: normalizedState.blocks, + edges: normalizedState.edges, + loops: normalizedState.loops, + parallels: normalizedState.parallels, variables: (row.variables as Record) ?? {}, lastSaved: row.updatedAt?.getTime() ?? Date.now(), isDeployed: row.isDeployed ?? false, From abf674f14d4b632e2c8a52896abf20fb9add8c06 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 16:47:02 -0600 Subject: [PATCH 072/284] fix(copilot): scope environment variable writes to workspace Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/copilot/registry.ts | 8 +-- .../lib/copilot/tools/server/router.test.ts | 13 +++- .../server/user/set-environment-variables.ts | 65 +++++++++++++------ 3 files changed, 61 insertions(+), 25 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/registry.ts b/apps/tradinggoose/lib/copilot/registry.ts index 038d7dc53..7aa4e0d6e 100644 --- a/apps/tradinggoose/lib/copilot/registry.ts +++ b/apps/tradinggoose/lib/copilot/registry.ts @@ -379,11 +379,9 @@ export const ToolArgSchemas = { [CopilotTool.read_environment_variables]: WorkspaceTargetArgs.strict(), - set_environment_variables: z - .object({ - variables: z.record(z.string()), - }) - .strict(), + set_environment_variables: WorkspaceTargetArgs.extend({ + variables: z.record(z.string()), + }).strict(), [CopilotTool.read_oauth_credentials]: WorkspaceTargetArgs.strict(), diff --git a/apps/tradinggoose/lib/copilot/tools/server/router.test.ts b/apps/tradinggoose/lib/copilot/tools/server/router.test.ts index be390d221..aa668a770 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/router.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/router.test.ts @@ -487,6 +487,17 @@ describe('routeExecution', () => { { workspaceId: 'workspace-1' }, context ) + + await expect( + routeExecution('set_environment_variables', { variables: { API_KEY: 'secret' } }, context) + ).resolves.toMatchObject({ + message: 'ok', + }) + + expect(setEnvironmentVariablesExecute).toHaveBeenCalledWith( + { variables: { API_KEY: 'secret' }, workspaceId: 'workspace-1' }, + context + ) }) it.each([ @@ -497,7 +508,7 @@ describe('routeExecution', () => { }, { toolName: 'set_environment_variables', - payload: { variables: { API_KEY: 'secret' } }, + payload: { workspaceId: 'workspace-123', variables: { API_KEY: 'secret' } }, execute: setEnvironmentVariablesExecute, }, { diff --git a/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts b/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts index d59f3909e..cab96f476 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts @@ -9,14 +9,20 @@ import { type ServerToolExecutionContext, shouldStageServerToolMutationForReview, throwIfServerToolAborted, + withWorkspaceArgContext, } from '@/lib/copilot/tools/server/base-tool' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { encryptSecret } from '@/lib/utils-server' interface SetEnvironmentVariablesParams { + workspaceId?: string variables: Record } -const EnvVarSchema = z.object({ variables: z.record(z.string()) }) +const EnvVarSchema = z.object({ + workspaceId: z.string().optional(), + variables: z.record(z.string()), +}) function hashEnvironmentVariableBase(entries: Array<[string, string | null]>): string { return hashServerToolReviewBase(entries.sort(([left], [right]) => left.localeCompare(right))) @@ -28,19 +34,26 @@ function normalizeEnvVarInput(input: Record | undefined): Recor ) as Record } -function parseEnvironmentVariablesPayload(payload: unknown): Record { - const variables = +function parseEnvironmentVariablesPayload(payload: unknown): { + workspaceId?: string + variables: Record +} { + const record = payload && typeof payload === 'object' && !Array.isArray(payload) - ? (payload as SetEnvironmentVariablesParams).variables + ? (payload as SetEnvironmentVariablesParams) : undefined - return EnvVarSchema.parse({ variables: normalizeEnvVarInput(variables) }).variables + const parsed = EnvVarSchema.parse({ + workspaceId: record?.workspaceId, + variables: normalizeEnvVarInput(record?.variables), + }) + return parsed } -async function readEnvironmentVariableSummary(userId: string, variableNames: string[]) { +async function readEnvironmentVariableSummary(workspaceId: string, variableNames: string[]) { const existingRows = await db .select({ key: environmentVariables.key, value: environmentVariables.value }) .from(environmentVariables) - .where(eq(environmentVariables.userId, userId)) + .where(eq(environmentVariables.workspaceId, workspaceId)) const existingKeySet = new Set(existingRows.map((row) => row.key)) const existingValueByKey = new Map(existingRows.map((row) => [row.key, row.value])) const added = variableNames.filter((key) => !existingKeySet.has(key)) @@ -77,7 +90,7 @@ async function encryptEnvironmentVariables( } async function writeEncryptedEnvironmentVariables( - userId: string, + workspaceId: string, encryptedVariables: Record, context?: ServerToolExecutionContext ) { @@ -88,12 +101,12 @@ async function writeEncryptedEnvironmentVariables( .insert(environmentVariables) .values({ id: crypto.randomUUID(), - userId, + workspaceId, key, value: encrypted, }) .onConflictDoUpdate({ - target: [environmentVariables.userId, environmentVariables.key], + target: [environmentVariables.workspaceId, environmentVariables.key], set: { value: encrypted, updatedAt: new Date(), @@ -110,21 +123,32 @@ export const setEnvironmentVariablesServerTool: BaseServerTool { - if (!context?.userId) { + const parsedPayload = parseEnvironmentVariablesPayload(params) + const scopedContext = withWorkspaceArgContext(context, parsedPayload) + if (!scopedContext?.userId) { throw new Error('Authentication required') } - const userId = context.userId - const validatedVariables = parseEnvironmentVariablesPayload(params) + const userId = scopedContext.userId + const workspaceId = scopedContext.workspaceId + if (!workspaceId) { + throw new Error('workspaceId is required') + } + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.exists || !workspaceAccess.hasAccess || !workspaceAccess.canWrite) { + throw new Error('Access denied: You do not have permission to edit this workspace') + } + + const validatedVariables = parsedPayload.variables const variableNames = Object.keys(validatedVariables) - throwIfServerToolAborted(context) + throwIfServerToolAborted(scopedContext) - const summary = await readEnvironmentVariableSummary(userId, variableNames) + const summary = await readEnvironmentVariableSummary(workspaceId, variableNames) const reviewBaseStateHash = hashEnvironmentVariableBase( variableNames.map((key) => [key, summary.existingValueByKey.get(key) ?? null]) ) - if (shouldStageServerToolMutationForReview(context)) { + if (shouldStageServerToolMutationForReview(scopedContext)) { return { requiresReview: true, success: true, @@ -133,9 +157,12 @@ export const setEnvironmentVariablesServerTool: BaseServerTool Date: Sun, 21 Jun 2026 17:22:03 -0600 Subject: [PATCH 073/284] style(mcp): reorder authorize page imports Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx index 29bea6f6b..49a700d19 100644 --- a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx +++ b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx @@ -1,10 +1,10 @@ import { getSessionCookie } from 'better-auth/cookies' import { headers } from 'next/headers' -import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' -import { inter } from '@/app/fonts/inter' import { Button } from '@/components/ui/button' import { getSession } from '@/lib/auth' import { createMcpDeviceLoginApprovalChallenge } from '@/lib/mcp/auth' +import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' +import { inter } from '@/app/fonts/inter' import { redirect } from '@/i18n/navigation' import { getPublicCopy } from '@/i18n/public-copy' import { normalizeLocaleCode } from '@/i18n/utils' From 4ba4ee03b9a280486f81779f5260b47a7d6cf193 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 17:22:29 -0600 Subject: [PATCH 074/284] feat(mcp): issue device login keys from approval state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../[locale]/(auth)/mcp/authorize/page.tsx | 4 + apps/tradinggoose/lib/api-key/service.ts | 24 ++++-- apps/tradinggoose/lib/mcp/auth.ts | 85 +++++++------------ 3 files changed, 53 insertions(+), 60 deletions(-) diff --git a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx index 49a700d19..232314985 100644 --- a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx +++ b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx @@ -76,6 +76,10 @@ export default async function McpAuthorizePage({ return renderStatus(mcpCopy.expired) } + if (challenge.status === 'invalid') { + return renderStatus(mcpCopy.invalid) + } + if (challenge.status === 'approved') { return renderStatus(mcpCopy.approved) } diff --git a/apps/tradinggoose/lib/api-key/service.ts b/apps/tradinggoose/lib/api-key/service.ts index 06523586a..383d03811 100644 --- a/apps/tradinggoose/lib/api-key/service.ts +++ b/apps/tradinggoose/lib/api-key/service.ts @@ -37,13 +37,16 @@ export interface CreatedPersonalApiKey { key: string } -export async function createApiKeyMaterial(useStorage = true): Promise<{ +export async function createApiKeyMaterial( + useStorage = true, + keyId?: string +): Promise<{ key: string encryptedKey?: string }> { try { const hasEncryptionKey = env.API_ENCRYPTION_KEY !== undefined - const plainKey = hasEncryptionKey ? generateEncryptedApiKey() : generateApiKey() + const plainKey = hasEncryptionKey ? generateEncryptedApiKey(keyId) : generateApiKey(keyId) if (useStorage) { const { encrypted } = await encryptApiKey(plainKey) @@ -67,7 +70,8 @@ export async function createPersonalApiKey({ throw new Error('API key name is required') } - const { key: plainKey, encryptedKey } = await createApiKeyMaterial(true) + const keyId = nanoid() + const { key: plainKey, encryptedKey } = await createApiKeyMaterial(true, keyId) if (!encryptedKey) { throw new Error('Failed to encrypt API key for storage') } @@ -75,7 +79,7 @@ export async function createPersonalApiKey({ const [newKey] = await db .insert(apiKeyTable) .values({ - id: nanoid(), + id: keyId, userId, workspaceId: null, name: trimmedName, @@ -131,6 +135,10 @@ export async function authenticateApiKeyFromHeader( // Apply filters const conditions = [] + const keyId = /^(?:sk-tradinggoose-|tradinggoose_)([^.]+)/.exec(apiKeyHeader)?.[1] + if (keyId) { + conditions.push(eq(apiKeyTable.id, keyId)) + } if (options.userId) { conditions.push(eq(apiKeyTable.userId, options.userId)) @@ -318,16 +326,16 @@ export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted * Generates a standardized API key with the 'tradinggoose_' prefix (plain-text format) * @returns A new API key string */ -export function generateApiKey(): string { - return `tradinggoose_${nanoid(32)}` +export function generateApiKey(keyId = nanoid()): string { + return `tradinggoose_${keyId}.${nanoid(32)}` } /** * Generates a new encrypted API key with the 'sk-tradinggoose-' prefix * @returns A new encrypted API key string */ -export function generateEncryptedApiKey(): string { - return `sk-tradinggoose-${nanoid(32)}` +export function generateEncryptedApiKey(keyId = nanoid()): string { + return `sk-tradinggoose-${keyId}.${nanoid(32)}` } /** diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 99a14dce5..546fedc41 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -3,7 +3,7 @@ import { db } from '@tradinggoose/db' import { apiKey, verification } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' -import { createApiKeyMaterial } from '@/lib/api-key/service' +import { createApiKeyMaterial, decryptApiKey } from '@/lib/api-key/service' const DEVICE_LOGIN_TTL_MS = 10 * 60 * 1000 const DEVICE_LOGIN_PREFIX = 'mcp:' @@ -23,6 +23,8 @@ type ApprovedDeviceLogin = { verificationKeyHash: string approvedAt: string userId: string + apiKeyId: string + encryptedApiKey: string } type DeviceLoginState = PendingDeviceLogin | ApprovedDeviceLogin @@ -79,7 +81,9 @@ function parseDeviceLoginState(value: string): DeviceLoginState | null { typeof parsed.createdAt === 'string' && typeof parsed.verificationKeyHash === 'string' && typeof parsed.approvedAt === 'string' && - typeof parsed.userId === 'string' + typeof parsed.userId === 'string' && + typeof parsed.apiKeyId === 'string' && + typeof parsed.encryptedApiKey === 'string' ) { return parsed as ApprovedDeviceLogin } @@ -134,49 +138,6 @@ async function updateDeviceLoginState( return Boolean(updated) } -async function issueDeviceLoginPersonalApiKey( - login: DeviceLogin, - approvedState: ApprovedDeviceLogin -): Promise { - const keyId = nanoid() - const { key: plainKey, encryptedKey } = await createApiKeyMaterial(true) - if (!encryptedKey) { - throw new Error('Failed to create MCP personal API key') - } - - const now = new Date() - const issued = await db.transaction(async (tx) => { - const [deleted] = await tx - .delete(verification) - .where(deviceLoginMatches(login, approvedState)) - .returning({ id: verification.id }) - - if (!deleted) { - return null - } - - const [createdKey] = await tx - .insert(apiKey) - .values({ - id: keyId, - userId: approvedState.userId, - workspaceId: null, - name: `TradingGoose MCP Access ${now.toISOString()}`, - key: encryptedKey, - type: 'personal', - createdAt: now, - updatedAt: now, - }) - .returning({ id: apiKey.id }) - - return createdKey - }) - - return issued - ? { status: 'approved', apiKey: plainKey, expiresAt: login.expiresAt.toISOString() } - : null -} - export async function startMcpDeviceLogin(): Promise { const code = randomBytes(32).toString('base64url') const verificationKey = randomBytes(32).toString('base64url') @@ -226,6 +187,10 @@ export async function createMcpDeviceLoginApprovalChallenge(code: string, userId } } + if (login.state.approvalToken) { + return { status: 'invalid' } + } + const approvalToken = randomBytes(32).toString('base64url') const nextState = { ...login.state, @@ -265,14 +230,23 @@ export async function pollMcpDeviceLogin( } } - const approvedState = login.state - - const issued = await issueDeviceLoginPersonalApiKey(login, approvedState) - if (!issued) { - continue - } + const now = new Date() + await db + .insert(apiKey) + .values({ + id: login.state.apiKeyId, + userId: login.state.userId, + workspaceId: null, + name: `TradingGoose MCP Access ${now.toISOString()}`, + key: login.state.encryptedApiKey, + type: 'personal', + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing() - return issued + const { decrypted } = await decryptApiKey(login.state.encryptedApiKey) + return { status: 'approved', apiKey: decrypted, expiresAt: login.expiresAt.toISOString() } } } @@ -303,12 +277,19 @@ export async function approveMcpDeviceLogin({ const now = new Date() const approvedAt = now.toISOString() + const apiKeyId = nanoid() + const { encryptedKey } = await createApiKeyMaterial(true, apiKeyId) + if (!encryptedKey) { + throw new Error('Failed to create MCP personal API key') + } const approvedState = { status: 'approved', createdAt: login.state.createdAt, verificationKeyHash: login.state.verificationKeyHash, approvedAt, userId, + apiKeyId, + encryptedApiKey: encryptedKey, } satisfies ApprovedDeviceLogin if (!(await updateDeviceLoginState(login, approvedState))) { From abe981895743d32a7758a1e1f67c13f3cd9b1192 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 17:40:35 -0600 Subject: [PATCH 075/284] fix(api-key): require complete key format for lookup Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/api-key/service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tradinggoose/lib/api-key/service.ts b/apps/tradinggoose/lib/api-key/service.ts index 383d03811..8948d58b6 100644 --- a/apps/tradinggoose/lib/api-key/service.ts +++ b/apps/tradinggoose/lib/api-key/service.ts @@ -135,7 +135,7 @@ export async function authenticateApiKeyFromHeader( // Apply filters const conditions = [] - const keyId = /^(?:sk-tradinggoose-|tradinggoose_)([^.]+)/.exec(apiKeyHeader)?.[1] + const keyId = /^(?:sk-tradinggoose-|tradinggoose_)([^.]+)\.[^.]+$/.exec(apiKeyHeader)?.[1] if (keyId) { conditions.push(eq(apiKeyTable.id, keyId)) } From e9c16c0256227ae6a7839037d0ae6e4163e1e485 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 17:40:44 -0600 Subject: [PATCH 076/284] fix(copilot): persist accepted reviewed entity updates Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/copilot/tools/server/entities/shared.test.ts | 9 +++------ .../lib/copilot/tools/server/entities/shared.ts | 9 ++------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts index 1014c9285..05c2ebf78 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts @@ -7,8 +7,7 @@ import { executeUpdateEntityDocumentMutation, } from './shared' -const { mockApplySavedEntityDraftState, mockApplySavedEntityPersistedState } = vi.hoisted(() => ({ - mockApplySavedEntityDraftState: vi.fn(), +const { mockApplySavedEntityPersistedState } = vi.hoisted(() => ({ mockApplySavedEntityPersistedState: vi.fn(), })) const mockCheckWorkspaceAccess = vi.hoisted(() => vi.fn()) @@ -24,7 +23,6 @@ vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ })) vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ - applySavedEntityDraftState: (...args: unknown[]) => mockApplySavedEntityDraftState(...args), applySavedEntityPersistedState: (...args: unknown[]) => mockApplySavedEntityPersistedState(...args), })) @@ -82,7 +80,7 @@ describe('entity document mutation helpers', () => { expect(mockReadBootstrappedSavedEntityFields).not.toHaveBeenCalled() }) - it('accepts reviewed updates into the saved-entity draft without persisting', async () => { + it('persists accepted reviewed updates after verifying the reviewed base', async () => { const currentFields = { name: 'Existing Skill', description: 'Existing description', @@ -110,8 +108,7 @@ describe('entity document mutation helpers', () => { } ) - expect(mockApplySavedEntityDraftState).toHaveBeenCalledWith('skill', 'skill-1', nextFields) - expect(mockApplySavedEntityPersistedState).not.toHaveBeenCalled() + expect(mockApplySavedEntityPersistedState).toHaveBeenCalledWith('skill', 'skill-1', nextFields) }) it('keeps Studio create mutations in review mode', async () => { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index da07a4b84..37b69c342 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -19,10 +19,7 @@ import { } from '@/lib/copilot/tools/server/base-tool' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import type { SavedEntityKind } from '@/lib/yjs/entity-state' -import { - applySavedEntityDraftState, - applySavedEntityPersistedState, -} from '@/lib/yjs/server/apply-entity-state' +import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' import { readBootstrappedSavedEntityFields } from '@/lib/yjs/server/bootstrap-review-target' export type SavedEntityDocumentKind = EntityDocumentKind @@ -302,9 +299,7 @@ export async function executeUpdateEntityDocumentMutation( ) } - if (context?.acceptedReviewBaseStateHash) { - await applySavedEntityDraftState(kind as SavedEntityKind, entityId, fields) - } else if (apply) { + if (apply) { await apply({ entityId, fields, workspaceId }) } else { await applySavedEntityDocument(kind, entityId, fields) From 935c22177954d5434eae0fde6fb2982e389ff781 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 18:06:24 -0600 Subject: [PATCH 077/284] fix(api-keys): bind generated key material to stored id Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../tradinggoose/app/api/workspaces/[id]/api-keys/route.ts | 5 +++-- apps/tradinggoose/lib/api-key/auth.ts | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts b/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts index 6c43b8cf5..0cccfa6ea 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts @@ -122,7 +122,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ) } - const { key: plainKey, encryptedKey } = await createApiKey(true) + const keyId = nanoid() + const { key: plainKey, encryptedKey } = await createApiKey(true, keyId) if (!encryptedKey) { throw new Error('Failed to encrypt API key for storage') @@ -131,7 +132,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const [newKey] = await db .insert(apiKey) .values({ - id: nanoid(), + id: keyId, workspaceId, userId: userId, createdBy: userId, diff --git a/apps/tradinggoose/lib/api-key/auth.ts b/apps/tradinggoose/lib/api-key/auth.ts index fff54366b..eaa4af761 100644 --- a/apps/tradinggoose/lib/api-key/auth.ts +++ b/apps/tradinggoose/lib/api-key/auth.ts @@ -83,11 +83,14 @@ export async function authenticateApiKey(inputKey: string, storedKey: string): P * @param useStorage - Whether to encrypt the key before storage (default: true) * @returns Promise<{key: string, encryptedKey?: string}> - The plain key and optionally encrypted version */ -export async function createApiKey(useStorage = true): Promise<{ +export async function createApiKey( + useStorage = true, + keyId?: string +): Promise<{ key: string encryptedKey?: string }> { - return createApiKeyMaterial(useStorage) + return createApiKeyMaterial(useStorage, keyId) } /** From e40590f0ce7d84ede57fc8af13b7950a30c6f832 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 18:06:52 -0600 Subject: [PATCH 078/284] fix(copilot): pass workspace context to environment writes Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/copilot/tool-prompt-metadata.ts | 2 +- .../stores/copilot/tool-registry.test.ts | 14 ++++++++++++++ apps/tradinggoose/stores/copilot/tool-registry.ts | 1 + 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts index 9786877ba..13e92ff87 100644 --- a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts +++ b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts @@ -121,7 +121,7 @@ export const TOOL_PROMPT_METADATA: Record = { entityKind: 'environment', }, set_environment_variables: { - description: 'Set environment variables.', + description: 'Set environment variables in the selected workspace.', kind: 'edit', entityKind: 'environment', }, diff --git a/apps/tradinggoose/stores/copilot/tool-registry.test.ts b/apps/tradinggoose/stores/copilot/tool-registry.test.ts index ada4c9406..5131a84ef 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.test.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.test.ts @@ -80,6 +80,20 @@ describe('tool-registry', () => { ).toThrow() }) + it('injects hosted workspace context for workspace environment mutations', () => { + const context = createExecutionContext({ + toolCallId, + toolName: 'set_environment_variables', + provenance: { workspaceId: 'workspace-1' }, + }) + + const args = { variables: { API_KEY: 'secret' } } + expect(prepareCopilotToolArgs('set_environment_variables', args, context)).toEqual({ + workspaceId: 'workspace-1', + variables: { API_KEY: 'secret' }, + }) + }) + it('injects hosted workspace context into workspace-targeted knowledge base tools', () => { const context = createExecutionContext({ toolCallId, diff --git a/apps/tradinggoose/stores/copilot/tool-registry.ts b/apps/tradinggoose/stores/copilot/tool-registry.ts index 8b6f00262..b0826996e 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.ts @@ -144,6 +144,7 @@ const WORKSPACE_TARGETED_TOOL_NAMES = new Set([ CopilotTool.list_workflows, CopilotTool.get_agent_accessory_catalog, CopilotTool.read_environment_variables, + CopilotTool.set_environment_variables, CopilotTool.read_credentials, CopilotTool.read_oauth_credentials, CopilotTool.list_gdrive_files, From 6bc41467169ea531e2651aaaf5070cead0437a26 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 18:07:08 -0600 Subject: [PATCH 079/284] style(copilot): wrap credential prompt metadata Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts index 13e92ff87..4e02ac217 100644 --- a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts +++ b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts @@ -131,7 +131,8 @@ export const TOOL_PROMPT_METADATA: Record = { entityKind: 'credential', }, [CopilotTool.read_credentials]: { - description: 'Read OAuth credentials and related environment variable names for the selected workspace.', + description: + 'Read OAuth credentials and related environment variable names for the selected workspace.', kind: 'read', entityKind: 'credential', }, From 9d332c64079d3e5f8c3bc87b063e0d4484c8d01c Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 18:07:21 -0600 Subject: [PATCH 080/284] docs(mcp): clarify device grant retry window Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/mcp/auth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 546fedc41..fbe39d1c1 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -230,6 +230,7 @@ export async function pollMcpDeviceLogin( } } + // Keep the approved grant retryable until TTL; code plus verificationKey is the local device secret. const now = new Date() await db .insert(apiKey) From f73a11305f14b4ffbbdb11ba06ec9580ac3a2d89 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 18:43:20 -0600 Subject: [PATCH 081/284] fix(mcp): make device login delivery idempotent Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/mcp/auth.ts | 47 ++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index fbe39d1c1..86bb28d86 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -25,6 +25,7 @@ type ApprovedDeviceLogin = { userId: string apiKeyId: string encryptedApiKey: string + deliveredAt?: string } type DeviceLoginState = PendingDeviceLogin | ApprovedDeviceLogin @@ -83,7 +84,8 @@ function parseDeviceLoginState(value: string): DeviceLoginState | null { typeof parsed.approvedAt === 'string' && typeof parsed.userId === 'string' && typeof parsed.apiKeyId === 'string' && - typeof parsed.encryptedApiKey === 'string' + typeof parsed.encryptedApiKey === 'string' && + (parsed.deliveredAt === undefined || typeof parsed.deliveredAt === 'string') ) { return parsed as ApprovedDeviceLogin } @@ -230,23 +232,48 @@ export async function pollMcpDeviceLogin( } } - // Keep the approved grant retryable until TTL; code plus verificationKey is the local device secret. + const approvedState = login.state + if (approvedState.deliveredAt) { + const [existingKey] = await db + .select({ id: apiKey.id }) + .from(apiKey) + .where(eq(apiKey.id, approvedState.apiKeyId)) + .limit(1) + if (!existingKey) { + return { status: 'expired' } + } + const { decrypted } = await decryptApiKey(approvedState.encryptedApiKey) + return { status: 'approved', apiKey: decrypted, expiresAt: login.expiresAt.toISOString() } + } + const now = new Date() - await db - .insert(apiKey) - .values({ - id: login.state.apiKeyId, - userId: login.state.userId, + const deliveredState = { ...approvedState, deliveredAt: now.toISOString() } + const delivered = await db.transaction(async (tx) => { + const [updated] = await tx + .update(verification) + .set({ value: JSON.stringify(deliveredState), updatedAt: now }) + .where(deviceLoginMatches(login)) + .returning({ id: verification.id }) + if (!updated) { + return false + } + await tx.insert(apiKey).values({ + id: approvedState.apiKeyId, + userId: approvedState.userId, workspaceId: null, name: `TradingGoose MCP Access ${now.toISOString()}`, - key: login.state.encryptedApiKey, + key: approvedState.encryptedApiKey, type: 'personal', createdAt: now, updatedAt: now, }) - .onConflictDoNothing() + return true + }) + if (!delivered) { + continue + } - const { decrypted } = await decryptApiKey(login.state.encryptedApiKey) + const { decrypted } = await decryptApiKey(approvedState.encryptedApiKey) return { status: 'approved', apiKey: decrypted, expiresAt: login.expiresAt.toISOString() } } } From 26809fb44158c3bc34369e2cbae30f5140b5d436 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 18:43:40 -0600 Subject: [PATCH 082/284] fix(yjs): persist state before applying document updates Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/yjs/server/apply-entity-state.ts | 2 +- .../lib/yjs/server/apply-workflow-state.test.ts | 4 ++-- apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts | 7 +++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index b0eea1631..7d4ac8288 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -135,6 +135,6 @@ export async function applySavedEntityPersistedState( entityId: string, fields: Record ): Promise { - await applySavedEntityDraftState(entityKind, entityId, fields) await persistSavedEntityState(entityKind, entityId, fields) + await applySavedEntityDraftState(entityKind, entityId, fields) } diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts index a2646466d..3970b122c 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts @@ -155,8 +155,8 @@ describe('applyWorkflowState', () => { 'normalized-block': expect.objectContaining({ id: 'normalized-block' }), }, }) - expect(mockStoreCanonicalState.mock.invocationCallOrder[0]).toBeLessThan( - mockSaveWorkflowToNormalizedTables.mock.invocationCallOrder[0] + expect(mockSaveWorkflowToNormalizedTables.mock.invocationCallOrder[0]).toBeLessThan( + mockStoreCanonicalState.mock.invocationCallOrder[0] ) expect(mockUpdateSet).toHaveBeenCalledWith( expect.not.objectContaining({ diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index 423daf550..4343eb4fb 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -72,8 +72,6 @@ export async function applyWorkflowState( : {}), }) - await applyWorkflowStateToYjs(workflowId, storedWorkflowState, variables, entityName) - const saveResult = await saveWorkflowToNormalizedTables(workflowId, storedWorkflowState) if (!saveResult.success) { throw new Error(saveResult.error || 'Failed to materialize workflow state') @@ -92,6 +90,8 @@ export async function applyWorkflowState( if (!updatedWorkflow) { throw new Error('Workflow not found') } + + await applyWorkflowStateToYjs(workflowId, storedWorkflowState, variables, entityName) } export async function applyWorkflowEntityName( @@ -100,8 +100,6 @@ export async function applyWorkflowEntityName( variables: Record, entityName: string ): Promise { - await applyWorkflowStateToYjs(workflowId, workflowState, variables, entityName) - const [updatedWorkflow] = await db .update(workflow) .set({ name: entityName, updatedAt: new Date() }) @@ -112,5 +110,6 @@ export async function applyWorkflowEntityName( throw new Error('Workflow not found') } + await applyWorkflowStateToYjs(workflowId, workflowState, variables, entityName) return updatedWorkflow } From e8698a9c3d612c03aabd42279e4bc960b7c98caa Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 19:04:32 -0600 Subject: [PATCH 083/284] fix(yjs): refresh stale saved target snapshots Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/workflows/db-helpers.ts | 9 ++ .../lib/yjs/server/bootstrap-review-target.ts | 36 +++++++- .../lib/yjs/server/entity-loaders.ts | 82 +++++++++---------- .../lib/yjs/server/snapshot-bridge.ts | 1 + apps/tradinggoose/socket-server/index.test.ts | 1 + .../tradinggoose/socket-server/routes/http.ts | 19 ++--- 6 files changed, 92 insertions(+), 56 deletions(-) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 2f68c386d..ce8bfaffd 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -128,6 +128,15 @@ export type WorkflowStateWithSource = PersistedWorkflowState & { source: 'yjs' | 'db' } +export async function readWorkflowUpdatedAt(workflowId: string): Promise { + const [row] = await db + .select({ updatedAt: workflow.updatedAt }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + return row?.updatedAt ?? null +} + export async function loadWorkflowState( workflowId: string ): Promise { diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 64455917d..dbe93e2ba 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -9,14 +9,20 @@ import type { ReviewTargetDescriptor, ReviewTargetRuntimeState, } from '@/lib/copilot/review-sessions/types' -import { loadWorkflowStateFromSavedTables } from '@/lib/workflows/db-helpers' +import { loadWorkflowStateFromSavedTables, readWorkflowUpdatedAt } from '@/lib/workflows/db-helpers' import { getEntityFields, seedEntitySession } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { readSavedEntityFieldsFromDb, + readSavedEntityUpdatedAt, resolveEntityWorkspaceId, } from '@/lib/yjs/server/entity-loaders' -import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' +import { + deleteYjsSessionInSocketServer, + getYjsSnapshot, + SocketServerBridgeError, + type YjsSnapshotResponse, +} from '@/lib/yjs/server/snapshot-bridge' import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' import { createWorkflowSnapshot, @@ -57,7 +63,11 @@ export function getRuntimeStateFromUpdate(update: Uint8Array): ReviewTargetRunti export async function readBootstrappedReviewTargetSnapshot(descriptor: ReviewTargetDescriptor) { const bridgeParams = serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor)) try { - return await getYjsSnapshot(descriptor.yjsSessionId, bridgeParams) + const snapshot = await getYjsSnapshot(descriptor.yjsSessionId, bridgeParams) + if (await isSavedTargetSnapshotFresh(descriptor, snapshot)) { + return snapshot + } + await deleteYjsSessionInSocketServer(descriptor.yjsSessionId) } catch (error) { if (!(error instanceof SocketServerBridgeError) || error.status !== 404) { throw error @@ -89,6 +99,26 @@ export async function readBootstrappedReviewTargetSnapshot(descriptor: ReviewTar } } +async function isSavedTargetSnapshotFresh( + descriptor: ReviewTargetDescriptor, + snapshot: YjsSnapshotResponse +): Promise { + if (descriptor.reviewSessionId || !descriptor.entityId) { + return true + } + + const savedAt = + descriptor.entityKind === 'workflow' + ? await readWorkflowUpdatedAt(descriptor.entityId) + : await readSavedEntityUpdatedAt( + descriptor.entityKind as SavedEntityKind, + descriptor.entityId + ) + return Boolean( + savedAt && typeof snapshot.touchedAt === 'number' && snapshot.touchedAt >= savedAt.getTime() + ) +} + export async function readBootstrappedSavedEntityFields( entityKind: SavedEntityKind, entityId: string, diff --git a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts index 569e93c26..ab0f61eb0 100644 --- a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts +++ b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts @@ -9,52 +9,37 @@ import { import { and, eq, isNull } from 'drizzle-orm' import { type SavedEntityKind, savedEntityRowToFields } from '@/lib/yjs/entity-state' +const ENTITY_TABLES = { + skill, + custom_tool: customTools, + indicator: pineIndicators, + knowledge_base: knowledgeBase, + mcp_server: mcpServers, +} as const + +function entityTable(entityKind: SavedEntityKind) { + return ENTITY_TABLES[entityKind] as any +} + +function entityIdCondition(entityKind: SavedEntityKind, entityId: string) { + const table = entityTable(entityKind) + const byId = eq(table.id, entityId) + return entityKind === 'knowledge_base' || entityKind === 'mcp_server' + ? and(byId, isNull(table.deletedAt)) + : byId +} + export async function resolveEntityWorkspaceId( entityKind: SavedEntityKind, entityId: string ): Promise { - switch (entityKind) { - case 'skill': { - const [row] = await db - .select({ workspaceId: skill.workspaceId }) - .from(skill) - .where(eq(skill.id, entityId)) - .limit(1) - return row?.workspaceId ?? null - } - case 'custom_tool': { - const [row] = await db - .select({ workspaceId: customTools.workspaceId }) - .from(customTools) - .where(eq(customTools.id, entityId)) - .limit(1) - return row?.workspaceId ?? null - } - case 'indicator': { - const [row] = await db - .select({ workspaceId: pineIndicators.workspaceId }) - .from(pineIndicators) - .where(eq(pineIndicators.id, entityId)) - .limit(1) - return row?.workspaceId ?? null - } - case 'knowledge_base': { - const [row] = await db - .select({ workspaceId: knowledgeBase.workspaceId }) - .from(knowledgeBase) - .where(and(eq(knowledgeBase.id, entityId), isNull(knowledgeBase.deletedAt))) - .limit(1) - return row?.workspaceId ?? null - } - case 'mcp_server': { - const [row] = await db - .select({ workspaceId: mcpServers.workspaceId }) - .from(mcpServers) - .where(and(eq(mcpServers.id, entityId), isNull(mcpServers.deletedAt))) - .limit(1) - return row?.workspaceId ?? null - } - } + const table = entityTable(entityKind) + const [row] = await db + .select({ workspaceId: table.workspaceId }) + .from(table) + .where(entityIdCondition(entityKind, entityId)) + .limit(1) + return row?.workspaceId ?? null } export async function readSavedEntityFieldsFromDb( @@ -120,3 +105,16 @@ export async function readSavedEntityFieldsFromDb( return savedEntityRowToFields(entityKind, row) } + +export async function readSavedEntityUpdatedAt( + entityKind: SavedEntityKind, + entityId: string +): Promise { + const table = entityTable(entityKind) + const [row] = await db + .select({ updatedAt: table.updatedAt }) + .from(table) + .where(entityIdCondition(entityKind, entityId)) + .limit(1) + return row?.updatedAt ?? null +} diff --git a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts index e947a9648..e3d7a0475 100644 --- a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts +++ b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts @@ -9,6 +9,7 @@ export interface YjsSnapshotResponse { snapshotBase64: string descriptor: ReviewTargetDescriptor runtime: ReviewTargetRuntimeState + touchedAt?: number | null } export class SocketServerBridgeError extends Error { diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index db407cd5d..6de1e5ead 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -455,6 +455,7 @@ describe('Socket Server Index Integration', () => { yjsSessionId: 'workflow-state-update', }, runtime: getRuntimeStateFromDoc(liveDoc!), + touchedAt: expect.any(Number), }) const doc = new Y.Doc() diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index 15f7710ba..19946727f 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -16,6 +16,7 @@ import { replaceWorkflowDocumentState, type WorkflowSnapshot } from '@/lib/yjs/w import { getMonitorRuntimeLockHealth } from '@/socket-server/monitor-runtime-lock' import { deleteSession, + getLastTouchedAt, getState, storeCanonicalState, storeState, @@ -345,18 +346,13 @@ async function handleInternalYjsSessionClearReseededRequest( async function getLiveOrPersistedYjsState( sessionId: string -): Promise<{ liveDoc: Y.Doc | null; state: Uint8Array | null }> { +): Promise<{ liveDoc: Y.Doc | null; state: Uint8Array | null; touchedAt: number | null }> { const liveDoc = await getExistingDocument(sessionId) - if (liveDoc) { - return { - liveDoc, - state: Y.encodeStateAsUpdate(liveDoc), - } - } - + const state = liveDoc ? Y.encodeStateAsUpdate(liveDoc) : await getState(sessionId) return { - liveDoc: null, - state: await getState(sessionId), + liveDoc, + state, + touchedAt: state ? await getLastTouchedAt(sessionId) : null, } } @@ -375,7 +371,7 @@ async function handleInternalYjsSnapshotRequest( } const descriptor = buildReviewTargetDescriptorFromEnvelope(envelope) - const { liveDoc, state } = await getLiveOrPersistedYjsState(sessionId) + const { liveDoc, state, touchedAt } = await getLiveOrPersistedYjsState(sessionId) if (!state) { res.writeHead(404, { 'Content-Type': 'application/json' }) @@ -391,6 +387,7 @@ async function handleInternalYjsSnapshotRequest( snapshotBase64: Buffer.from(state).toString('base64'), descriptor, runtime, + touchedAt, }) ) } catch (error) { From 7ff5782bd3413a1e520ff0c090d8d882325ba367 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 20:04:27 -0600 Subject: [PATCH 084/284] fix(yjs): make saved entity persistence best effort Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/indicators/custom/route.ts | 16 ++--- .../tradinggoose/app/api/mcp/servers/route.ts | 13 ++-- .../app/api/tools/custom/route.ts | 15 ++--- .../app/api/workflows/[id]/route.test.ts | 23 +++---- .../app/api/workflows/[id]/route.ts | 18 ++---- .../lib/custom-tools/operations.ts | 52 ++++++--------- .../lib/indicators/custom/operations.ts | 52 ++++++--------- apps/tradinggoose/lib/knowledge/service.ts | 4 +- .../lib/skills/operations.test.ts | 13 ++-- apps/tradinggoose/lib/skills/operations.ts | 64 +++++++------------ apps/tradinggoose/lib/yjs/entity-state.ts | 28 ++++---- .../lib/yjs/server/apply-workflow-state.ts | 5 +- .../lib/yjs/server/snapshot-bridge.ts | 8 +++ 13 files changed, 126 insertions(+), 185 deletions(-) diff --git a/apps/tradinggoose/app/api/indicators/custom/route.ts b/apps/tradinggoose/app/api/indicators/custom/route.ts index 1eb360eae..50c9ea7ad 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -6,7 +6,7 @@ import { z } from 'zod' import { upsertIndicators } from '@/lib/indicators/custom/operations' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { tryDeleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { authenticateIndicatorRequest, checkWorkspacePermission } from '../utils' const logger = createLogger('IndicatorsAPI') @@ -220,21 +220,17 @@ export async function DELETE(request: NextRequest) { return permissionCheck.response } - const [existingIndicator] = await db - .select({ id: pineIndicators.id }) - .from(pineIndicators) + const [deletedIndicator] = await db + .delete(pineIndicators) .where(and(eq(pineIndicators.id, indicatorId), eq(pineIndicators.workspaceId, workspaceId))) - .limit(1) + .returning({ id: pineIndicators.id }) - if (!existingIndicator) { + if (!deletedIndicator) { logger.warn(`[${requestId}] Indicator not found: ${indicatorId}`) return NextResponse.json({ error: 'Indicator not found' }, { status: 404 }) } - await deleteYjsSessionInSocketServer(indicatorId) - await db - .delete(pineIndicators) - .where(and(eq(pineIndicators.id, indicatorId), eq(pineIndicators.workspaceId, workspaceId))) + await tryDeleteYjsSessionInSocketServer(indicatorId) logger.info(`[${requestId}] Deleted indicator ${indicatorId}`) return NextResponse.json({ success: true }, { status: 200 }) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index c45162f42..9031bb384 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -10,7 +10,7 @@ import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' -import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { tryDeleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { CreateMcpServerSchema } from './schema' const logger = createLogger('McpServersAPI') @@ -173,10 +173,9 @@ export const DELETE = withMcpAuth('write')( logger.info(`[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}`) const [server] = await db - .select({ id: mcpServers.id }) - .from(mcpServers) + .delete(mcpServers) .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - .limit(1) + .returning({ id: mcpServers.id }) if (!server) { return createMcpErrorResponse( @@ -186,11 +185,7 @@ export const DELETE = withMcpAuth('write')( ) } - await deleteYjsSessionInSocketServer(serverId) - await db - .delete(mcpServers) - .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - + await tryDeleteYjsSessionInSocketServer(serverId) mcpService.clearCache(workspaceId) logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) diff --git a/apps/tradinggoose/app/api/tools/custom/route.ts b/apps/tradinggoose/app/api/tools/custom/route.ts index 7e38db129..bc2f0be19 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.ts @@ -9,7 +9,7 @@ import { CustomToolUpsertRequestSchema } from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' -import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { tryDeleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('CustomToolsAPI') @@ -174,20 +174,17 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) } - // Check if the tool exists in this workspace - const existingTool = await db - .select() - .from(customTools) + const deletedTool = await db + .delete(customTools) .where(and(eq(customTools.id, toolId), eq(customTools.workspaceId, workspaceId))) - .limit(1) + .returning({ id: customTools.id }) - if (existingTool.length === 0) { + if (deletedTool.length === 0) { logger.warn(`[${requestId}] Tool not found: ${toolId}`) return NextResponse.json({ error: 'Tool not found' }, { status: 404 }) } - await deleteYjsSessionInSocketServer(toolId) - await db.delete(customTools).where(eq(customTools.id, toolId)) + await tryDeleteYjsSessionInSocketServer(toolId) logger.info(`[${requestId}] Deleted tool: ${toolId}`) return NextResponse.json({ success: true }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index 26bae6c41..b0f140e71 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -18,7 +18,7 @@ describe('Workflow By ID API Route', () => { const mockReadWorkflowById = vi.fn() const mockReadWorkflowAccessContext = vi.fn() - const mockDeleteYjsSessionInSocketServer = vi.fn() + const mockTryDeleteYjsSessionInSocketServer = vi.fn() const mockLoadWorkflowState = vi.fn() const mockApplyWorkflowEntityName = vi.fn() const mockWorkflowRenameState = { @@ -74,10 +74,10 @@ describe('Workflow By ID API Route', () => { mockReadWorkflowById.mockReset() mockReadWorkflowAccessContext.mockReset() - mockDeleteYjsSessionInSocketServer.mockReset() + mockTryDeleteYjsSessionInSocketServer.mockReset() mockLoadWorkflowState.mockReset() mockApplyWorkflowEntityName.mockReset() - mockDeleteYjsSessionInSocketServer.mockResolvedValue(undefined) + mockTryDeleteYjsSessionInSocketServer.mockResolvedValue(undefined) mockLoadWorkflowState.mockResolvedValue(null) mockApplyWorkflowEntityName.mockResolvedValue({ id: 'workflow-123', @@ -90,7 +90,7 @@ describe('Workflow By ID API Route', () => { })) vi.doMock('@/lib/yjs/server/snapshot-bridge', () => ({ - deleteYjsSessionInSocketServer: mockDeleteYjsSessionInSocketServer, + tryDeleteYjsSessionInSocketServer: mockTryDeleteYjsSessionInSocketServer, })) vi.doMock('@/lib/workflows/utils', () => ({ @@ -391,7 +391,7 @@ describe('Workflow By ID API Route', () => { isOwner: true, isWorkspaceOwner: false, }) - mockDeleteYjsSessionInSocketServer.mockImplementationOnce(async () => { + mockTryDeleteYjsSessionInSocketServer.mockImplementationOnce(async () => { events.push('socket-delete') }) @@ -418,7 +418,7 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(200) const data = await response.json() expect(data.success).toBe(true) - expect(mockDeleteYjsSessionInSocketServer).toHaveBeenCalledWith('workflow-123') + expect(mockTryDeleteYjsSessionInSocketServer).toHaveBeenCalledWith('workflow-123') expect(events).toEqual(['db-delete', 'socket-delete']) }) @@ -467,7 +467,7 @@ describe('Workflow By ID API Route', () => { const data = await response.json() expect(data.error).toBe('Internal server error') expect(deleteWhereMock).toHaveBeenCalledOnce() - expect(mockDeleteYjsSessionInSocketServer).not.toHaveBeenCalled() + expect(mockTryDeleteYjsSessionInSocketServer).not.toHaveBeenCalled() }) it('should allow admin to delete workspace workflow', async () => { @@ -538,7 +538,7 @@ describe('Workflow By ID API Route', () => { isOwner: true, isWorkspaceOwner: false, }) - mockDeleteYjsSessionInSocketServer.mockRejectedValueOnce(new Error('socket offline')) + mockTryDeleteYjsSessionInSocketServer.mockResolvedValueOnce(undefined) vi.doMock('@tradinggoose/db', () => ({ db: { @@ -561,12 +561,7 @@ describe('Workflow By ID API Route', () => { const data = await response.json() expect(data.success).toBe(true) expect(deleteWhereMock).toHaveBeenCalledOnce() - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to delete socket/Yjs session for workflow workflow-123'), - expect.objectContaining({ - workflowId: 'workflow-123', - }) - ) + expect(mockTryDeleteYjsSessionInSocketServer).toHaveBeenCalledWith('workflow-123') }) it('should deny deletion for non-admin users', async () => { diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index 2e8e7ec17..be6764f34 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -12,7 +12,7 @@ import { generateRequestId } from '@/lib/utils' import { loadWorkflowState } from '@/lib/workflows/db-helpers' import { readWorkflowAccessContext, readWorkflowById } from '@/lib/workflows/utils' import { applyWorkflowEntityName } from '@/lib/yjs/server/apply-workflow-state' -import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { tryDeleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' const logger = createLogger('WorkflowByIdAPI') @@ -287,16 +287,7 @@ export async function DELETE( await db.delete(workflow).where(eq(workflow.id, workflowId)) - // Best-effort cleanup of the authoritative socket/Yjs session. - // Do not block workflow deletion if the bridge is unavailable. - try { - await deleteYjsSessionInSocketServer(workflowId) - } catch (error) { - logger.warn(`[${requestId}] Failed to delete socket/Yjs session for workflow ${workflowId}`, { - error, - workflowId, - }) - } + await tryDeleteYjsSessionInSocketServer(workflowId) const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully deleted workflow ${workflowId} in ${elapsed}ms`) @@ -387,11 +378,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ lastSaved: new Date(workflowState.lastSaved).toISOString(), }), workflowState.variables, - updates.name + updates.name, + updateData ) } - if (!updatedWorkflow || updates.description !== undefined || updates.folderId !== undefined) { + if (!updatedWorkflow) { ;[updatedWorkflow] = await db .update(workflow) .set(updateData) diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index c0b9d8d24..39d853ac3 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -1,6 +1,6 @@ import { db } from '@tradinggoose/db' import { customTools } from '@tradinggoose/db/schema' -import { and, desc, eq, inArray } from 'drizzle-orm' +import { and, desc, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import { type CustomToolTransferRecord, @@ -8,7 +8,7 @@ import { } from '@/lib/custom-tools/import-export' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityRows } from '@/lib/yjs/entity-state' +import { syncSavedEntityRowsToYjs } from '@/lib/yjs/entity-state' const logger = createLogger('CustomToolsOperations') @@ -50,7 +50,6 @@ export async function upsertCustomTools({ }: UpsertCustomToolsParams) { const createdRows: Array = [] const updatedRows: Array = [] - const createdIds: string[] = [] await db.transaction(async (tx) => { for (const tool of tools) { const nowTime = new Date() @@ -79,17 +78,25 @@ export async function upsertCustomTools({ .limit(1) if (duplicateTitle.length > 0 && duplicateTitle[0].id !== tool.id) { - throw new Error(`A tool with the title "${tool.title}" already exists in this workspace`) + throw new Error( + `A tool with the title "${tool.title}" already exists in this workspace` + ) } + const [updatedTool] = await tx + .update(customTools) + .set({ + title: tool.title, + schema: tool.schema, + code: tool.code, + updatedAt: nowTime, + }) + .where(and(eq(customTools.id, tool.id), eq(customTools.workspaceId, workspaceId))) + .returning() + if (updatedTool) { + updatedRows.push(updatedTool) + } logger.info(`[${requestId}] Updated custom tool ${tool.id}`) - updatedRows.push({ - ...existingTool[0], - title: tool.title, - schema: tool.schema, - code: tool.code, - updatedAt: nowTime, - }) continue } } @@ -109,18 +116,10 @@ export async function upsertCustomTools({ logger.info(`[${requestId}] Created custom tool ${tool.title}`) createdRows.push(newTool) - createdIds.push(toolId) } }) - await applySavedEntityRows('custom_tool', createdRows, { - rollbackRows: async () => { - if (createdIds.length > 0) { - await db.delete(customTools).where(inArray(customTools.id, createdIds)) - } - }, - }) - await applySavedEntityRows('custom_tool', updatedRows) + await syncSavedEntityRowsToYjs('custom_tool', [...createdRows, ...updatedRows]) return listCustomTools({ workspaceId }) } @@ -172,18 +171,7 @@ export async function importCustomTools({ } }) - await applySavedEntityRows('custom_tool', result.tools, { - rollbackRows: async () => { - if (result.tools.length > 0) { - await db.delete(customTools).where( - inArray( - customTools.id, - result.tools.map((row) => row.id) - ) - ) - } - }, - }) + await syncSavedEntityRowsToYjs('custom_tool', result.tools) return result } diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index 5d05fae2e..c7ad52d8b 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -1,6 +1,6 @@ import { db } from '@tradinggoose/db' import { pineIndicators } from '@tradinggoose/db/schema' -import { and, desc, eq, inArray } from 'drizzle-orm' +import { and, desc, eq } from 'drizzle-orm' import { getStableVibrantColor } from '@/lib/colors' import { type IndicatorTransferRecord, @@ -9,7 +9,7 @@ import { import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityRows } from '@/lib/yjs/entity-state' +import { syncSavedEntityRowsToYjs } from '@/lib/yjs/entity-state' const logger = createLogger('IndicatorsOperations') @@ -54,7 +54,6 @@ export async function upsertIndicators({ }: UpsertIndicatorsParams) { const createdRows: Array = [] const updatedRows: Array = [] - const createdIds: string[] = [] await db.transaction(async (tx) => { for (const indicator of indicators) { const nowTime = new Date() @@ -71,15 +70,23 @@ export async function upsertIndicators({ if (existing.length > 0) { const existingColor = existing[0]?.color + const [updatedIndicator] = await tx + .update(pineIndicators) + .set({ + name: indicator.name, + color: existingColor ?? getStableVibrantColor(indicator.id), + pineCode: indicator.pineCode, + inputMeta: indicator.inputMeta ?? null, + updatedAt: nowTime, + }) + .where( + and(eq(pineIndicators.id, indicator.id), eq(pineIndicators.workspaceId, workspaceId)) + ) + .returning() + if (updatedIndicator) { + updatedRows.push(updatedIndicator) + } logger.info(`[${requestId}] Updated Indicator ${indicator.id}`) - updatedRows.push({ - ...existing[0], - name: indicator.name, - color: existingColor ?? getStableVibrantColor(indicator.id), - pineCode: indicator.pineCode, - inputMeta: indicator.inputMeta ?? null, - updatedAt: nowTime, - }) continue } } @@ -100,18 +107,10 @@ export async function upsertIndicators({ logger.info(`[${requestId}] Created Indicator ${indicator.name}`) createdRows.push(newIndicator) - createdIds.push(indicatorId) } }) - await applySavedEntityRows('indicator', createdRows, { - rollbackRows: async () => { - if (createdIds.length > 0) { - await db.delete(pineIndicators).where(inArray(pineIndicators.id, createdIds)) - } - }, - }) - await applySavedEntityRows('indicator', updatedRows) + await syncSavedEntityRowsToYjs('indicator', [...createdRows, ...updatedRows]) return db .select() @@ -173,18 +172,7 @@ export async function importIndicators({ } }) - await applySavedEntityRows('indicator', result.indicators, { - rollbackRows: async () => { - if (result.indicators.length > 0) { - await db.delete(pineIndicators).where( - inArray( - pineIndicators.id, - result.indicators.map((row) => row.id) - ) - ) - } - }, - }) + await syncSavedEntityRowsToYjs('indicator', result.indicators) return result } diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index 1bd609aa8..f5ecd6d34 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -23,7 +23,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/permissions/utils' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' -import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { tryDeleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('KnowledgeBaseService') @@ -451,7 +451,6 @@ export async function deleteKnowledgeBase( ): Promise { const now = new Date() - await deleteYjsSessionInSocketServer(knowledgeBaseId) await db .update(knowledgeBase) .set({ @@ -459,6 +458,7 @@ export async function deleteKnowledgeBase( updatedAt: now, }) .where(eq(knowledgeBase.id, knowledgeBaseId)) + await tryDeleteYjsSessionInSocketServer(knowledgeBaseId) logger.info(`[${requestId}] Soft deleted knowledge base: ${knowledgeBaseId}`) } diff --git a/apps/tradinggoose/lib/skills/operations.test.ts b/apps/tradinggoose/lib/skills/operations.test.ts index 15a6272f7..320bbed55 100644 --- a/apps/tradinggoose/lib/skills/operations.test.ts +++ b/apps/tradinggoose/lib/skills/operations.test.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockTransaction, mockNanoid, mockApplySavedEntityPersistedState } = vi.hoisted(() => ({ +const { mockTransaction, mockNanoid, mockApplySavedEntityDraftState } = vi.hoisted(() => ({ mockTransaction: vi.fn(), mockNanoid: vi.fn(), - mockApplySavedEntityPersistedState: vi.fn(), + mockApplySavedEntityDraftState: vi.fn(), })) vi.mock('@tradinggoose/db', () => ({ @@ -32,8 +32,7 @@ vi.mock('nanoid', () => ({ })) vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ - applySavedEntityPersistedState: (...args: unknown[]) => - mockApplySavedEntityPersistedState(...args), + applySavedEntityDraftState: (...args: unknown[]) => mockApplySavedEntityDraftState(...args), })) import { importSkills } from '@/lib/skills/operations' @@ -149,13 +148,13 @@ describe('skills import operations', () => { ]) expect(result.importedCount).toBe(2) expect(result.renamedCount).toBe(1) - expect(mockApplySavedEntityPersistedState).toHaveBeenCalledTimes(2) - expect(mockApplySavedEntityPersistedState).toHaveBeenCalledWith('skill', 'skill-b', { + expect(mockApplySavedEntityDraftState).toHaveBeenCalledTimes(2) + expect(mockApplySavedEntityDraftState).toHaveBeenCalledWith('skill', 'skill-b', { name: 'Execution Plan (imported) 1', description: 'Create the execution plan.', content: 'Follow the checklist.', }) - expect(mockApplySavedEntityPersistedState).toHaveBeenCalledWith('skill', 'skill-a', { + expect(mockApplySavedEntityDraftState).toHaveBeenCalledWith('skill', 'skill-a', { name: 'Market Research', description: 'Research the market.', content: 'Review catalysts.', diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index f8a1308e4..acf6b9cc1 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -1,6 +1,6 @@ import { db } from '@tradinggoose/db' import { skill } from '@tradinggoose/db/schema' -import { and, desc, eq, inArray, ne } from 'drizzle-orm' +import { and, desc, eq, ne } from 'drizzle-orm' import { nanoid } from 'nanoid' import { createLogger } from '@/lib/logs/console/logger' import { @@ -9,8 +9,8 @@ import { type SkillTransferRecord, } from '@/lib/skills/import-export' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityRows } from '@/lib/yjs/entity-state' -import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { syncSavedEntityRowsToYjs } from '@/lib/yjs/entity-state' +import { tryDeleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('SkillsOperations') @@ -45,20 +45,16 @@ export async function deleteSkill(params: { skillId: string workspaceId: string }): Promise { - const existingSkill = await db - .select({ id: skill.id }) - .from(skill) + const deletedSkill = await db + .delete(skill) .where(and(eq(skill.id, params.skillId), eq(skill.workspaceId, params.workspaceId))) - .limit(1) + .returning({ id: skill.id }) - if (existingSkill.length === 0) { + if (deletedSkill.length === 0) { return false } - await deleteYjsSessionInSocketServer(params.skillId) - await db - .delete(skill) - .where(and(eq(skill.id, params.skillId), eq(skill.workspaceId, params.workspaceId))) + await tryDeleteYjsSessionInSocketServer(params.skillId) logger.info(`Deleted skill ${params.skillId}`) return true @@ -72,7 +68,6 @@ export async function upsertSkills({ }: UpsertSkillsParams) { const createdRows: Array = [] const updatedRows: Array = [] - const createdIds: string[] = [] await db.transaction(async (tx) => { for (const currentSkill of skills) { const nowTime = new Date() @@ -105,14 +100,20 @@ export async function upsertSkills({ } } + const [updatedSkill] = await tx + .update(skill) + .set({ + name: currentSkill.name, + description: currentSkill.description, + content: currentSkill.content, + updatedAt: nowTime, + }) + .where(and(eq(skill.id, currentSkill.id), eq(skill.workspaceId, workspaceId))) + .returning() + if (updatedSkill) { + updatedRows.push(updatedSkill) + } logger.info(`[${requestId}] Updated skill ${currentSkill.id}`) - updatedRows.push({ - ...existingSkill[0], - name: currentSkill.name, - description: currentSkill.description, - content: currentSkill.content, - updatedAt: nowTime, - }) continue } } @@ -144,18 +145,10 @@ export async function upsertSkills({ logger.info(`[${requestId}] Created skill "${currentSkill.name}"`) createdRows.push(newSkill) - createdIds.push(skillId) } }) - await applySavedEntityRows('skill', createdRows, { - rollbackRows: async () => { - if (createdIds.length > 0) { - await db.delete(skill).where(inArray(skill.id, createdIds)) - } - }, - }) - await applySavedEntityRows('skill', updatedRows) + await syncSavedEntityRowsToYjs('skill', [...createdRows, ...updatedRows]) return listSkills({ workspaceId }) } @@ -220,18 +213,7 @@ export async function importSkills({ } }) - await applySavedEntityRows('skill', result.skills, { - rollbackRows: async () => { - if (result.skills.length > 0) { - await db.delete(skill).where( - inArray( - skill.id, - result.skills.map((row) => row.id) - ) - ) - } - }, - }) + await syncSavedEntityRowsToYjs('skill', result.skills) return result } diff --git a/apps/tradinggoose/lib/yjs/entity-state.ts b/apps/tradinggoose/lib/yjs/entity-state.ts index 48b570cec..b193bc0a6 100644 --- a/apps/tradinggoose/lib/yjs/entity-state.ts +++ b/apps/tradinggoose/lib/yjs/entity-state.ts @@ -1,5 +1,5 @@ import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' -import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' +import { applySavedEntityDraftState } from '@/lib/yjs/server/apply-entity-state' export type SavedEntityKind = Exclude @@ -63,19 +63,19 @@ export function savedEntityRowToFields( } } -export async function applySavedEntityRows( +export async function syncSavedEntityRowsToYjs( entityKind: SavedEntityKind, - rows: T[], - options?: { rollbackRows?: () => Promise } + rows: T[] ): Promise { - try { - await Promise.all( - rows.map((row) => - applySavedEntityPersistedState(entityKind, row.id, savedEntityRowToFields(entityKind, row)) - ) - ) - } catch (error) { - await options?.rollbackRows?.() - throw error - } + await Promise.all( + rows.map(async (row) => { + try { + await applySavedEntityDraftState( + entityKind, + row.id, + savedEntityRowToFields(entityKind, row) + ) + } catch {} + }) + ) } diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index 4343eb4fb..107e17511 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -98,11 +98,12 @@ export async function applyWorkflowEntityName( workflowId: string, workflowState: WorkflowSnapshot, variables: Record, - entityName: string + entityName: string, + fields: Partial = {} ): Promise { const [updatedWorkflow] = await db .update(workflow) - .set({ name: entityName, updatedAt: new Date() }) + .set({ ...fields, name: entityName, updatedAt: fields.updatedAt ?? new Date() }) .where(eq(workflow.id, workflowId)) .returning() diff --git a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts index e3d7a0475..a27ffc498 100644 --- a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts +++ b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts @@ -142,6 +142,14 @@ export async function deleteYjsSessionInSocketServer(sessionId: string): Promise ) } +export async function tryDeleteYjsSessionInSocketServer(sessionId: string): Promise { + try { + await deleteYjsSessionInSocketServer(sessionId) + } catch { + // Yjs session cleanup must not decide durable DB deletion. + } +} + export async function clearYjsSessionReseededFromCanonicalInSocketServer( sessionId: string ): Promise { From c2d5b9e8737c2d582fe2c59af58df3e4ca44a33e Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 21 Jun 2026 20:36:39 -0600 Subject: [PATCH 085/284] feat(copilot): scope environment variable mutations Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../tradinggoose/app/api/copilot/mcp/route.ts | 2 +- apps/tradinggoose/lib/copilot/registry.ts | 35 +++-- .../lib/copilot/tool-prompt-metadata.ts | 4 +- .../lib/copilot/tools/server/router.test.ts | 28 +++- .../server/user/read-environment-variables.ts | 12 +- .../server/user/set-environment-variables.ts | 147 +++++++++++------- .../tradinggoose/stores/copilot/store.test.ts | 45 +++--- .../stores/copilot/tool-registry.test.ts | 29 +++- .../stores/copilot/tool-registry.ts | 22 ++- 9 files changed, 215 insertions(+), 109 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index edbbc17a9..01e893569 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -97,7 +97,7 @@ async function buildInstructions(userId: string) { return [ 'TradingGoose Copilot MCP exposes the same server-side Copilot tools used by TradingGoose Studio.', 'Local MCP config stores only this user auth token. Do not store workspaceId, entityId, or entity targets in the local MCP config.', - 'Use entityId for read/edit/rename tools that target an existing entity. Use workspaceId for workspace-scoped tools, including list/create and environment, credential, OAuth, Google Drive, and workspace account reads.', + 'Use entityId for read/edit/rename tools that target an existing entity. Use workspaceId for workspace-scoped tools, including list/create, credential, OAuth, Google Drive, workspace account reads, and workspace-scoped environment writes. Environment writes require scope="personal" or scope="workspace".', 'Accessible workspaces for the authenticated user:', ...workspaceLines, ].join('\n') diff --git a/apps/tradinggoose/lib/copilot/registry.ts b/apps/tradinggoose/lib/copilot/registry.ts index 7aa4e0d6e..9c4198a23 100644 --- a/apps/tradinggoose/lib/copilot/registry.ts +++ b/apps/tradinggoose/lib/copilot/registry.ts @@ -126,6 +126,18 @@ const EntityTargetArgs = z.object({ const WorkspaceTargetArgs = z.object({ workspaceId: RequiredId, }) +const SetEnvironmentVariablesArgs = z.discriminatedUnion('scope', [ + z + .object({ + scope: z.literal('personal'), + variables: z.record(z.string()), + }) + .strict(), + WorkspaceTargetArgs.extend({ + scope: z.literal('workspace'), + variables: z.record(z.string()), + }).strict(), +]) function buildEntityDocumentMutationArgs( documentFormat: TDocumentFormat @@ -379,9 +391,7 @@ export const ToolArgSchemas = { [CopilotTool.read_environment_variables]: WorkspaceTargetArgs.strict(), - set_environment_variables: WorkspaceTargetArgs.extend({ - variables: z.record(z.string()), - }).strict(), + set_environment_variables: SetEnvironmentVariablesArgs, [CopilotTool.read_oauth_credentials]: WorkspaceTargetArgs.strict(), @@ -931,7 +941,9 @@ const EditWorkflowVariableResult = WorkflowVariableDocumentEnvelope.extend({ }) const EnvironmentVariablesMutationResult = DocumentDiffReviewMetadata.extend({ - success: z.boolean().optional(), + success: z.boolean(), + scope: z.enum(['personal', 'workspace']), + workspaceId: z.string().optional(), message: z.any().optional(), data: z.any().optional(), variableCount: z.number().optional(), @@ -1014,13 +1026,14 @@ export const ToolResultSchemas = { data: z.any().optional(), body: z.any().optional(), }), - [CopilotTool.read_environment_variables]: z.union([ - z.object({ variableNames: z.array(z.string()), count: z.number() }), - z.object({ variables: z.record(z.string()) }), - ]), - set_environment_variables: z - .object({ variables: z.record(z.string()) }) - .or(EnvironmentVariablesMutationResult), + [CopilotTool.read_environment_variables]: z.object({ + variableNames: z.array(z.string()), + personalVariableNames: z.array(z.string()), + workspaceVariableNames: z.array(z.string()), + conflicts: z.array(z.string()), + count: z.number(), + }), + set_environment_variables: EnvironmentVariablesMutationResult, [CopilotTool.read_oauth_credentials]: z.object({ credentials: z.array( z.object({ id: z.string(), provider: z.string(), isDefault: z.boolean().optional() }) diff --git a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts index 4e02ac217..cd63f5a34 100644 --- a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts +++ b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts @@ -116,12 +116,12 @@ export const TOOL_PROMPT_METADATA: Record = { }, [CopilotTool.read_environment_variables]: { description: - 'Read environment variable names for the selected workspace. Use returned names with the exact `{{ENV_VAR_NAME}}` syntax in block inputs.', + 'Read personal and workspace environment variable names for the selected workspace. Use returned names with the exact `{{ENV_VAR_NAME}}` syntax in block inputs.', kind: 'read', entityKind: 'environment', }, set_environment_variables: { - description: 'Set environment variables in the selected workspace.', + description: 'Set personal or workspace environment variables using an explicit scope.', kind: 'edit', entityKind: 'environment', }, diff --git a/apps/tradinggoose/lib/copilot/tools/server/router.test.ts b/apps/tradinggoose/lib/copilot/tools/server/router.test.ts index aa668a770..b979c4b2e 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/router.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/router.test.ts @@ -36,9 +36,19 @@ const readCredentialsExecute = vi.fn(async () => ({ }, environment: { variableNames: [], count: 0 }, })) -const readEnvironmentVariablesExecute = vi.fn(async () => ({ variableNames: [], count: 0 })) +const readEnvironmentVariablesExecute = vi.fn(async () => ({ + variableNames: [], + personalVariableNames: [], + workspaceVariableNames: [], + conflicts: [], + count: 0, +})) const readOAuthCredentialsExecute = vi.fn(async () => ({ credentials: [], total: 0 })) -const setEnvironmentVariablesExecute = vi.fn(async () => ({ message: 'ok' })) +const setEnvironmentVariablesExecute = vi.fn(async () => ({ + success: true, + scope: 'workspace', + message: 'ok', +})) vi.mock('@/lib/copilot/tools/server/blocks/get-available-blocks', () => ({ getAvailableBlocksServerTool: { @@ -489,13 +499,17 @@ describe('routeExecution', () => { ) await expect( - routeExecution('set_environment_variables', { variables: { API_KEY: 'secret' } }, context) + routeExecution( + 'set_environment_variables', + { scope: 'workspace', variables: { API_KEY: 'secret' } }, + context + ) ).resolves.toMatchObject({ message: 'ok', }) expect(setEnvironmentVariablesExecute).toHaveBeenCalledWith( - { variables: { API_KEY: 'secret' }, workspaceId: 'workspace-1' }, + { scope: 'workspace', variables: { API_KEY: 'secret' }, workspaceId: 'workspace-1' }, context ) }) @@ -508,7 +522,11 @@ describe('routeExecution', () => { }, { toolName: 'set_environment_variables', - payload: { workspaceId: 'workspace-123', variables: { API_KEY: 'secret' } }, + payload: { + scope: 'workspace', + workspaceId: 'workspace-123', + variables: { API_KEY: 'secret' }, + }, execute: setEnvironmentVariablesExecute, }, { diff --git a/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.ts b/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.ts index 4cf791cfb..cdccf9f74 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.ts @@ -49,18 +49,18 @@ export const readEnvironmentVariablesServerTool: BaseServerTool< }) const envResult = await getPersonalAndWorkspaceEnv(userId, workspaceId) - const variableNames = [ - ...new Set([ - ...Object.keys(envResult.personalEncrypted), - ...Object.keys(envResult.workspaceEncrypted), - ]), - ] + const personalVariableNames = Object.keys(envResult.personalEncrypted) + const workspaceVariableNames = Object.keys(envResult.workspaceEncrypted) + const variableNames = [...new Set([...personalVariableNames, ...workspaceVariableNames])] logger.info('Environment variable keys retrieved', { userId, variableCount: variableNames.length, }) return { variableNames, + personalVariableNames, + workspaceVariableNames, + conflicts: envResult.conflicts, count: variableNames.length, } }, diff --git a/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts b/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts index cab96f476..620d6fcc1 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/set-environment-variables.ts @@ -14,46 +14,48 @@ import { import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { encryptSecret } from '@/lib/utils-server' -interface SetEnvironmentVariablesParams { - workspaceId?: string - variables: Record -} - -const EnvVarSchema = z.object({ - workspaceId: z.string().optional(), - variables: z.record(z.string()), -}) - -function hashEnvironmentVariableBase(entries: Array<[string, string | null]>): string { - return hashServerToolReviewBase(entries.sort(([left], [right]) => left.localeCompare(right))) -} - -function normalizeEnvVarInput(input: Record | undefined): Record { - return Object.fromEntries( - Object.entries(input || {}).map(([k, v]) => [k, String(v ?? '')]) - ) as Record -} - -function parseEnvironmentVariablesPayload(payload: unknown): { - workspaceId?: string - variables: Record -} { - const record = - payload && typeof payload === 'object' && !Array.isArray(payload) - ? (payload as SetEnvironmentVariablesParams) - : undefined - const parsed = EnvVarSchema.parse({ - workspaceId: record?.workspaceId, - variables: normalizeEnvVarInput(record?.variables), +const EnvVarSchema = z.discriminatedUnion('scope', [ + z + .object({ + scope: z.literal('personal'), + variables: z.record(z.string()), + }) + .strict(), + z + .object({ + scope: z.literal('workspace'), + workspaceId: z.string().min(1), + variables: z.record(z.string()), + }) + .strict(), +]) +type SetEnvironmentVariablesParams = z.infer + +function hashEnvironmentVariableBase( + scope: 'personal' | 'workspace', + targetId: string, + entries: Array<[string, string | null]> +): string { + return hashServerToolReviewBase({ + scope, + targetId, + entries: entries.sort(([left], [right]) => left.localeCompare(right)), }) - return parsed } -async function readEnvironmentVariableSummary(workspaceId: string, variableNames: string[]) { +async function readEnvironmentVariableSummary( + scope: 'personal' | 'workspace', + targetId: string, + variableNames: string[] +) { const existingRows = await db .select({ key: environmentVariables.key, value: environmentVariables.value }) .from(environmentVariables) - .where(eq(environmentVariables.workspaceId, workspaceId)) + .where( + scope === 'workspace' + ? eq(environmentVariables.workspaceId, targetId) + : eq(environmentVariables.userId, targetId) + ) const existingKeySet = new Set(existingRows.map((row) => row.key)) const existingValueByKey = new Map(existingRows.map((row) => [row.key, row.value])) const added = variableNames.filter((key) => !existingKeySet.has(key)) @@ -63,11 +65,16 @@ async function readEnvironmentVariableSummary(workspaceId: string, variableNames } function buildEnvironmentVariablesResult( + scope: 'personal' | 'workspace', + workspaceId: string | undefined, variableNames: string[], summary: Awaited>, messagePrefix: string ) { return { + success: true, + scope, + workspaceId, message: `${messagePrefix} ${variableNames.length} environment variable(s): ${summary.added.length} added, ${summary.updated.length} updated`, variableCount: variableNames.length, variableNames, @@ -90,7 +97,8 @@ async function encryptEnvironmentVariables( } async function writeEncryptedEnvironmentVariables( - workspaceId: string, + scope: 'personal' | 'workspace', + targetId: string, encryptedVariables: Record, context?: ServerToolExecutionContext ) { @@ -101,12 +109,15 @@ async function writeEncryptedEnvironmentVariables( .insert(environmentVariables) .values({ id: crypto.randomUUID(), - workspaceId, + ...(scope === 'workspace' ? { workspaceId: targetId } : { userId: targetId }), key, value: encrypted, }) .onConflictDoUpdate({ - target: [environmentVariables.workspaceId, environmentVariables.key], + target: + scope === 'workspace' + ? [environmentVariables.workspaceId, environmentVariables.key] + : [environmentVariables.userId, environmentVariables.key], set: { value: encrypted, updatedAt: new Date(), @@ -123,46 +134,74 @@ export const setEnvironmentVariablesServerTool: BaseServerTool { - const parsedPayload = parseEnvironmentVariablesPayload(params) - const scopedContext = withWorkspaceArgContext(context, parsedPayload) + const parsedPayload = EnvVarSchema.parse(params) + const scopedContext = + parsedPayload.scope === 'workspace' + ? withWorkspaceArgContext(context, parsedPayload) + : context if (!scopedContext?.userId) { throw new Error('Authentication required') } const userId = scopedContext.userId - const workspaceId = scopedContext.workspaceId - if (!workspaceId) { - throw new Error('workspaceId is required') - } - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - if (!workspaceAccess.exists || !workspaceAccess.hasAccess || !workspaceAccess.canWrite) { - throw new Error('Access denied: You do not have permission to edit this workspace') + const workspaceId = + parsedPayload.scope === 'workspace' ? scopedContext.workspaceId : undefined + const targetId = workspaceId ?? userId + if (parsedPayload.scope === 'workspace') { + if (!workspaceId) { + throw new Error('workspaceId is required') + } + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.exists || !workspaceAccess.hasAccess || !workspaceAccess.canWrite) { + throw new Error('Access denied: You do not have permission to edit this workspace') + } } - const validatedVariables = parsedPayload.variables - const variableNames = Object.keys(validatedVariables) + const variableNames = Object.keys(parsedPayload.variables).sort() throwIfServerToolAborted(scopedContext) - const summary = await readEnvironmentVariableSummary(workspaceId, variableNames) + const summary = await readEnvironmentVariableSummary( + parsedPayload.scope, + targetId, + variableNames + ) const reviewBaseStateHash = hashEnvironmentVariableBase( + parsedPayload.scope, + targetId, variableNames.map((key) => [key, summary.existingValueByKey.get(key) ?? null]) ) if (shouldStageServerToolMutationForReview(scopedContext)) { return { requiresReview: true, - success: true, - ...buildEnvironmentVariablesResult(variableNames, summary, 'Review required for'), + ...buildEnvironmentVariablesResult( + parsedPayload.scope, + workspaceId, + variableNames, + summary, + 'Review required for' + ), reviewBaseStateHash, } } assertAcceptedServerToolReviewBase(scopedContext, reviewBaseStateHash) const encryptedVariables = await encryptEnvironmentVariables( - validatedVariables, + parsedPayload.variables, scopedContext ) - await writeEncryptedEnvironmentVariables(workspaceId, encryptedVariables, scopedContext) - return buildEnvironmentVariablesResult(variableNames, summary, 'Successfully processed') + await writeEncryptedEnvironmentVariables( + parsedPayload.scope, + targetId, + encryptedVariables, + scopedContext + ) + return buildEnvironmentVariablesResult( + parsedPayload.scope, + workspaceId, + variableNames, + summary, + 'Successfully processed' + ) }, } diff --git a/apps/tradinggoose/stores/copilot/store.test.ts b/apps/tradinggoose/stores/copilot/store.test.ts index 17d138383..18eeecfdf 100644 --- a/apps/tradinggoose/stores/copilot/store.test.ts +++ b/apps/tradinggoose/stores/copilot/store.test.ts @@ -2,12 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' import { registerClientTool, unregisterClientTool } from '@/lib/copilot/tools/client/manager' import { encodeSSE } from '@/lib/utils' +import { getQueryClient } from '@/app/query-provider' +import { environmentKeys } from '@/hooks/queries/environment' import { getCopilotStore } from '@/stores/copilot/store' import { getCopilotStoreForToolCall } from '@/stores/copilot/store-access' import { createExecutionContext } from '@/stores/copilot/tool-registry' import type { ChatContext, CopilotSendRuntimeContext } from '@/stores/copilot/types' import { resetCopilotWorkspaceSelectionState } from '@/stores/copilot/workspace-selection' -import { useEnvironmentStore } from '@/stores/settings/environment/store' type FetchCall = readonly [input: RequestInfo | URL, init?: RequestInit] @@ -3127,8 +3128,9 @@ describe('copilot tool user action delegation', () => { const channelId = 'copilot-env-refresh' const toolCallId = 'set-env-tool' const store = getCopilotStore(channelId) - const originalLoadEnvironmentVariables = useEnvironmentStore.getState().loadEnvironmentVariables - const loadEnvironmentVariables = vi.fn(async () => {}) + const invalidateQueries = vi + .spyOn(getQueryClient(), 'invalidateQueries') + .mockResolvedValue(undefined) const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = typeof input === 'string' ? input : input.toString() if (url === '/api/copilot/execute-copilot-server-tool') { @@ -3137,7 +3139,7 @@ describe('copilot tool user action delegation', () => { status: 200, json: async () => ({ success: true, - result: { message: 'ok' }, + result: { success: true, scope: 'personal', message: 'ok' }, }), } } @@ -3154,30 +3156,23 @@ describe('copilot tool user action delegation', () => { }) vi.stubGlobal('fetch', fetchMock) - useEnvironmentStore.setState({ loadEnvironmentVariables } as any) - try { - store.setState({ - accessLevel: 'full', - toolCallsById: { - [toolCallId]: { - id: toolCallId, - name: 'set_environment_variables', - state: ClientToolCallState.pending, - params: { variables: { API_KEY: 'secret' } }, - } as any, - }, - }) + store.setState({ + accessLevel: 'full', + toolCallsById: { + [toolCallId]: { + id: toolCallId, + name: 'set_environment_variables', + state: ClientToolCallState.pending, + params: { scope: 'personal', variables: { API_KEY: 'secret' } }, + } as any, + }, + }) - await store.getState().executeCopilotToolCall(toolCallId) + await store.getState().executeCopilotToolCall(toolCallId) - expect(loadEnvironmentVariables).toHaveBeenCalledTimes(1) - expect(store.getState().toolCallsById[toolCallId]?.state).toBe(ClientToolCallState.success) - } finally { - useEnvironmentStore.setState({ - loadEnvironmentVariables: originalLoadEnvironmentVariables, - } as any) - } + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: environmentKeys.personal() }) + expect(store.getState().toolCallsById[toolCallId]?.state).toBe(ClientToolCallState.success) }) it('persists completed server-managed tool states into assistant message blocks', async () => { diff --git a/apps/tradinggoose/stores/copilot/tool-registry.test.ts b/apps/tradinggoose/stores/copilot/tool-registry.test.ts index 5131a84ef..e351294b6 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.test.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { getQueryClient } from '@/app/query-provider' +import { environmentKeys } from '@/hooks/queries/environment' import { skillsKeys } from '@/hooks/queries/skills' import { workflowKeys } from '@/hooks/queries/workflows' import { @@ -87,11 +88,20 @@ describe('tool-registry', () => { provenance: { workspaceId: 'workspace-1' }, }) - const args = { variables: { API_KEY: 'secret' } } + const args = { scope: 'workspace', variables: { API_KEY: 'secret' } } expect(prepareCopilotToolArgs('set_environment_variables', args, context)).toEqual({ + scope: 'workspace', workspaceId: 'workspace-1', variables: { API_KEY: 'secret' }, }) + + expect(() => + prepareCopilotToolArgs( + 'set_environment_variables', + { variables: { API_KEY: 'secret' } }, + context + ) + ).toThrow() }) it('injects hosted workspace context into workspace-targeted knowledge base tools', () => { @@ -222,4 +232,21 @@ describe('tool-registry', () => { queryKey: skillsKeys.list('workspace-1'), }) }) + + it('invalidates the matching environment query after server-managed environment mutations', async () => { + const invalidateQueries = vi + .spyOn(getQueryClient(), 'invalidateQueries') + .mockResolvedValue(undefined) + + await handleCopilotServerToolSuccess('set_environment_variables', { + success: true, + scope: 'workspace', + workspaceId: 'workspace-1', + }) + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: environmentKeys.workspace('workspace-1'), + }) + expect(invalidateQueries).toHaveBeenCalledTimes(1) + }) }) diff --git a/apps/tradinggoose/stores/copilot/tool-registry.ts b/apps/tradinggoose/stores/copilot/tool-registry.ts index b0826996e..241e89419 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.ts @@ -19,13 +19,13 @@ import { RunWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/run-w import { createLogger } from '@/lib/logs/console/logger' import { getQueryClient } from '@/app/query-provider' import { customToolsKeys } from '@/hooks/queries/custom-tools' +import { environmentKeys } from '@/hooks/queries/environment' import { indicatorKeys } from '@/hooks/queries/indicators' import { knowledgeKeys } from '@/hooks/queries/knowledge' import { skillsKeys } from '@/hooks/queries/skills' import { workflowKeys } from '@/hooks/queries/workflows' import type { CopilotToolExecutionProvenance } from '@/stores/copilot/types' import { useMcpServersStore } from '@/stores/mcp-servers/store' -import { useEnvironmentStore } from '@/stores/settings/environment/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('CopilotToolRegistry') @@ -144,7 +144,6 @@ const WORKSPACE_TARGETED_TOOL_NAMES = new Set([ CopilotTool.list_workflows, CopilotTool.get_agent_accessory_catalog, CopilotTool.read_environment_variables, - CopilotTool.set_environment_variables, CopilotTool.read_credentials, CopilotTool.read_oauth_credentials, CopilotTool.list_gdrive_files, @@ -276,6 +275,13 @@ export function prepareCopilotToolArgs( context.workspaceId ) { clonedArgs.workspaceId = context.workspaceId + } else if ( + toolName === CopilotTool.set_environment_variables && + clonedArgs.scope === 'workspace' && + !clonedArgs.workspaceId && + context.workspaceId + ) { + clonedArgs.workspaceId = context.workspaceId } return ToolArgSchemas[toolName].parse(clonedArgs) as Record @@ -303,8 +309,17 @@ export async function handleCopilotServerToolSuccess( const workspaceId = readResultWorkspaceId(result, context) try { + const queryClient = getQueryClient() if (toolName === CopilotTool.set_environment_variables) { - await useEnvironmentStore.getState().loadEnvironmentVariables() + const scope = + result && typeof result === 'object' && !Array.isArray(result) + ? (result as { scope?: unknown }).scope + : undefined + if (scope === 'workspace' && workspaceId) { + await queryClient.invalidateQueries({ queryKey: environmentKeys.workspace(workspaceId) }) + } else if (scope === 'personal') { + await queryClient.invalidateQueries({ queryKey: environmentKeys.personal() }) + } return } @@ -312,7 +327,6 @@ export async function handleCopilotServerToolSuccess( return } - const queryClient = getQueryClient() if (toolName === CopilotTool.create_workflow || toolName === CopilotTool.rename_workflow) { await Promise.all([ useWorkflowRegistry.getState().loadWorkflows({ workspaceId }), From 127087c9d28b4c7529448c560f751ac7b452f77b Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 23 Jun 2026 11:31:40 -0600 Subject: [PATCH 086/284] fix(copilot): align contracts after staging rebase --- .../app/api/tools/custom/import/route.test.ts | 7 ------- .../lib/indicators/custom/operations.ts | 1 - .../lib/indicators/default/runtime.ts | 16 ++++++++++++++++ .../custom-tool-editor.test.tsx | 6 +----- .../editor_custom_tool/custom-tool-editor.tsx | 2 +- .../widgets/widgets/editor_custom_tool/index.tsx | 7 +++---- 6 files changed, 21 insertions(+), 18 deletions(-) diff --git a/apps/tradinggoose/app/api/tools/custom/import/route.test.ts b/apps/tradinggoose/app/api/tools/custom/import/route.test.ts index e2cc94b8a..e2845fc31 100644 --- a/apps/tradinggoose/app/api/tools/custom/import/route.test.ts +++ b/apps/tradinggoose/app/api/tools/custom/import/route.test.ts @@ -47,7 +47,6 @@ describe('Custom tools import route', () => { schema: { type: 'function', function: { - name: 'myTool_imported_1', parameters: { type: 'object', properties: {}, @@ -81,7 +80,6 @@ describe('Custom tools import route', () => { schema: { type: 'function', function: { - name: 'myTool', parameters: { type: 'object', properties: {}, @@ -112,7 +110,6 @@ describe('Custom tools import route', () => { schema: { type: 'function', function: { - name: 'myTool', parameters: { type: 'object', properties: {}, @@ -143,7 +140,6 @@ describe('Custom tools import route', () => { schema: { type: 'function', function: { - name: 'myTool', parameters: { type: 'object', properties: {}, @@ -184,7 +180,6 @@ describe('Custom tools import route', () => { schema: { type: 'function', function: { - name: 'myTool', parameters: { type: 'object', properties: {}, @@ -229,7 +224,6 @@ describe('Custom tools import route', () => { schema: { type: 'function', function: { - name: 'myTool', parameters: { type: 'object', properties: {}, @@ -281,7 +275,6 @@ describe('Custom tools import route', () => { schema: { type: 'function', function: { - name: 'myTool', parameters: { type: 'object', properties: {}, diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index c7ad52d8b..659360105 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -18,7 +18,6 @@ export async function listCustomIndicatorRuntimeEntries(workspaceId: string) { .select() .from(pineIndicators) .where(eq(pineIndicators.workspaceId, workspaceId)) - .then((indicatorRows) => applySavedEntityYjsStateToRows('indicator', indicatorRows)) return rows.map(({ id, pineCode, inputMeta }) => ({ id, diff --git a/apps/tradinggoose/lib/indicators/default/runtime.ts b/apps/tradinggoose/lib/indicators/default/runtime.ts index 19177033f..b068b6db4 100644 --- a/apps/tradinggoose/lib/indicators/default/runtime.ts +++ b/apps/tradinggoose/lib/indicators/default/runtime.ts @@ -17,6 +17,22 @@ export const DEFAULT_INDICATOR_RUNTIME_ENTRIES: DefaultIndicatorRuntimeEntry[] = inputMeta: normalizeInputMetaMap(indicator.inputMeta), })) +export const DEFAULT_INDICATOR_RUNTIME_IDS = DEFAULT_INDICATOR_RUNTIME_ENTRIES.map( + (entry) => entry.id +) + export const DEFAULT_INDICATOR_RUNTIME_MAP = new Map( DEFAULT_INDICATOR_RUNTIME_ENTRIES.map((entry) => [entry.id, entry] as const) ) + +export const DEFAULT_INDICATOR_RUNTIME_MANIFEST = { + indicators: DEFAULT_INDICATOR_RUNTIME_ENTRIES, +} + +export const resolveDefaultIndicatorRuntimeEntry = ( + alias: string +): DefaultIndicatorRuntimeEntry | null => { + const normalizedAlias = alias.trim() + if (!normalizedAlias) return null + return DEFAULT_INDICATOR_RUNTIME_MAP.get(normalizedAlias) ?? null +} diff --git a/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.test.tsx b/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.test.tsx index 1424203c8..99a68526b 100644 --- a/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.test.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.test.tsx @@ -100,11 +100,7 @@ const readBlobText = async (blob: Blob) => reader.readAsText(blob) }) -const createCustomToolDoc = (initialValues: { - title: string - schema: unknown - code: string -}) => { +const createCustomToolDoc = (initialValues: { title: string; schema: unknown; code: string }) => { const doc = new Y.Doc() seedEntitySession(doc, { entityKind: 'custom_tool', diff --git a/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.tsx b/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.tsx index a707b1e96..abe85c2e1 100644 --- a/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.tsx @@ -14,9 +14,9 @@ import { CustomToolOpenAiSchema } from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' import { useYjsStringField } from '@/lib/yjs/use-entity-fields' -import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' import { useUpdateCustomTool } from '@/hooks/queries/custom-tools' import { useWand } from '@/hooks/workflow/use-wand' +import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' import { WandPromptBar } from '@/widgets/widgets/editor_workflow/components/wand-prompt-bar/wand-prompt-bar' import { CodeEditor } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/components/code-editor/code-editor' import { useWorkspaceId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' diff --git a/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx b/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx index 0051066ff..6aad1c930 100644 --- a/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx @@ -2,15 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Download, Save, SquareTerminal } from 'lucide-react' -import { useLocale } from 'next-intl' +import { useLocale, useMessages } from 'next-intl' import { Button } from '@/components/ui/button' import { LoadingAgent } from '@/components/ui/loading-agent' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' -import { useMessages } from 'next-intl' -import type { LocaleCode } from '@/i18n/utils' -import { useCustomTools } from '@/hooks/queries/custom-tools' import { useSavedEntityYjsSession } from '@/lib/yjs/use-entity-fields' +import { useCustomTools } from '@/hooks/queries/custom-tools' +import type { LocaleCode } from '@/i18n/utils' import type { CustomToolDefinition } from '@/stores/custom-tools/types' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import { DEFAULT_WORKFLOW_CHANNEL_ID } from '@/stores/workflows/workflow/store-client' From 705863cbeb3d42a2e9af3fa1ac7792ec4bebcec2 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 23 Jun 2026 11:41:44 -0600 Subject: [PATCH 087/284] fix(copilot): align indicator runtime contracts --- .../tools/server/entities/indicator.test.ts | 131 ++++++++++++++++++ .../tools/server/entities/indicator.ts | 32 +++-- .../lib/indicators/default/runtime.ts | 16 --- 3 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts new file mode 100644 index 000000000..fb7f37ef5 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { INDICATOR_DOCUMENT_FORMAT } from '@/lib/copilot/entity-documents' +import { DEFAULT_INDICATOR_RUNTIME_ENTRIES } from '@/lib/indicators/default/runtime' +import { listIndicatorsServerTool, readIndicatorServerTool } from './indicator' + +const mockCheckWorkspaceAccess = vi.hoisted(() => vi.fn()) +const mockDbOrderBy = vi.hoisted(() => vi.fn()) +const mockReadBootstrappedSavedEntityFields = vi.hoisted(() => vi.fn()) +const mockVerifyReviewTargetAccess = vi.hoisted(() => vi.fn()) + +vi.mock('@tradinggoose/db', () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: mockDbOrderBy, + })), + })), + })), + }, +})) + +vi.mock('@/lib/permissions/utils', () => ({ + checkWorkspaceAccess: (...args: unknown[]) => mockCheckWorkspaceAccess(...args), +})) + +vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ + verifyReviewTargetAccess: (...args: unknown[]) => mockVerifyReviewTargetAccess(...args), +})) + +vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ + readBootstrappedSavedEntityFields: (...args: unknown[]) => + mockReadBootstrappedSavedEntityFields(...args), +})) + +describe('indicator server tools', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + }) + mockDbOrderBy.mockResolvedValue([ + { + id: 'indicator-custom-1', + name: 'Custom Momentum', + pineCode: 'indicator("Custom Momentum")', + inputMeta: { Length: { defaultValue: 14 } }, + workspaceId: 'workspace-1', + userId: 'user-1', + color: '#10b981', + createdAt: new Date('2026-06-23T00:00:00.000Z'), + updatedAt: new Date('2026-06-23T00:00:00.000Z'), + }, + ]) + mockVerifyReviewTargetAccess.mockResolvedValue({ + hasAccess: true, + workspaceId: 'workspace-1', + }) + mockReadBootstrappedSavedEntityFields.mockResolvedValue({ + name: 'Custom Momentum', + pineCode: 'indicator("Custom Momentum")', + inputMeta: { Length: { defaultValue: 14 } }, + }) + }) + + it('lists custom indicators with callable runtime ids', async () => { + const result = await listIndicatorsServerTool.execute( + {}, + { userId: 'user-1', workspaceId: 'workspace-1', accessLevel: 'full' } + ) + + expect(result.indicators).toContainEqual( + expect.objectContaining({ + name: 'Custom Momentum', + source: 'custom', + editable: true, + callableInFunctionBlock: true, + entityId: 'indicator-custom-1', + runtimeId: 'indicator-custom-1', + inputTitles: ['Length'], + }) + ) + }) + + it('reads default indicators through the staging runtime map', async () => { + const defaultIndicator = DEFAULT_INDICATOR_RUNTIME_ENTRIES[0] + + const result = await readIndicatorServerTool.execute( + { runtimeId: defaultIndicator.id }, + { userId: 'user-1', accessLevel: 'full' } + ) + + expect(result).toMatchObject({ + entityKind: 'indicator', + entityName: defaultIndicator.name, + documentFormat: INDICATOR_DOCUMENT_FORMAT, + }) + expect(result).not.toHaveProperty('entityId') + expect(mockVerifyReviewTargetAccess).not.toHaveBeenCalled() + }) + + it('reads custom indicators by the same runtime id used for Function execution', async () => { + const result = await readIndicatorServerTool.execute( + { runtimeId: 'indicator-custom-1' }, + { userId: 'user-1', accessLevel: 'full' } + ) + + expect(mockVerifyReviewTargetAccess).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ + entityKind: 'indicator', + entityId: 'indicator-custom-1', + yjsSessionId: 'indicator-custom-1', + }), + 'read' + ) + expect(mockReadBootstrappedSavedEntityFields).toHaveBeenCalledWith( + 'indicator', + 'indicator-custom-1', + 'workspace-1' + ) + expect(result).toMatchObject({ + entityKind: 'indicator', + entityId: 'indicator-custom-1', + entityName: 'Custom Momentum', + documentFormat: INDICATOR_DOCUMENT_FORMAT, + }) + }) +}) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts index 6a391f660..0b3e80940 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts @@ -6,7 +6,7 @@ import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' import { upsertIndicators } from '@/lib/indicators/custom/operations' import { DEFAULT_INDICATOR_RUNTIME_ENTRIES, - resolveDefaultIndicatorRuntimeEntry, + DEFAULT_INDICATOR_RUNTIME_MAP, } from '@/lib/indicators/default/runtime' import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' @@ -47,9 +47,10 @@ function toCustomIndicatorListEntry( name: row.name, source: 'custom', editable: true, - callableInFunctionBlock: false, + callableInFunctionBlock: true, ...(inputTitles.length > 0 ? { inputTitles } : {}), entityId: row.id, + runtimeId: row.id, } } @@ -122,16 +123,27 @@ export const readIndicatorServerTool: EntityServerTool = { const runtimeId = args.runtimeId?.trim() if (runtimeId) { requireUserId(context) - const indicator = resolveDefaultIndicatorRuntimeEntry(runtimeId) - if (!indicator) { - throw new Error(`Built-in indicator ${runtimeId} was not found`) + const defaultIndicator = DEFAULT_INDICATOR_RUNTIME_MAP.get(runtimeId) + if (defaultIndicator) { + return buildDocumentEnvelope(ENTITY_KIND_INDICATOR, undefined, { + name: defaultIndicator.name, + pineCode: defaultIndicator.pineCode, + inputMeta: defaultIndicator.inputMeta ?? null, + }) } - return buildDocumentEnvelope(ENTITY_KIND_INDICATOR, undefined, { - name: indicator.name, - pineCode: indicator.pineCode, - inputMeta: indicator.inputMeta ?? null, - }) + const { workspaceId } = await verifySavedEntityContext( + context, + ENTITY_KIND_INDICATOR, + runtimeId, + 'read' + ) + const fields = await readSavedEntityDocumentFields( + ENTITY_KIND_INDICATOR, + runtimeId, + workspaceId + ) + return buildDocumentEnvelope(ENTITY_KIND_INDICATOR, runtimeId, fields) } const entityId = requireEntityId(args, 'read_indicator') diff --git a/apps/tradinggoose/lib/indicators/default/runtime.ts b/apps/tradinggoose/lib/indicators/default/runtime.ts index b068b6db4..19177033f 100644 --- a/apps/tradinggoose/lib/indicators/default/runtime.ts +++ b/apps/tradinggoose/lib/indicators/default/runtime.ts @@ -17,22 +17,6 @@ export const DEFAULT_INDICATOR_RUNTIME_ENTRIES: DefaultIndicatorRuntimeEntry[] = inputMeta: normalizeInputMetaMap(indicator.inputMeta), })) -export const DEFAULT_INDICATOR_RUNTIME_IDS = DEFAULT_INDICATOR_RUNTIME_ENTRIES.map( - (entry) => entry.id -) - export const DEFAULT_INDICATOR_RUNTIME_MAP = new Map( DEFAULT_INDICATOR_RUNTIME_ENTRIES.map((entry) => [entry.id, entry] as const) ) - -export const DEFAULT_INDICATOR_RUNTIME_MANIFEST = { - indicators: DEFAULT_INDICATOR_RUNTIME_ENTRIES, -} - -export const resolveDefaultIndicatorRuntimeEntry = ( - alias: string -): DefaultIndicatorRuntimeEntry | null => { - const normalizedAlias = alias.trim() - if (!normalizedAlias) return null - return DEFAULT_INDICATOR_RUNTIME_MAP.get(normalizedAlias) ?? null -} From f73e357b6f4061ac9407fcbc0189931694eaf7be Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 23 Jun 2026 14:26:02 -0600 Subject: [PATCH 088/284] feat(yjs): persist saved entity session edits Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../sessions/[sessionId]/snapshot/route.ts | 108 +++++++++++++----- .../hooks/queries/custom-tools.ts | 53 +-------- apps/tradinggoose/hooks/queries/indicators.ts | 31 ----- apps/tradinggoose/hooks/queries/skills.ts | 53 +-------- .../lib/copilot/entity-documents.ts | 56 +++++---- apps/tradinggoose/lib/yjs/client.ts | 9 -- .../lib/yjs/server/apply-entity-state.ts | 43 +++++-- .../tradinggoose/lib/yjs/use-entity-fields.ts | 45 +++++++- .../tradinggoose/socket-server/routes/http.ts | 10 +- .../socket-server/yjs/upstream-utils.ts | 20 ++-- .../widgets/widgets/_shared/mcp/utils.ts | 15 --- .../custom-tool-editor.test.tsx | 16 +-- .../editor_custom_tool/custom-tool-editor.tsx | 25 ++-- .../widgets/editor_custom_tool/index.tsx | 1 + .../components/pine-indicator-code-panel.tsx | 30 +++-- .../editor-indicator-body.tsx | 9 +- .../widgets/editor_mcp/editor-mcp-body.tsx | 32 ++---- .../editor_skill/editor-skill-body.tsx | 10 +- .../editor_skill/skill-editor.test.tsx | 29 +---- .../widgets/editor_skill/skill-editor.tsx | 42 ++----- 20 files changed, 279 insertions(+), 358 deletions(-) diff --git a/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts b/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts index 9273c4d0d..de8758b4d 100644 --- a/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts +++ b/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts @@ -5,51 +5,49 @@ import { parseYjsTransportEnvelope, } from '@/lib/copilot/review-sessions/identity' import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' +import { mcpService } from '@/lib/mcp/service' +import type { SavedEntityKind } from '@/lib/yjs/entity-state' +import { + applySavedEntityPersistedState, + SavedEntityPersistenceError, +} from '@/lib/yjs/server/apply-entity-state' import { - readBootstrappedReviewTargetSnapshot, ReviewTargetBootstrapError, + readBootstrappedReviewTargetSnapshot, + readBootstrappedSavedEntityFields, } from '@/lib/yjs/server/bootstrap-review-target' export const dynamic = 'force-dynamic' -export async function GET( +async function authorizeYjsSnapshotRequest( request: NextRequest, - { params }: { params: Promise<{ sessionId: string }> } + sessionId: string, + accessMode: 'read' | 'write' ) { const session = await getSession() if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - const { sessionId } = await params - - const queryParams: Record = {} - request.nextUrl.searchParams.forEach((value, key) => { - queryParams[key] = value - }) - const accessMode = request.nextUrl.searchParams.get('accessMode') - if (accessMode !== 'read' && accessMode !== 'write') { - return NextResponse.json({ error: 'Invalid access mode' }, { status: 400 }) + return { response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } } let descriptor try { - const envelope = parseYjsTransportEnvelope(queryParams) + const envelope = parseYjsTransportEnvelope(Object.fromEntries(request.nextUrl.searchParams)) descriptor = buildReviewTargetDescriptorFromEnvelope(envelope) } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Invalid transport envelope' }, - { status: 400 } - ) + return { + response: NextResponse.json( + { error: error instanceof Error ? error.message : 'Invalid transport envelope' }, + { status: 400 } + ), + } } if (descriptor.yjsSessionId !== sessionId) { - return NextResponse.json({ error: 'Session ID mismatch' }, { status: 409 }) + return { response: NextResponse.json({ error: 'Session ID mismatch' }, { status: 409 }) } } const access = await verifyReviewTargetAccess( - userId, + session.user.id, { entityKind: descriptor.entityKind, entityId: descriptor.entityId, @@ -62,16 +60,32 @@ export async function GET( ) if (!access.hasAccess) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + return { response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } } - const authorizedDescriptor = { - ...descriptor, - workspaceId: access.workspaceId ?? descriptor.workspaceId, + return { + descriptor: { + ...descriptor, + workspaceId: access.workspaceId ?? descriptor.workspaceId, + }, } +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ sessionId: string }> } +) { + const { sessionId } = await params + const accessMode = request.nextUrl.searchParams.get('accessMode') + if (accessMode !== 'read' && accessMode !== 'write') { + return NextResponse.json({ error: 'Invalid access mode' }, { status: 400 }) + } + + const authorized = await authorizeYjsSnapshotRequest(request, sessionId, accessMode) + if ('response' in authorized) return authorized.response try { - const snapshot = await readBootstrappedReviewTargetSnapshot(authorizedDescriptor) + const snapshot = await readBootstrappedReviewTargetSnapshot(authorized.descriptor) return NextResponse.json(snapshot, { status: snapshot.runtime.docState === 'expired' ? 410 : 200, }) @@ -83,3 +97,41 @@ export async function GET( return NextResponse.json({ error: 'Failed to load snapshot' }, { status: 500 }) } } + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ sessionId: string }> } +) { + const { sessionId } = await params + const authorized = await authorizeYjsSnapshotRequest(request, sessionId, 'write') + if ('response' in authorized) return authorized.response + + const { descriptor } = authorized + if (descriptor.entityKind === 'workflow' || !descriptor.entityId || !descriptor.workspaceId) { + return NextResponse.json({ error: 'Saved entity Yjs session required' }, { status: 400 }) + } + const entityKind: SavedEntityKind = descriptor.entityKind + + try { + const fields = await readBootstrappedSavedEntityFields( + entityKind, + descriptor.entityId, + descriptor.workspaceId + ) + await applySavedEntityPersistedState(entityKind, descriptor.entityId, fields) + if (descriptor.entityKind === 'mcp_server') { + mcpService.clearCache(descriptor.workspaceId) + } + + return NextResponse.json({ success: true }) + } catch (error) { + if ( + error instanceof SavedEntityPersistenceError || + error instanceof ReviewTargetBootstrapError + ) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } + + return NextResponse.json({ error: 'Failed to save Yjs session' }, { status: 500 }) + } +} diff --git a/apps/tradinggoose/hooks/queries/custom-tools.ts b/apps/tradinggoose/hooks/queries/custom-tools.ts index dbc729eab..feaf07a7d 100644 --- a/apps/tradinggoose/hooks/queries/custom-tools.ts +++ b/apps/tradinggoose/hooks/queries/custom-tools.ts @@ -320,36 +320,6 @@ export function useUpdateCustomTool() { logger.info(`Updated custom tool: ${toolId}`) return data.data }, - onMutate: async ({ workspaceId, toolId, updates }) => { - await queryClient.cancelQueries({ queryKey: customToolsKeys.list(workspaceId) }) - - const previousTools = queryClient.getQueryData( - customToolsKeys.list(workspaceId) - ) - - if (previousTools) { - queryClient.setQueryData( - customToolsKeys.list(workspaceId), - previousTools.map((tool) => - tool.id === toolId - ? { - ...tool, - title: updates.title ?? tool.title, - schema: updates.schema ?? tool.schema, - code: updates.code ?? tool.code, - } - : tool - ) - ) - } - - return { previousTools } - }, - onError: (_err, variables, context) => { - if (context?.previousTools) { - queryClient.setQueryData(customToolsKeys.list(variables.workspaceId), context.previousTools) - } - }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) }) }, @@ -386,28 +356,7 @@ export function useDeleteCustomTool() { logger.info(`Deleted custom tool: ${toolId}`) return data }, - onMutate: async ({ workspaceId, toolId }) => { - await queryClient.cancelQueries({ queryKey: customToolsKeys.list(workspaceId) }) - - const previousTools = queryClient.getQueryData( - customToolsKeys.list(workspaceId) - ) - - if (previousTools) { - queryClient.setQueryData( - customToolsKeys.list(workspaceId), - previousTools.filter((tool) => tool.id !== toolId) - ) - } - - return { previousTools, workspaceId } - }, - onError: (_err, _variables, context) => { - if (context?.previousTools && context?.workspaceId) { - queryClient.setQueryData(customToolsKeys.list(context.workspaceId), context.previousTools) - } - }, - onSettled: (_data, _error, variables) => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) }) }, }) diff --git a/apps/tradinggoose/hooks/queries/indicators.ts b/apps/tradinggoose/hooks/queries/indicators.ts index 6120786c1..bd4515979 100644 --- a/apps/tradinggoose/hooks/queries/indicators.ts +++ b/apps/tradinggoose/hooks/queries/indicators.ts @@ -242,37 +242,6 @@ export function useUpdateIndicator() { logger.info(`Updated indicator: ${indicatorId}`) return data.data }, - onMutate: async ({ workspaceId, indicatorId, updates }) => { - await queryClient.cancelQueries({ queryKey: indicatorKeys.list(workspaceId) }) - - const previousIndicators = queryClient.getQueryData( - indicatorKeys.list(workspaceId) - ) - - if (previousIndicators) { - queryClient.setQueryData( - indicatorKeys.list(workspaceId), - previousIndicators.map((indicator) => - indicator.id === indicatorId - ? { - ...indicator, - ...updates, - } - : indicator - ) - ) - } - - return { previousIndicators } - }, - onError: (_err, variables, context) => { - if (context?.previousIndicators) { - queryClient.setQueryData( - indicatorKeys.list(variables.workspaceId), - context.previousIndicators - ) - } - }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: indicatorKeys.list(variables.workspaceId) }) }, diff --git a/apps/tradinggoose/hooks/queries/skills.ts b/apps/tradinggoose/hooks/queries/skills.ts index 551394f0c..0848c3dc2 100644 --- a/apps/tradinggoose/hooks/queries/skills.ts +++ b/apps/tradinggoose/hooks/queries/skills.ts @@ -274,36 +274,6 @@ export function useUpdateSkill() { return data.data }, - onMutate: async ({ workspaceId, skillId, updates }) => { - await queryClient.cancelQueries({ queryKey: skillsKeys.list(workspaceId) }) - - const previousSkills = queryClient.getQueryData( - skillsKeys.list(workspaceId) - ) - - if (previousSkills) { - queryClient.setQueryData( - skillsKeys.list(workspaceId), - previousSkills.map((skill) => - skill.id === skillId - ? { - ...skill, - name: updates.name ?? skill.name, - description: updates.description ?? skill.description, - content: updates.content ?? skill.content, - } - : skill - ) - ) - } - - return { previousSkills } - }, - onError: (_err, variables, context) => { - if (context?.previousSkills) { - queryClient.setQueryData(skillsKeys.list(variables.workspaceId), context.previousSkills) - } - }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) }) }, @@ -335,28 +305,7 @@ export function useDeleteSkill() { return data }, - onMutate: async ({ workspaceId, skillId }) => { - await queryClient.cancelQueries({ queryKey: skillsKeys.list(workspaceId) }) - - const previousSkills = queryClient.getQueryData( - skillsKeys.list(workspaceId) - ) - - if (previousSkills) { - queryClient.setQueryData( - skillsKeys.list(workspaceId), - previousSkills.filter((skill) => skill.id !== skillId) - ) - } - - return { previousSkills, workspaceId } - }, - onError: (_err, _variables, context) => { - if (context?.previousSkills && context?.workspaceId) { - queryClient.setQueryData(skillsKeys.list(context.workspaceId), context.previousSkills) - } - }, - onSettled: (_data, _error, variables) => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) }) }, }) diff --git a/apps/tradinggoose/lib/copilot/entity-documents.ts b/apps/tradinggoose/lib/copilot/entity-documents.ts index 7d47e7ed2..2b8f97d63 100644 --- a/apps/tradinggoose/lib/copilot/entity-documents.ts +++ b/apps/tradinggoose/lib/copilot/entity-documents.ts @@ -1,4 +1,7 @@ import { z } from 'zod' +import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' +import { inferInputMetaFromPineCode } from '@/lib/indicators/input-meta' +import { validateMcpServerUrl } from '@/lib/mcp/url-validator' export const SKILL_DOCUMENT_FORMAT = 'tg-skill-document-v1' as const export const CUSTOM_TOOL_DOCUMENT_FORMAT = 'tg-custom-tool-document-v1' as const export const INDICATOR_DOCUMENT_FORMAT = 'tg-indicator-document-v1' as const @@ -94,7 +97,7 @@ function redactStringRecordValues(value: unknown): Record { ) } -function normalizeEntityFields( +export function normalizeEntityFields( kind: EntityDocumentKind, fields: Record | null | undefined ): Record { @@ -103,38 +106,43 @@ function normalizeEntityFields( switch (kind) { case 'skill': return { - name: typeof source.name === 'string' ? source.name : '', - description: typeof source.description === 'string' ? source.description : '', - content: typeof source.content === 'string' ? source.content : '', + name: typeof source.name === 'string' ? source.name.trim() : '', + description: typeof source.description === 'string' ? source.description.trim() : '', + content: typeof source.content === 'string' ? source.content.trim() : '', } - case 'custom_tool': + case 'custom_tool': { + const schemaText = typeof source.schemaText === 'string' ? source.schemaText : '' return { - title: typeof source.title === 'string' ? source.title : '', - schemaText: typeof source.schemaText === 'string' ? source.schemaText : '', + title: typeof source.title === 'string' ? source.title.trim().replace(/\s+/g, ' ') : '', + schemaText: JSON.stringify(parseCustomToolSchemaText(schemaText), null, 2), codeText: typeof source.codeText === 'string' ? source.codeText : '', } - case 'indicator': + } + case 'indicator': { + const pineCode = typeof source.pineCode === 'string' ? source.pineCode : '' return { - name: typeof source.name === 'string' ? source.name : '', - pineCode: typeof source.pineCode === 'string' ? source.pineCode : '', - inputMeta: - source.inputMeta && - typeof source.inputMeta === 'object' && - !Array.isArray(source.inputMeta) - ? (source.inputMeta as Record) - : null, + name: typeof source.name === 'string' ? source.name.trim() : '', + pineCode, + inputMeta: inferInputMetaFromPineCode(pineCode) ?? null, } - case 'mcp_server': + } + case 'mcp_server': { + const rawUrl = typeof source.url === 'string' ? source.url.trim() : '' + const validation = rawUrl ? validateMcpServerUrl(rawUrl) : null + if (validation && !validation.isValid) { + throw new Error(`Invalid MCP server URL: ${validation.error}`) + } + return { - name: typeof source.name === 'string' ? source.name : '', - description: typeof source.description === 'string' ? source.description : '', + name: typeof source.name === 'string' ? source.name.trim() : '', + description: typeof source.description === 'string' ? source.description.trim() : '', transport: source.transport === 'http' || source.transport === 'sse' || source.transport === 'streamable-http' ? source.transport : 'http', - url: typeof source.url === 'string' ? source.url : '', + url: validation?.normalizedUrl ?? rawUrl, headers: source.headers && typeof source.headers === 'object' && !Array.isArray(source.headers) ? Object.fromEntries( @@ -144,7 +152,7 @@ function normalizeEntityFields( ]) ) : {}, - command: typeof source.command === 'string' ? source.command : '', + command: typeof source.command === 'string' ? source.command.trim() : '', args: Array.isArray(source.args) ? source.args.map((value) => (typeof value === 'string' ? value : String(value ?? ''))) : [], @@ -161,10 +169,12 @@ function normalizeEntityFields( retries: typeof source.retries === 'number' ? source.retries : 3, enabled: typeof source.enabled === 'boolean' ? source.enabled : true, } + } case 'knowledge_base': return { - name: source.name, - description: source.description, + name: typeof source.name === 'string' ? source.name.trim() : source.name, + description: + typeof source.description === 'string' ? source.description.trim() : source.description, chunkingConfig: source.chunkingConfig, } } diff --git a/apps/tradinggoose/lib/yjs/client.ts b/apps/tradinggoose/lib/yjs/client.ts index 6f3d84dcb..64c27323e 100644 --- a/apps/tradinggoose/lib/yjs/client.ts +++ b/apps/tradinggoose/lib/yjs/client.ts @@ -10,12 +10,3 @@ export function applySnapshotToDoc(doc: Y.Doc, snapshotBase64: string): void { const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0)) Y.applyUpdate(doc, bytes) } - -/** - * Encodes a Yjs doc state as a base64 string. - */ -export function encodeDocAsBase64(doc: Y.Doc): string { - const update = Y.encodeStateAsUpdate(doc) - const binaryString = Array.from(update, (byte) => String.fromCharCode(byte)).join('') - return btoa(binaryString) -} diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index 7d4ac8288..d316ec6c5 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -8,17 +8,21 @@ import { } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' import * as Y from 'yjs' +import { normalizeEntityFields } from '@/lib/copilot/entity-documents' +import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' import { seedEntitySession } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { applyEntityStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { storeState } from '@/socket-server/yjs/persistence' -function parseObjectJson(value: unknown, fieldName: string): Record { - const parsed = JSON.parse(String(value ?? '')) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`${fieldName} must be a JSON object`) +export class SavedEntityPersistenceError extends Error { + constructor( + public status: number, + message: string + ) { + super(message) + this.name = 'SavedEntityPersistenceError' } - return parsed as Record } function objectField(value: unknown): Record { @@ -27,6 +31,23 @@ function objectField(value: unknown): Record { : {} } +function normalizeSavedEntityFields( + entityKind: SavedEntityKind, + fields: Record +): Record { + try { + const normalized = normalizeEntityFields(entityKind, fields) + return entityKind === 'indicator' + ? { ...normalized, color: String(fields.color ?? '').trim() } + : normalized + } catch (error) { + throw new SavedEntityPersistenceError( + 400, + error instanceof Error ? error.message : 'Invalid saved entity fields' + ) + } +} + async function persistSavedEntityState( entityKind: SavedEntityKind, entityId: string, @@ -53,7 +74,7 @@ async function persistSavedEntityState( .update(customTools) .set({ title: String(fields.title ?? ''), - schema: parseObjectJson(fields.schemaText, 'schemaText'), + schema: parseCustomToolSchemaText(fields.schemaText), code: String(fields.codeText ?? ''), updatedAt: now, }) @@ -108,7 +129,10 @@ async function persistSavedEntityState( } if (persisted.length === 0) { - throw new Error(`Saved ${entityKind} ${entityId} was not found while materializing Yjs state`) + throw new SavedEntityPersistenceError( + 404, + `Saved ${entityKind} ${entityId} was not found while materializing Yjs state` + ) } } @@ -135,6 +159,7 @@ export async function applySavedEntityPersistedState( entityId: string, fields: Record ): Promise { - await persistSavedEntityState(entityKind, entityId, fields) - await applySavedEntityDraftState(entityKind, entityId, fields) + const normalizedFields = normalizeSavedEntityFields(entityKind, fields) + await persistSavedEntityState(entityKind, entityId, normalizedFields) + await applySavedEntityDraftState(entityKind, entityId, normalizedFields) } diff --git a/apps/tradinggoose/lib/yjs/use-entity-fields.ts b/apps/tradinggoose/lib/yjs/use-entity-fields.ts index b3a1c1545..8e47fa446 100644 --- a/apps/tradinggoose/lib/yjs/use-entity-fields.ts +++ b/apps/tradinggoose/lib/yjs/use-entity-fields.ts @@ -9,11 +9,19 @@ */ import { useCallback, useEffect, useMemo, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import * as Y from 'yjs' +import { + buildYjsTransportEnvelope, + serializeYjsTransportEnvelope, +} from '@/lib/copilot/review-sessions/identity' import { getFieldsMap, replaceEntityTextField, setEntityField } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { bootstrapYjsProvider, type YjsProviderBootstrapResult } from '@/lib/yjs/provider' import { useYjsSubscription } from '@/lib/yjs/use-yjs-subscription' +import { customToolsKeys } from '@/hooks/queries/custom-tools' +import { indicatorKeys } from '@/hooks/queries/indicators' +import { skillsKeys } from '@/hooks/queries/skills' type SavedEntityYjsSessionState = { key: string | null @@ -26,6 +34,7 @@ export function useSavedEntityYjsSession( entityId: string | null | undefined, workspaceId: string | null | undefined ) { + const queryClient = useQueryClient() const sessionKey = entityId && workspaceId ? `${entityKind}:${workspaceId}:${entityId}` : null const [state, setState] = useState({ key: null, @@ -76,9 +85,39 @@ export function useSavedEntityYjsSession( }, [entityId, entityKind, sessionKey, workspaceId]) const activeState = state.key === sessionKey ? state : null + const save = useCallback(async () => { + if (!activeState?.result || !workspaceId) { + throw new Error('Yjs session is not ready') + } + + const { descriptor } = activeState.result + const params = new URLSearchParams({ + ...serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor)), + accessMode: 'write', + }) + const response = await fetch( + `/api/yjs/sessions/${encodeURIComponent(descriptor.yjsSessionId)}/snapshot?${params}`, + { + method: 'POST', + } + ) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error || 'Failed to save Yjs session') + } + + if (entityKind === 'skill') { + queryClient.invalidateQueries({ queryKey: skillsKeys.list(workspaceId) }) + } else if (entityKind === 'custom_tool') { + queryClient.invalidateQueries({ queryKey: customToolsKeys.list(workspaceId) }) + } else if (entityKind === 'indicator') { + queryClient.invalidateQueries({ queryKey: indicatorKeys.list(workspaceId) }) + } + }, [activeState?.result, entityKind, queryClient, workspaceId]) return { doc: activeState?.result?.doc ?? null, + save, isLoading: Boolean(sessionKey && !activeState?.result && !activeState?.error), error: activeState?.error ?? null, } @@ -92,7 +131,7 @@ export function useSavedEntityYjsSession( export function useYjsStringField( doc: Y.Doc | null | undefined, key: string, - fallback: string = '' + fallback = '' ): [string, (v: string | ((prev: string) => string)) => void] { const subscribe = useMemo(() => { if (!doc) return (cb: () => void) => () => {} @@ -186,7 +225,7 @@ export function useYjsField( export function useYjsBooleanField( doc: Y.Doc | null | undefined, key: string, - fallback: boolean = false + fallback = false ): [boolean, (v: boolean) => void] { return useYjsField(doc, key, fallback) } @@ -197,7 +236,7 @@ export function useYjsBooleanField( export function useYjsNumberField( doc: Y.Doc | null | undefined, key: string, - fallback: number = 0 + fallback = 0 ): [number, (v: number) => void] { return useYjsField(doc, key, fallback) } diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index 19946727f..078e0ad82 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -21,7 +21,11 @@ import { storeCanonicalState, storeState, } from '@/socket-server/yjs/persistence' -import { getExistingDocument, removeDocument } from '@/socket-server/yjs/upstream-utils' +import { + flushDocumentPersistence, + getExistingDocument, + removeDocument, +} from '@/socket-server/yjs/upstream-utils' interface Logger { info: (message: string, ...args: any[]) => void @@ -348,6 +352,10 @@ async function getLiveOrPersistedYjsState( sessionId: string ): Promise<{ liveDoc: Y.Doc | null; state: Uint8Array | null; touchedAt: number | null }> { const liveDoc = await getExistingDocument(sessionId) + if (liveDoc) { + await flushDocumentPersistence(sessionId) + } + const state = liveDoc ? Y.encodeStateAsUpdate(liveDoc) : await getState(sessionId) return { liveDoc, diff --git a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts index 6dccc8681..9a9009e44 100644 --- a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts +++ b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts @@ -7,15 +7,15 @@ * and `cleanupAllDocuments`. */ -import * as Y from 'yjs' +import type { IncomingMessage } from 'http' import * as awarenessProtocol from '@y/protocols/awareness' import * as syncProtocol from '@y/protocols/sync' import * as decoding from 'lib0/decoding' import * as encoding from 'lib0/encoding' import * as map from 'lib0/map' import * as mutex from 'lib0/mutex' -import type { IncomingMessage } from 'http' import type { WebSocket } from 'ws' +import * as Y from 'yjs' const messageSync = 0 const messageAwareness = 1 @@ -54,11 +54,7 @@ class WSSharedDoc extends Y.Doc { this.awareness.on( 'update', ( - { - added, - updated, - removed, - }: { added: number[]; updated: number[]; removed: number[] }, + { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }, conn: WebSocket | null ) => { const changedClients = added.concat(updated, removed) @@ -265,6 +261,16 @@ export async function getExistingDocument(docId: string): Promise return doc } +export async function flushDocumentPersistence(docId: string): Promise { + const doc = docs.get(docId) + if (!doc) { + return + } + + await doc.whenInitialized + await doc.flushPersistence() +} + export function setupWSConnection( conn: WebSocket, _req: IncomingMessage, diff --git a/apps/tradinggoose/widgets/widgets/_shared/mcp/utils.ts b/apps/tradinggoose/widgets/widgets/_shared/mcp/utils.ts index 35b62ceff..41a9ace13 100644 --- a/apps/tradinggoose/widgets/widgets/_shared/mcp/utils.ts +++ b/apps/tradinggoose/widgets/widgets/_shared/mcp/utils.ts @@ -1,5 +1,4 @@ import type { McpTransport } from '@/lib/mcp/types' -import { normalizeStringArray, sanitizeRecord } from '@/lib/utils' import { readEntitySelectionState, resolveEntityId } from '@/widgets/utils/entity-selection' import { MCP_SERVER_DEFAULTS } from '@/widgets/utils/mcp-defaults' @@ -27,20 +26,6 @@ export const createDefaultMcpServerFormData = (): McpServerFormData => ({ env: {}, }) -export const createMcpSavePayload = (formData: McpServerFormData) => ({ - name: formData.name.trim(), - description: formData.description.trim() || null, - transport: formData.transport, - url: formData.url.trim() || null, - headers: sanitizeRecord(formData.headers), - command: formData.command.trim() || null, - args: normalizeStringArray(formData.args), - env: sanitizeRecord(formData.env), - timeout: formData.timeout, - retries: formData.retries, - enabled: formData.enabled, -}) - export const resolveMcpServerId = ({ params, pairContext, diff --git a/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.test.tsx b/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.test.tsx index 99a68526b..c16e82182 100644 --- a/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.test.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.test.tsx @@ -10,17 +10,8 @@ import * as Y from 'yjs' import { replaceEntityTextField, seedEntitySession, setEntityField } from '@/lib/yjs/entity-session' import { CustomToolEditor } from '@/widgets/widgets/editor_custom_tool/custom-tool-editor' -const mockUseUpdateCustomTool = vi.fn() const mockUseWand = vi.fn() -vi.mock('@/hooks/queries/custom-tools', async () => { - const actual = await vi.importActual('@/hooks/queries/custom-tools') - return { - ...actual, - useUpdateCustomTool: () => mockUseUpdateCustomTool(), - } -}) - vi.mock('@/hooks/workflow/use-wand', () => ({ useWand: (...args: unknown[]) => mockUseWand(...args), })) @@ -131,10 +122,6 @@ describe('CustomToolEditor export', () => { root = createRoot(container) capturedDownloadName = '' - mockUseUpdateCustomTool.mockReturnValue({ - isPending: false, - mutateAsync: vi.fn(), - }) mockUseWand.mockImplementation(() => createWandState()) createObjectUrlSpy = vi.fn(() => 'blob:custom-tool-export') @@ -197,6 +184,7 @@ describe('CustomToolEditor export', () => { exportRef={exportRef as MutableRefObject<() => void>} saveRef={saveRef as MutableRefObject<() => void>} doc={doc} + save={vi.fn()} /> ) }) @@ -239,6 +227,7 @@ describe('CustomToolEditor export', () => { exportRef={exportRef as MutableRefObject<() => void>} saveRef={saveRef as MutableRefObject<() => void>} doc={doc} + save={vi.fn()} /> ) }) @@ -326,6 +315,7 @@ describe('CustomToolEditor export', () => { exportRef={exportRef as MutableRefObject<() => void>} saveRef={saveRef as MutableRefObject<() => void>} doc={doc} + save={vi.fn()} /> ) }) diff --git a/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.tsx b/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.tsx index abe85c2e1..ca51e66d2 100644 --- a/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.tsx @@ -14,7 +14,6 @@ import { CustomToolOpenAiSchema } from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' import { useYjsStringField } from '@/lib/yjs/use-entity-fields' -import { useUpdateCustomTool } from '@/hooks/queries/custom-tools' import { useWand } from '@/hooks/workflow/use-wand' import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' import { WandPromptBar } from '@/widgets/widgets/editor_workflow/components/wand-prompt-bar/wand-prompt-bar' @@ -30,6 +29,7 @@ interface CustomToolEditorProps { blockId: string toolId: string doc: Y.Doc | null + save: () => Promise onSave: () => void onSectionChange: (section: CustomToolEditorSection) => void exportRef: MutableRefObject<() => void> @@ -41,6 +41,7 @@ export function CustomToolEditor({ blockId, toolId, doc, + save, onSave, onSectionChange, exportRef, @@ -65,8 +66,6 @@ export function CustomToolEditor({ const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }) const [schemaParamSelectedIndex, setSchemaParamSelectedIndex] = useState(0) - const updateToolMutation = useUpdateCustomTool() - useEffect(() => { setSchemaError(null) setCodeError(null) @@ -349,15 +348,12 @@ IMPORTANT FORMATTING RULES: return } - await updateToolMutation.mutateAsync({ - workspaceId, - toolId, - updates: { - title, - schema, - code: functionCode || '', - }, - }) + const latestFunctionCode = + codeEditorHandleRef.current?.getEditor()?.getValue() ?? functionCode + + setFunctionCode(latestFunctionCode) + + await save() onSave() } catch (error) { @@ -368,12 +364,13 @@ IMPORTANT FORMATTING RULES: }, [ parseCurrentSchema, doc, - functionCode, onSave, onSectionChange, + save, + functionCode, + setFunctionCode, toolTitle, toolId, - updateToolMutation, workspaceId, ]) diff --git a/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx b/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx index 6aad1c930..92642fa28 100644 --- a/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx @@ -277,6 +277,7 @@ function EditorCustomToolWidgetBody({ { diff --git a/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx b/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx index 084faec9f..bc294d1fb 100644 --- a/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx @@ -16,8 +16,8 @@ import { SelectValue, } from '@/components/ui/select' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { exportIndicatorsAsJson } from '@/lib/indicators/import-export' import { executeBrowserPineIndicator } from '@/lib/indicators/browser-execution' +import { exportIndicatorsAsJson } from '@/lib/indicators/import-export' import { buildInputsMapFromMeta, inferInputMetaFromPineCode } from '@/lib/indicators/input-meta' import { PINE_CHEAT_SHEET_EXTRA_LIBS } from '@/lib/indicators/pine-cheat-sheet' import { mapMarketSeriesToBarsMs } from '@/lib/indicators/series-data' @@ -25,7 +25,6 @@ import { detectTriggerUsage } from '@/lib/indicators/trigger-detection' import { detectUnsupportedFeatures } from '@/lib/indicators/unsupported' import { generateMockMarketSeries } from '@/lib/market/mock-series' import { useYjsStringField } from '@/lib/yjs/use-entity-fields' -import { useUpdateIndicator } from '@/hooks/queries/indicators' import { useWand } from '@/hooks/workflow/use-wand' import { CHEAT_SHEET_GROUPS, @@ -38,6 +37,7 @@ type IndicatorCodePanelProps = { indicatorId: string workspaceId: string doc: Y.Doc | null + save: () => Promise exportRef: MutableRefObject<() => void> saveRef: MutableRefObject<() => void> verifyRef: MutableRefObject<() => void> @@ -162,11 +162,11 @@ export function IndicatorCodePanel({ indicatorId, workspaceId, doc, + save, exportRef, saveRef, verifyRef, }: IndicatorCodePanelProps) { - const updateMutation = useUpdateIndicator() const [indicatorName] = useYjsStringField(doc, 'name') const [pineCode, setPineCode] = useYjsStringField(doc, 'pineCode') @@ -259,27 +259,23 @@ export function IndicatorCodePanel({ const handleSave = useCallback(async () => { if (!workspaceId || !indicatorId || !doc) return - const disallowedMessage = validateNoDollarGlobals(pineCode) + const currentPineCode = codeEditorHandleRef.current?.getEditor()?.getValue() ?? pineCode + const disallowedMessage = validateNoDollarGlobals(currentPineCode) if (disallowedMessage) { setVerifyStatus({ state: 'error', message: disallowedMessage }) return } - const inferredInputMeta = inferInputMetaFromPineCode(pineCode) try { - await updateMutation.mutateAsync({ - workspaceId, - indicatorId, - updates: { - name: indicatorName, - pineCode, - inputMeta: inferredInputMeta ?? null, - }, - }) + if (currentPineCode !== pineCode) { + setPineCode(currentPineCode) + } + + await save() } catch (err) { console.error('Failed to update indicator', err) } - }, [workspaceId, indicatorId, doc, updateMutation, indicatorName, pineCode]) + }, [workspaceId, indicatorId, doc, pineCode, save, setPineCode]) const handleExport = useCallback(() => { if (!doc) return @@ -298,7 +294,9 @@ export function IndicatorCodePanel({ .trim() .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-') .replace(/\s+/g, '-') || 'indicator' - const blobUrl = URL.createObjectURL(new Blob([json], { type: 'application/json;charset=utf-8' })) + const blobUrl = URL.createObjectURL( + new Blob([json], { type: 'application/json;charset=utf-8' }) + ) const link = document.createElement('a') link.href = blobUrl link.download = `${fileNameBase}.json` diff --git a/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx b/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx index 9b8e8b671..e27cae7cf 100644 --- a/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx @@ -1,10 +1,10 @@ 'use client' import { useCallback, useEffect, useRef } from 'react' -import { LoadingAgent } from '@/components/ui/loading-agent' import { useMessages } from 'next-intl' -import { useIndicators } from '@/hooks/queries/indicators' +import { LoadingAgent } from '@/components/ui/loading-agent' import { useSavedEntityYjsSession } from '@/lib/yjs/use-entity-fields' +import { useIndicators } from '@/hooks/queries/indicators' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import type { PairColor } from '@/widgets/pair-colors' import type { WidgetComponentProps } from '@/widgets/types' @@ -46,7 +46,9 @@ export function EditorIndicatorWidgetBody({ workspaceIndicators.some((indicator) => indicator.id === normalizedRequestedIndicatorId) const indicatorId = hasRequestedIndicator ? normalizedRequestedIndicatorId - : (isLinkedToColorPair ? null : (workspaceIndicators[0]?.id ?? null)) + : isLinkedToColorPair + ? null + : (workspaceIndicators[0]?.id ?? null) const indicator = indicatorId ? (workspaceIndicators.find((candidate) => candidate.id === indicatorId) ?? null) : null @@ -177,6 +179,7 @@ export function EditorIndicatorWidgetBody({ indicatorId={indicatorId} workspaceId={workspaceId} doc={indicatorSession.doc} + save={indicatorSession.save} exportRef={codeExportRef} saveRef={codeSaveRef} verifyRef={codeVerifyRef} diff --git a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx index b8bcd7ed7..7c693899f 100644 --- a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx @@ -1,21 +1,16 @@ 'use client' -import { - type SetStateAction, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' -import { LoadingAgent } from '@/components/ui/loading-agent' -import { useMcpServerTest } from '@/hooks/use-mcp-server-test' -import { useMcpTools } from '@/hooks/use-mcp-tools' +import { type SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useMessages } from 'next-intl' -import { formatTemplate } from '@/i18n/utils' +import type * as Y from 'yjs' +import { LoadingAgent } from '@/components/ui/loading-agent' +import { sanitizeRecord } from '@/lib/utils' import { getFieldsMap, setEntityField } from '@/lib/yjs/entity-session' import { useSavedEntityYjsSession } from '@/lib/yjs/use-entity-fields' import { useYjsSubscription } from '@/lib/yjs/use-yjs-subscription' +import { useMcpServerTest } from '@/hooks/use-mcp-server-test' +import { useMcpTools } from '@/hooks/use-mcp-tools' +import { formatTemplate } from '@/i18n/utils' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import { useMcpServersStore } from '@/stores/mcp-servers/store' import type { McpServerWithStatus } from '@/stores/mcp-servers/types' @@ -26,12 +21,10 @@ import { useMcpSelectionPersistence } from '@/widgets/utils/mcp-selection' import { McpServerForm } from '@/widgets/widgets/_shared/mcp/components/mcp-server-form' import { createDefaultMcpServerFormData, - createMcpSavePayload, type McpServerFormData, resolveMcpServerId, } from '@/widgets/widgets/_shared/mcp/utils' import { WidgetStateMessage } from '@/widgets/widgets/editor_indicator/components/widget-state-message' -import type * as Y from 'yjs' type EditorMcpWidgetBodyProps = WidgetComponentProps @@ -181,14 +174,12 @@ export function EditorMcpWidgetBody({ error: serverError, fetchServers, refreshServer, - updateServer, } = useMcpServersStore((state) => ({ servers: state.servers, isLoading: state.isLoading, error: state.error, fetchServers: state.fetchServers, refreshServer: state.refreshServer, - updateServer: state.updateServer, })) const { refreshTools, getToolsByServer } = useMcpTools(workspaceId ?? '') const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest() @@ -283,7 +274,7 @@ export function EditorMcpWidgetBody({ name: formDataState.name.trim() || copy.unnamedServer, transport: formDataState.transport, url: formDataState.url, - headers: createMcpSavePayload(formDataState).headers, + headers: sanitizeRecord(formDataState.headers), timeout: formDataState.timeout, workspaceId, }) @@ -313,8 +304,7 @@ export function EditorMcpWidgetBody({ const handleSave = useCallback(async () => { if (!workspaceId || !selectedServerId || !serverSession.doc) return - const payload = createMcpSavePayload(formDataState) - if (!payload.name) { + if (!formDataState.name.trim()) { setSaveError(copy.serverNameRequired) return } @@ -322,7 +312,7 @@ export function EditorMcpWidgetBody({ setSaveError(null) try { - await updateServer(workspaceId, selectedServerId, payload) + await serverSession.save() initialFormDataRef.current = formDataState await fetchServers(workspaceId) } catch (error) { @@ -335,8 +325,8 @@ export function EditorMcpWidgetBody({ fetchServers, formDataState, serverSession.doc, + serverSession.save, selectedServerId, - updateServer, workspaceId, ]) diff --git a/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx b/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx index 62ad6c417..32c65d558 100644 --- a/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx @@ -1,10 +1,10 @@ 'use client' import { useEffect, useRef } from 'react' -import { LoadingAgent } from '@/components/ui/loading-agent' import { useMessages } from 'next-intl' -import { useSkills } from '@/hooks/queries/skills' +import { LoadingAgent } from '@/components/ui/loading-agent' import { useSavedEntityYjsSession } from '@/lib/yjs/use-entity-fields' +import { useSkills } from '@/hooks/queries/skills' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import type { PairColor } from '@/widgets/pair-colors' import type { WidgetComponentProps } from '@/widgets/types' @@ -42,7 +42,9 @@ export function EditorSkillWidgetBody({ skills.some((skill) => skill.id === normalizedRequestedSkillId) const skillId = hasRequestedSkill ? normalizedRequestedSkillId - : (isLinkedToColorPair ? null : (skills[0]?.id ?? null)) + : isLinkedToColorPair + ? null + : (skills[0]?.id ?? null) const skill = skillId ? (skills.find((candidate) => candidate.id === skillId) ?? null) : null const skillSession = useSavedEntityYjsSession('skill', skillId, workspaceId) @@ -151,8 +153,8 @@ export function EditorSkillWidgetBody({ return (
{ - const actual = await vi.importActual('@/hooks/queries/skills') - return { - ...actual, - useUpdateSkill: () => mockUseUpdateSkill(), - } -}) - const reactActEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -44,7 +34,7 @@ describe('SkillEditor save', () => { }) it('saves the current Yjs fields', async () => { - const mutateAsync = vi.fn().mockResolvedValue({}) + const save = vi.fn().mockResolvedValue(undefined) const exportRef = createRef<() => void>() const saveRef = createRef<() => void>() const doc = new Y.Doc() @@ -57,19 +47,14 @@ describe('SkillEditor save', () => { saveRef.current = () => {} seedEntitySession(doc, { entityKind: 'skill', payload: initialValues }) - mockUseUpdateSkill.mockReturnValue({ - isPending: false, - mutateAsync, - }) - await act(async () => { root.render( void>} saveRef={saveRef as MutableRefObject<() => void>} skillId='skill-1' doc={doc} + save={save} /> ) }) @@ -89,15 +74,7 @@ describe('SkillEditor save', () => { await Promise.resolve() }) - expect(mutateAsync).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - skillId: 'skill-1', - updates: { - name: 'Market Research Updated', - description: 'Investigate the market.', - content: 'Use multiple trusted sources.', - }, - }) + expect(save).toHaveBeenCalledTimes(1) doc.destroy() }) }) diff --git a/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.tsx b/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.tsx index 8b0cf30f7..6135406c1 100644 --- a/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.tsx @@ -4,30 +4,24 @@ import type * as Y from 'yjs' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' -import { formatTemplate } from '@/i18n/utils' -import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' import { createLogger } from '@/lib/logs/console/logger' import { exportSkillsAsJson, SKILL_NAME_MAX_LENGTH } from '@/lib/skills/import-export' import { useYjsStringField } from '@/lib/yjs/use-entity-fields' -import { isValidSkillName, useUpdateSkill } from '@/hooks/queries/skills' +import { isValidSkillName } from '@/hooks/queries/skills' +import { formatTemplate } from '@/i18n/utils' +import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' const logger = createLogger('SkillEditor') interface SkillEditorProps { - workspaceId: string skillId: string doc: Y.Doc | null + save: () => Promise exportRef: MutableRefObject<() => void> saveRef: MutableRefObject<() => void> } -export function SkillEditor({ - workspaceId, - skillId, - doc, - exportRef, - saveRef, -}: SkillEditorProps) { +export function SkillEditor({ skillId, doc, save, exportRef, saveRef }: SkillEditorProps) { const copy = useWorkspaceWidgetsMessages().skillEditor const [name, setName] = useYjsStringField(doc, 'name') const [description, setDescription] = useYjsStringField(doc, 'description') @@ -35,8 +29,6 @@ export function SkillEditor({ const [error, setError] = useState(null) const [isSaving, setIsSaving] = useState(false) - const updateSkillMutation = useUpdateSkill() - useEffect(() => { setError(null) }, [doc, skillId]) @@ -72,19 +64,7 @@ export function SkillEditor({ setError(null) try { - await updateSkillMutation.mutateAsync({ - workspaceId, - skillId, - updates: { - name: trimmedName, - description: trimmedDescription, - content: trimmedContent, - }, - }) - - setName(trimmedName) - setDescription(trimmedDescription) - setContent(trimmedContent) + await save() } catch (saveError) { const message = saveError instanceof Error ? saveError.message : copy.validation.saveFailed logger.error('Failed to save skill', { error: saveError, skillId }) @@ -92,7 +72,7 @@ export function SkillEditor({ } finally { setIsSaving(false) } - }, [content, description, doc, name, skillId, updateSkillMutation, workspaceId]) + }, [content, copy.validation, description, doc, name, save, skillId]) const handleExport = useCallback(() => { if (!doc) return @@ -105,7 +85,9 @@ export function SkillEditor({ .trim() .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-') .replace(/\s+/g, '-') || 'skill' - const blobUrl = URL.createObjectURL(new Blob([json], { type: 'application/json;charset=utf-8' })) + const blobUrl = URL.createObjectURL( + new Blob([json], { type: 'application/json;charset=utf-8' }) + ) const link = document.createElement('a') link.href = blobUrl link.download = `${fileNameBase}.json` @@ -138,9 +120,7 @@ export function SkillEditor({ disabled={!doc || isSaving} maxLength={SKILL_NAME_MAX_LENGTH} /> -

- {copy.form.helperText} -

+

{copy.form.helperText}

From 6d03e1a5a11e6f5f53e4c76bc841269ed5f8b6c9 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 23 Jun 2026 15:39:33 -0600 Subject: [PATCH 089/284] fix(copilot): preserve MCP server secrets in documents Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../tradinggoose/app/api/copilot/mcp/route.ts | 1 + .../lib/copilot/entity-documents.ts | 29 ++++---- .../lib/copilot/tool-prompt-metadata.ts | 6 +- .../tools/server/entities/mcp-server.ts | 71 ++++++++++++++++++- .../tools/server/entities/shared.test.ts | 19 ++++- .../copilot/tools/server/entities/shared.ts | 25 ++----- .../copilot/tools/server/review-acceptance.ts | 51 +------------ 7 files changed, 112 insertions(+), 90 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index 01e893569..b630a1810 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -98,6 +98,7 @@ async function buildInstructions(userId: string) { 'TradingGoose Copilot MCP exposes the same server-side Copilot tools used by TradingGoose Studio.', 'Local MCP config stores only this user auth token. Do not store workspaceId, entityId, or entity targets in the local MCP config.', 'Use entityId for read/edit/rename tools that target an existing entity. Use workspaceId for workspace-scoped tools, including list/create, credential, OAuth, Google Drive, workspace account reads, and workspace-scoped environment writes. Environment writes require scope="personal" or scope="workspace".', + 'MCP server documents redact header/env secret values as [redacted]. Keep [redacted] to preserve an existing secret, send a concrete value to replace it, or omit the key to delete it.', 'Accessible workspaces for the authenticated user:', ...workspaceLines, ].join('\n') diff --git a/apps/tradinggoose/lib/copilot/entity-documents.ts b/apps/tradinggoose/lib/copilot/entity-documents.ts index 2b8f97d63..243dd3d9b 100644 --- a/apps/tradinggoose/lib/copilot/entity-documents.ts +++ b/apps/tradinggoose/lib/copilot/entity-documents.ts @@ -50,10 +50,18 @@ const McpServerDocumentSchema = z.object({ description: z.string(), transport: z.enum(['http', 'sse', 'streamable-http']), url: z.string(), - headers: z.record(z.string()), + headers: z + .record(z.string()) + .describe( + 'MCP server headers. Secret values are returned as [redacted]; keep [redacted] to preserve an existing value, send a concrete value to replace it, or omit a key to delete it.' + ), command: z.string(), args: z.array(z.string()), - env: z.record(z.string()), + env: z + .record(z.string()) + .describe( + 'MCP server environment variables. Secret values are returned as [redacted]; keep [redacted] to preserve an existing value, send a concrete value to replace it, or omit a key to delete it.' + ), timeout: z.number(), retries: z.number(), enabled: z.boolean(), @@ -85,7 +93,7 @@ export type EntityDocumentFields = z.infer< (typeof EntityDocumentSchemas)[K] > -const REVIEW_SECRET_PLACEHOLDER = '[redacted]' +export const ENTITY_SECRET_PLACEHOLDER = '[redacted]' function redactStringRecordValues(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -93,7 +101,7 @@ function redactStringRecordValues(value: unknown): Record { } return Object.fromEntries( - Object.keys(value as Record).map((key) => [key, REVIEW_SECRET_PLACEHOLDER]) + Object.keys(value as Record).map((key) => [key, ENTITY_SECRET_PLACEHOLDER]) ) } @@ -197,7 +205,7 @@ export function parseEntityDocument( return EntityDocumentSchemas[kind].parse(normalized) as EntityDocumentFields } -function redactEntityDocumentFieldsForReview( +function redactEntityDocumentSecretFields( kind: K, fields: Record | null | undefined ): EntityDocumentFields { @@ -218,16 +226,7 @@ export function serializeEntityDocument( kind: K, fields: Record | null | undefined ): string { - const normalized = normalizeEntityFields(kind, fields) - const parsed = EntityDocumentSchemas[kind].parse(normalized) - return JSON.stringify(parsed, null, 2) -} - -export function serializeEntityDocumentForReview( - kind: K, - fields: Record | null | undefined -): string { - return JSON.stringify(redactEntityDocumentFieldsForReview(kind, fields), null, 2) + return JSON.stringify(redactEntityDocumentSecretFields(kind, fields), null, 2) } export function getEntityDocumentName( diff --git a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts index cd63f5a34..71e3760ce 100644 --- a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts +++ b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts @@ -304,7 +304,7 @@ export const TOOL_PROMPT_METADATA: Record = { }, [CopilotTool.read_mcp_server]: { description: - 'Return one MCP server by `entityId` as an editable document payload with `entityDocument` and `documentFormat`.', + 'Return one MCP server by `entityId` as an editable document payload. Secret header/env values are redacted as `[redacted]`.', kind: 'read', entityKind: 'mcp_server', }, @@ -316,13 +316,13 @@ export const TOOL_PROMPT_METADATA: Record = { }, edit_mcp_server: { description: - 'Update the target MCP server from a full server document and return the resulting document.', + 'Update the target MCP server from a full server document. Keep `[redacted]` header/env values to preserve existing secrets, send concrete values to replace them, or omit keys to delete them.', kind: 'edit', entityKind: 'mcp_server', }, rename_mcp_server: { description: - 'Rename the target MCP server by sending a full server document with the updated `name`, then return the resulting document.', + 'Rename the target MCP server by sending a full server document with the updated `name`. Keep `[redacted]` header/env values to preserve existing secrets.', kind: 'rename', entityKind: 'mcp_server', }, diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts index 5fb1ed07d..d77457a2a 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -1,6 +1,7 @@ import { db } from '@tradinggoose/db' import { mcpServers } from '@tradinggoose/db/schema' import { and, eq, isNull } from 'drizzle-orm' +import { ENTITY_SECRET_PLACEHOLDER } from '@/lib/copilot/entity-documents' import { ENTITY_KIND_MCP_SERVER } from '@/lib/copilot/review-sessions/types' import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' import { mcpService } from '@/lib/mcp/service' @@ -66,6 +67,67 @@ function normalizeMcpServerFields(fields: Record): Record, + currentValues: Record +): Record { + return Object.fromEntries( + Object.entries(nextValues).map(([key, value]) => { + if (value !== ENTITY_SECRET_PLACEHOLDER) { + return [key, value] + } + const currentValue = currentValues[key] + if (typeof currentValue !== 'string') { + throw new Error(`Cannot preserve missing MCP server ${fieldName} value "${key}"`) + } + return [key, currentValue] + }) + ) +} + +function assertNoSecretPlaceholders(fields: Record): void { + for (const fieldName of ['headers', 'env'] as const) { + const values = normalizeStringRecord(fields[fieldName]) + const placeholderKey = Object.entries(values).find( + ([, value]) => value === ENTITY_SECRET_PLACEHOLDER + )?.[0] + if (placeholderKey) { + throw new Error( + `Cannot use ${ENTITY_SECRET_PLACEHOLDER} for new MCP server ${fieldName} value "${placeholderKey}"` + ) + } + } +} + +function preserveMcpServerSecretPlaceholders( + nextFields: Record, + currentFields: Record +): Record { + const normalizedNext = normalizeMcpServerFields(nextFields) + const normalizedCurrent = normalizeMcpServerFields(currentFields) + + return { + ...normalizedNext, + headers: preserveSecretRecordPlaceholders( + 'headers', + normalizeStringRecord(normalizedNext.headers), + normalizeStringRecord(normalizedCurrent.headers) + ), + env: preserveSecretRecordPlaceholders( + 'env', + normalizeStringRecord(normalizedNext.env), + normalizeStringRecord(normalizedCurrent.env) + ), + } +} + +function prepareNewMcpServerFields(fields: Record): Record { + const normalized = normalizeMcpServerFields(fields) + assertNoSecretPlaceholders(normalized) + return normalized +} + function toMcpServerListEntry(row: typeof mcpServers.$inferSelect): EntityListEntry { return { entityId: row.id, @@ -131,10 +193,15 @@ async function applyMcpServerDocument(input: { fields: Record workspaceId: string }) { + const currentFields = await readSavedEntityDocumentFields( + ENTITY_KIND_MCP_SERVER, + input.entityId, + input.workspaceId + ) await applySavedEntityDocument( ENTITY_KIND_MCP_SERVER, input.entityId, - normalizeMcpServerFields(input.fields) + preserveMcpServerSecretPlaceholders(input.fields, currentFields) ) mcpService.clearCache(input.workspaceId) } @@ -187,7 +254,7 @@ export const createMcpServerServerTool: EntityServerTool = { args, context, createMcpServerEntity, - normalizeMcpServerFields + prepareNewMcpServerFields ) }, } diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts index 05c2ebf78..9d6cdf0f7 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { MCP_SERVER_DOCUMENT_FORMAT, SKILL_DOCUMENT_FORMAT } from '@/lib/copilot/entity-documents' import { hashServerToolReviewBase } from '@/lib/copilot/tools/server/base-tool' import { + buildDocumentEnvelope, buildReviewDocumentDiff, executeCreateEntityDocumentMutation, executeUpdateEntityDocumentMutation, @@ -141,7 +142,20 @@ describe('entity document mutation helpers', () => { ) }) - it('redacts MCP server secret values in review documents', async () => { + it('redacts MCP server secret values in Copilot documents', async () => { + const readEnvelope = buildDocumentEnvelope('mcp_server', 'mcp-1', { + name: 'Private MCP', + description: 'Uses auth', + transport: 'http', + url: 'https://mcp.example.test', + headers: { Authorization: 'Bearer read-secret' }, + command: '', + args: [], + env: { API_KEY: 'read-secret-env' }, + timeout: 30000, + retries: 3, + enabled: true, + }) const result = await executeCreateEntityDocumentMutation( 'mcp_server', { @@ -183,6 +197,9 @@ describe('entity document mutation helpers', () => { JSON.parse(after) ) + expect(readEnvelope.entityDocument).toContain('[redacted]') + expect(readEnvelope.entityDocument).not.toContain('read-secret') + expect(readEnvelope.entityDocument).not.toContain('read-secret-env') expect(after).toContain('[redacted]') expect(after).not.toContain('secret-token') expect(after).not.toContain('secret-env') diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index 37b69c342..8ded14139 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -4,7 +4,6 @@ import { getEntityDocumentName, parseEntityDocument, serializeEntityDocument, - serializeEntityDocumentForReview, } from '@/lib/copilot/entity-documents' import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' import type { @@ -185,28 +184,14 @@ export function buildDocumentEnvelope( } } -export function buildReviewDocumentEnvelope( - kind: SavedEntityDocumentKind, - entityId: string | undefined, - fields: Record -) { - return { - entityKind: kind, - ...(entityId ? { entityId } : {}), - entityName: getEntityDocumentName(kind, fields), - documentFormat: getEntityDocumentFormat(kind), - entityDocument: serializeEntityDocumentForReview(kind, fields), - } -} - export function buildReviewDocumentDiff( kind: SavedEntityDocumentKind, before: Record, after: Record ) { return { - before: serializeEntityDocumentForReview(kind, before), - after: serializeEntityDocumentForReview(kind, after), + before: serializeEntityDocument(kind, before), + after: serializeEntityDocument(kind, after), } } @@ -248,11 +233,11 @@ export async function executeCreateEntityDocumentMutation( success: true, workspaceId, reviewBaseStateHash: hashServerToolReviewBase({ kind, workspaceId }), - ...buildReviewDocumentEnvelope(kind, undefined, fields), + ...buildDocumentEnvelope(kind, undefined, fields), preview: { documentDiff: { before: '', - after: serializeEntityDocumentForReview(kind, fields), + after: serializeEntityDocument(kind, fields), }, }, } @@ -285,7 +270,7 @@ export async function executeUpdateEntityDocumentMutation( requiresReview: true, success: true, reviewBaseStateHash: hashServerToolReviewBase(currentFields), - ...buildReviewDocumentEnvelope(kind, entityId, fields), + ...buildDocumentEnvelope(kind, entityId, fields), preview: { documentDiff: buildReviewDocumentDiff(kind, currentFields, fields), }, diff --git a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts index 366845005..208d50712 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts @@ -2,11 +2,6 @@ import { db } from '@tradinggoose/db' import { verification } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' -import { - MCP_SERVER_DOCUMENT_FORMAT, - parseEntityDocument, - serializeEntityDocumentForReview, -} from '@/lib/copilot/entity-documents' import type { ToolId } from '@/lib/copilot/registry' import { assertAcceptedServerToolReviewBase, @@ -41,48 +36,6 @@ function readBaseStateHash(result: unknown): string { throw new Error('Server tool review result is missing base state') } -function redactMcpServerReviewDocument(value: unknown): unknown { - if (typeof value !== 'string') { - return value - } - - if (!value) { - return '' - } - - return serializeEntityDocumentForReview('mcp_server', parseEntityDocument('mcp_server', value)) -} - -function redactReviewSecrets(result: unknown) { - if (!result || typeof result !== 'object' || Array.isArray(result)) { - return result - } - - const record = result as Record - if (record.entityKind !== 'mcp_server' && record.documentFormat !== MCP_SERVER_DOCUMENT_FORMAT) { - return result - } - - const publicResult: Record = { ...record } - publicResult.entityDocument = redactMcpServerReviewDocument(publicResult.entityDocument) - const preview = record.preview - if (preview && typeof preview === 'object' && !Array.isArray(preview)) { - const documentDiff = (preview as { documentDiff?: unknown }).documentDiff - if (documentDiff && typeof documentDiff === 'object' && !Array.isArray(documentDiff)) { - publicResult.preview = { - ...preview, - documentDiff: { - ...(documentDiff as Record), - before: redactMcpServerReviewDocument((documentDiff as { before?: unknown }).before), - after: redactMcpServerReviewDocument((documentDiff as { after?: unknown }).after), - }, - } - } - } - - return publicResult -} - export async function stageServerManagedToolReview( toolName: ToolId, payload: unknown, @@ -122,7 +75,7 @@ export async function stageServerManagedToolReview( }) return { - ...(redactReviewSecrets(publicResult) as Record), + ...publicResult, reviewToken, } } @@ -221,5 +174,5 @@ export async function acceptServerManagedToolReview( .catch((error) => { logger.warn('Failed to delete accepted server tool review token', { error, toolName }) }) - return redactReviewSecrets(acceptedResult) + return acceptedResult } From 10c32a2eb36d622017df818cfe39c22bb534c184 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 23 Jun 2026 15:39:58 -0600 Subject: [PATCH 090/284] fix(yjs): apply snapshots before database persistence Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../sessions/[sessionId]/snapshot/route.ts | 31 +++-- apps/tradinggoose/lib/workflows/db-helpers.ts | 11 +- .../lib/yjs/server/apply-entity-state.ts | 27 ++--- .../yjs/server/apply-workflow-state.test.ts | 93 ++++++++------- .../lib/yjs/server/apply-workflow-state.ts | 110 ++++++++---------- .../tradinggoose/lib/yjs/use-entity-fields.ts | 4 + 6 files changed, 145 insertions(+), 131 deletions(-) diff --git a/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts b/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts index de8758b4d..844002b10 100644 --- a/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts +++ b/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts @@ -1,4 +1,5 @@ import { type NextRequest, NextResponse } from 'next/server' +import * as Y from 'yjs' import { getSession } from '@/lib/auth' import { buildReviewTargetDescriptorFromEnvelope, @@ -6,6 +7,7 @@ import { } from '@/lib/copilot/review-sessions/identity' import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' import { mcpService } from '@/lib/mcp/service' +import { getEntityFields } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { applySavedEntityPersistedState, @@ -14,7 +16,6 @@ import { import { ReviewTargetBootstrapError, readBootstrappedReviewTargetSnapshot, - readBootstrappedSavedEntityFields, } from '@/lib/yjs/server/bootstrap-review-target' export const dynamic = 'force-dynamic' @@ -113,12 +114,28 @@ export async function POST( const entityKind: SavedEntityKind = descriptor.entityKind try { - const fields = await readBootstrappedSavedEntityFields( - entityKind, - descriptor.entityId, - descriptor.workspaceId - ) - await applySavedEntityPersistedState(entityKind, descriptor.entityId, fields) + const { updateBase64 } = (await request.json().catch(() => ({}))) as { + updateBase64?: unknown + } + if (typeof updateBase64 !== 'string' || !updateBase64) { + return NextResponse.json({ error: 'updateBase64 is required' }, { status: 400 }) + } + + const snapshot = await readBootstrappedReviewTargetSnapshot(descriptor) + const doc = new Y.Doc() + try { + if (snapshot.snapshotBase64) { + Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) + } + Y.applyUpdate(doc, Buffer.from(updateBase64, 'base64')) + await applySavedEntityPersistedState( + entityKind, + descriptor.entityId, + getEntityFields(doc, entityKind) + ) + } finally { + doc.destroy() + } if (descriptor.entityKind === 'mcp_server') { mcpService.clearCache(descriptor.workspaceId) } diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index ce8bfaffd..20c07e6e3 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -29,6 +29,12 @@ import { SUBFLOW_TYPES } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowDBHelpers') +type WorkflowDbTransaction = Parameters[0]>[0] +type WorkflowNormalizedCommit = ( + tx: WorkflowDbTransaction, + normalizedState: WorkflowState +) => Promise + const resolveLockedFromBlockData = (data: unknown): boolean => { if (!data || typeof data !== 'object' || Array.isArray(data)) { return false @@ -758,7 +764,8 @@ export async function loadWorkflowFromNormalizedTables( */ export async function saveWorkflowToNormalizedTables( workflowId: string, - state: WorkflowState + state: WorkflowState, + commit?: WorkflowNormalizedCommit ): Promise<{ success: boolean; error?: string; normalizedState?: WorkflowState }> { try { const stateWithUniqueBlockIds = await ensureUniqueBlockIds(workflowId, state) @@ -914,6 +921,8 @@ export async function saveWorkflowToNormalizedTables( if (subflowInserts.length > 0) { await tx.insert(workflowSubflows).values(subflowInserts) } + + await commit?.(tx, normalizedState) }) return { success: true, normalizedState } diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index d316ec6c5..99d4303d3 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -7,13 +7,13 @@ import { skill, } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' -import * as Y from 'yjs' import { normalizeEntityFields } from '@/lib/copilot/entity-documents' import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' -import { seedEntitySession } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' -import { applyEntityStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' -import { storeState } from '@/socket-server/yjs/persistence' +import { + applyEntityStateInSocketServer, + deleteYjsSessionInSocketServer, +} from '@/lib/yjs/server/snapshot-bridge' export class SavedEntityPersistenceError extends Error { constructor( @@ -141,17 +141,7 @@ export async function applySavedEntityDraftState( entityId: string, fields: Record ): Promise { - try { - await applyEntityStateInSocketServer(entityId, entityKind, fields) - } catch { - const doc = new Y.Doc() - try { - seedEntitySession(doc, { entityKind, payload: fields }) - await storeState(entityId, Y.encodeStateAsUpdate(doc)) - } finally { - doc.destroy() - } - } + await applyEntityStateInSocketServer(entityId, entityKind, fields) } export async function applySavedEntityPersistedState( @@ -160,6 +150,11 @@ export async function applySavedEntityPersistedState( fields: Record ): Promise { const normalizedFields = normalizeSavedEntityFields(entityKind, fields) - await persistSavedEntityState(entityKind, entityId, normalizedFields) await applySavedEntityDraftState(entityKind, entityId, normalizedFields) + try { + await persistSavedEntityState(entityKind, entityId, normalizedFields) + } catch (error) { + await deleteYjsSessionInSocketServer(entityId) + throw error + } } diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts index 3970b122c..9500ac0ec 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts @@ -3,16 +3,14 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import * as Y from 'yjs' const { mockApplyWorkflowStateInSocketServer, mockDbUpdate, mockEnsureUniqueBlockIds, mockEnsureUniqueEdgeIds, - mockGetState, mockSaveWorkflowToNormalizedTables, - mockStoreCanonicalState, + mockDeleteYjsSessionInSocketServer, mockUpdateReturning, mockUpdateSet, mockUpdateWhere, @@ -22,9 +20,8 @@ const { mockDbUpdate: vi.fn(), mockEnsureUniqueBlockIds: vi.fn(), mockEnsureUniqueEdgeIds: vi.fn(), - mockGetState: vi.fn(), mockSaveWorkflowToNormalizedTables: vi.fn(), - mockStoreCanonicalState: vi.fn(), + mockDeleteYjsSessionInSocketServer: vi.fn(), mockUpdateReturning: vi.fn(), mockUpdateSet: vi.fn(), mockUpdateWhere: vi.fn(), @@ -52,12 +49,10 @@ vi.mock('@/lib/workflows/db-helpers', () => ({ vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ applyWorkflowStateInSocketServer: mockApplyWorkflowStateInSocketServer, + deleteYjsSessionInSocketServer: mockDeleteYjsSessionInSocketServer, })) -vi.mock('@/socket-server/yjs/persistence', () => ({ - getState: mockGetState, - storeCanonicalState: mockStoreCanonicalState, -})) +const emptyWorkflowState = { blocks: {}, edges: [], loops: {}, parallels: {} } describe('applyWorkflowState', () => { beforeEach(() => { @@ -65,17 +60,18 @@ describe('applyWorkflowState', () => { mockApplyWorkflowStateInSocketServer.mockResolvedValue(undefined) mockEnsureUniqueBlockIds.mockImplementation(async (_workflowId, state) => state) mockEnsureUniqueEdgeIds.mockImplementation(async (_workflowId, state) => state) - mockGetState.mockResolvedValue(null) - mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) - mockStoreCanonicalState.mockResolvedValue(undefined) + mockSaveWorkflowToNormalizedTables.mockImplementation(async (_workflowId, state, commit) => { + await commit?.({ update: mockDbUpdate }, state) + return { success: true } + }) + mockDeleteYjsSessionInSocketServer.mockResolvedValue(undefined) mockUpdateReturning.mockResolvedValue([{ id: 'workflow-1' }]) mockUpdateWhere.mockReturnValue({ returning: mockUpdateReturning }) mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }) mockDbUpdate.mockReturnValue({ set: mockUpdateSet }) }) - it('publishes the normalized workflow state to Yjs and DB while preserving existing variables', async () => { - mockApplyWorkflowStateInSocketServer.mockRejectedValueOnce(new TypeError('fetch failed')) + it('publishes the normalized workflow state to Yjs before committing DB changes', async () => { mockEnsureUniqueBlockIds.mockImplementationOnce(async () => ({ blocks: { 'normalized-block': { @@ -94,18 +90,6 @@ describe('applyWorkflowState', () => { })) const { applyWorkflowState } = await import('./apply-workflow-state') - const { extractPersistedStateFromDoc, getMetadataMap, setVariables } = await import( - '@/lib/yjs/workflow-session' - ) - - const existingDoc = new Y.Doc() - setVariables( - existingDoc, - { var1: { id: 'var1', workflowId: 'workflow-1', name: 'token', value: 'secret' } }, - 'test' - ) - mockGetState.mockResolvedValueOnce(Y.encodeStateAsUpdate(existingDoc)) - existingDoc.destroy() await applyWorkflowState( 'workflow-1', @@ -129,25 +113,16 @@ describe('applyWorkflowState', () => { 'Workflow Name' ) - expect(mockStoreCanonicalState).toHaveBeenCalledOnce() - expect(mockStoreCanonicalState.mock.calls[0][0]).toBe('workflow-1') - - const doc = new Y.Doc() - try { - Y.applyUpdate(doc, mockStoreCanonicalState.mock.calls[0][1] as Uint8Array) - expect(extractPersistedStateFromDoc(doc)).toMatchObject({ + expect(mockApplyWorkflowStateInSocketServer).toHaveBeenCalledWith( + 'workflow-1', + expect.objectContaining({ blocks: { 'normalized-block': expect.objectContaining({ id: 'normalized-block' }), }, - variables: { - var1: expect.objectContaining({ value: 'secret' }), - }, - }) - expect(extractPersistedStateFromDoc(doc).blocks).not.toHaveProperty('input-block') - expect(getMetadataMap(doc).get('entityName')).toBe('Workflow Name') - } finally { - doc.destroy() - } + }), + undefined, + 'Workflow Name' + ) expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledOnce() expect(mockSaveWorkflowToNormalizedTables.mock.calls[0][1]).toMatchObject({ @@ -155,8 +130,12 @@ describe('applyWorkflowState', () => { 'normalized-block': expect.objectContaining({ id: 'normalized-block' }), }, }) + expect(mockSaveWorkflowToNormalizedTables.mock.calls[0][2]).toEqual(expect.any(Function)) + expect(mockApplyWorkflowStateInSocketServer.mock.invocationCallOrder[0]).toBeLessThan( + mockSaveWorkflowToNormalizedTables.mock.invocationCallOrder[0] + ) expect(mockSaveWorkflowToNormalizedTables.mock.invocationCallOrder[0]).toBeLessThan( - mockStoreCanonicalState.mock.invocationCallOrder[0] + mockDbUpdate.mock.invocationCallOrder[0] ) expect(mockUpdateSet).toHaveBeenCalledWith( expect.not.objectContaining({ @@ -164,4 +143,32 @@ describe('applyWorkflowState', () => { }) ) }) + + it('does not commit workflow DB changes when Yjs persistence fails', async () => { + mockApplyWorkflowStateInSocketServer.mockRejectedValueOnce(new TypeError('fetch failed')) + + const { applyWorkflowState } = await import('./apply-workflow-state') + + await expect(applyWorkflowState('workflow-1', emptyWorkflowState)).rejects.toThrow( + 'fetch failed' + ) + + expect(mockSaveWorkflowToNormalizedTables).not.toHaveBeenCalled() + expect(mockDbUpdate).not.toHaveBeenCalled() + expect(mockDeleteYjsSessionInSocketServer).not.toHaveBeenCalled() + }) + + it('clears workflow Yjs state when DB persistence fails after Yjs apply', async () => { + mockSaveWorkflowToNormalizedTables.mockResolvedValueOnce({ + success: false, + error: 'db failed', + }) + + const { applyWorkflowState } = await import('./apply-workflow-state') + + await expect(applyWorkflowState('workflow-1', emptyWorkflowState)).rejects.toThrow('db failed') + + expect(mockDeleteYjsSessionInSocketServer).toHaveBeenCalledWith('workflow-1') + expect(mockDbUpdate).not.toHaveBeenCalled() + }) }) diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index 107e17511..2276a69da 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -1,51 +1,18 @@ import { db, workflow } from '@tradinggoose/db' import { eq } from 'drizzle-orm' -import * as Y from 'yjs' import { ensureUniqueBlockIds, ensureUniqueEdgeIds, saveWorkflowToNormalizedTables, } from '@/lib/workflows/db-helpers' -import { applyWorkflowStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { + applyWorkflowStateInSocketServer, + deleteYjsSessionInSocketServer, +} from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot, - replaceWorkflowDocumentState, type WorkflowSnapshot, } from '@/lib/yjs/workflow-session' -import { getState, storeCanonicalState } from '@/socket-server/yjs/persistence' - -async function storeWorkflowStateDirectly( - workflowId: string, - workflowState: WorkflowSnapshot, - variables?: Record, - entityName?: string -) { - const doc = new Y.Doc() - try { - const existingState = await getState(workflowId) - if (existingState) { - Y.applyUpdate(doc, existingState) - } - - replaceWorkflowDocumentState(doc, workflowState, variables, entityName) - await storeCanonicalState(workflowId, Y.encodeStateAsUpdate(doc)) - } finally { - doc.destroy() - } -} - -async function applyWorkflowStateToYjs( - workflowId: string, - workflowState: WorkflowSnapshot, - variables?: Record, - entityName?: string -) { - try { - await applyWorkflowStateInSocketServer(workflowId, workflowState, variables, entityName) - } catch { - await storeWorkflowStateDirectly(workflowId, workflowState, variables, entityName) - } -} export async function applyWorkflowState( workflowId: string, @@ -72,26 +39,35 @@ export async function applyWorkflowState( : {}), }) - const saveResult = await saveWorkflowToNormalizedTables(workflowId, storedWorkflowState) - if (!saveResult.success) { - throw new Error(saveResult.error || 'Failed to materialize workflow state') - } + await applyWorkflowStateInSocketServer(workflowId, storedWorkflowState, variables, entityName) - const [updatedWorkflow] = await db - .update(workflow) - .set({ - lastSynced: syncedAt, - updatedAt: syncedAt, - ...(variables === undefined ? {} : { variables }), - }) - .where(eq(workflow.id, workflowId)) - .returning({ id: workflow.id }) + try { + const saveResult = await saveWorkflowToNormalizedTables( + workflowId, + storedWorkflowState, + async (tx) => { + const [updatedWorkflow] = await tx + .update(workflow) + .set({ + lastSynced: syncedAt, + updatedAt: syncedAt, + ...(variables === undefined ? {} : { variables }), + }) + .where(eq(workflow.id, workflowId)) + .returning({ id: workflow.id }) - if (!updatedWorkflow) { - throw new Error('Workflow not found') + if (!updatedWorkflow) { + throw new Error('Workflow not found') + } + } + ) + if (!saveResult.success) { + throw new Error(saveResult.error || 'Failed to materialize workflow state') + } + } catch (error) { + await deleteYjsSessionInSocketServer(workflowId) + throw error } - - await applyWorkflowStateToYjs(workflowId, storedWorkflowState, variables, entityName) } export async function applyWorkflowEntityName( @@ -101,16 +77,22 @@ export async function applyWorkflowEntityName( entityName: string, fields: Partial = {} ): Promise { - const [updatedWorkflow] = await db - .update(workflow) - .set({ ...fields, name: entityName, updatedAt: fields.updatedAt ?? new Date() }) - .where(eq(workflow.id, workflowId)) - .returning() + await applyWorkflowStateInSocketServer(workflowId, workflowState, variables, entityName) - if (!updatedWorkflow) { - throw new Error('Workflow not found') - } + try { + const [updatedWorkflow] = await db + .update(workflow) + .set({ ...fields, name: entityName, updatedAt: fields.updatedAt ?? new Date() }) + .where(eq(workflow.id, workflowId)) + .returning() - await applyWorkflowStateToYjs(workflowId, workflowState, variables, entityName) - return updatedWorkflow + if (!updatedWorkflow) { + throw new Error('Workflow not found') + } + + return updatedWorkflow + } catch (error) { + await deleteYjsSessionInSocketServer(workflowId) + throw error + } } diff --git a/apps/tradinggoose/lib/yjs/use-entity-fields.ts b/apps/tradinggoose/lib/yjs/use-entity-fields.ts index 8e47fa446..e17bb14f0 100644 --- a/apps/tradinggoose/lib/yjs/use-entity-fields.ts +++ b/apps/tradinggoose/lib/yjs/use-entity-fields.ts @@ -95,10 +95,14 @@ export function useSavedEntityYjsSession( ...serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor)), accessMode: 'write', }) + const update = Y.encodeStateAsUpdate(activeState.result.doc) + const updateBase64 = btoa(Array.from(update, (byte) => String.fromCharCode(byte)).join('')) const response = await fetch( `/api/yjs/sessions/${encodeURIComponent(descriptor.yjsSessionId)}/snapshot?${params}`, { method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ updateBase64 }), } ) if (!response.ok) { From 8fedcb95a1985052a411d7df448a54f813790c4a Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 23 Jun 2026 15:45:31 -0600 Subject: [PATCH 091/284] test(copilot): update test files Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/function/execute/route.test.ts | 200 ++++++------------ .../user/read-environment-variables.test.ts | 3 + 2 files changed, 63 insertions(+), 140 deletions(-) diff --git a/apps/tradinggoose/app/api/function/execute/route.test.ts b/apps/tradinggoose/app/api/function/execute/route.test.ts index f297e7284..355fc1db3 100644 --- a/apps/tradinggoose/app/api/function/execute/route.test.ts +++ b/apps/tradinggoose/app/api/function/execute/route.test.ts @@ -6,11 +6,7 @@ import { createMockRequest } from '@/app/api/__test-utils__/utils' const checkInternalAuthMock = vi.fn() const checkWorkspaceAccessMock = vi.fn() -const checkServerSideUsageLimitsMock = vi.fn() -const executeFunctionWithRuntimeGateMock = vi.fn() -const listCustomIndicatorRuntimeEntriesMock = vi.fn() -const isBillingEnabledForRuntimeMock = vi.fn() -const accrueUserUsageCostMock = vi.fn() +const executeFunctionRequestMock = vi.fn() const readWorkflowByIdMock = vi.fn() const loggerMock = { info: vi.fn(), @@ -37,25 +33,18 @@ describe('Function Execute API Route', () => { success: true, userId: 'user-1', }) - checkServerSideUsageLimitsMock.mockResolvedValue({ - isExceeded: false, - currentUsage: 0, - limit: 100, - }) checkWorkspaceAccessMock.mockResolvedValue({ hasAccess: true, canWrite: true }) - executeFunctionWithRuntimeGateMock.mockResolvedValue({ - engine: 'local_vm', - success: true, - result: 'ok', - stdout: 'stdout', - executionTime: 2400, - userCodeStartLine: 3, + executeFunctionRequestMock.mockResolvedValue({ + statusCode: 200, + body: { + success: true, + output: { + result: 'ok', + stdout: 'stdout', + executionTime: 2400, + }, + }, }) - listCustomIndicatorRuntimeEntriesMock.mockResolvedValue([ - { id: 'indicator-1', pineCode: 'indicator("Custom Indicator")' }, - ]) - isBillingEnabledForRuntimeMock.mockResolvedValue(false) - accrueUserUsageCostMock.mockResolvedValue(true) vi.doMock('@/lib/auth/hybrid', () => ({ checkInternalAuth: checkInternalAuthMock, @@ -66,74 +55,18 @@ describe('Function Execute API Route', () => { vi.doMock('@/lib/utils', () => ({ generateRequestId: vi.fn(() => 'request-1'), })) - vi.doMock('@/lib/billing', () => ({ - checkServerSideUsageLimits: checkServerSideUsageLimitsMock, - })) - vi.doMock('@/lib/billing/settings', () => ({ - getResolvedBillingSettings: vi.fn().mockResolvedValue({ - functionExecutionChargeUsd: 0.25, - }), - isBillingEnabledForRuntime: isBillingEnabledForRuntimeMock, - })) - vi.doMock('@/lib/billing/tiers', () => ({ - getTierFunctionExecutionMultiplier: vi.fn(() => 0.5), - })) - vi.doMock('@/lib/billing/workspace-billing', () => ({ - resolveWorkflowBillingContext: vi.fn().mockResolvedValue({ - tier: { id: 'tier-1' }, - }), - resolveWorkspaceBillingContext: vi.fn().mockResolvedValue({ - tier: { id: 'tier-1' }, - }), - })) - vi.doMock('@/lib/billing/usage-accrual', () => ({ - accrueUserUsageCost: accrueUserUsageCostMock, - })) - vi.doMock('@/app/api/function/code-resolution', () => ({ - resolveCodeVariables: vi.fn((code: string) => ({ - resolvedCode: code, - contextVariables: {}, - })), - })) - vi.doMock('@/app/api/function/typescript-utils', () => ({ - findFunctionPineDisallowedReason: vi.fn(async () => null), - transpileTypeScriptCode: vi.fn(async (code: string) => code), - })) - vi.doMock('@/app/api/function/error-formatting', () => ({ - createUserFriendlyErrorMessage: vi.fn( - (error: { message?: string }) => error.message ?? 'Function execution failed' - ), - extractEnhancedError: vi.fn((error: Error) => ({ - message: error.message, - name: error.name, - stack: error.stack, - })), - })) - vi.doMock('@/app/api/function/e2b-execution', () => ({ - executeFunctionWithRuntimeGate: executeFunctionWithRuntimeGateMock, - })) - vi.doMock('@/lib/indicators/custom/operations', () => ({ - listCustomIndicatorRuntimeEntries: listCustomIndicatorRuntimeEntriesMock, - })) vi.doMock('@/lib/permissions/utils', () => ({ checkWorkspaceAccess: checkWorkspaceAccessMock, })) + vi.doMock('@/lib/function/execution', () => ({ + executeFunctionRequest: executeFunctionRequestMock, + })) vi.doMock('@/lib/workflows/utils', () => ({ readWorkflowById: readWorkflowByIdMock.mockResolvedValue({ id: 'workflow-1', workspaceId: 'workspace-1', }), })) - vi.doMock('@/lib/execution/local-saturation-limit', () => ({ - getLocalVmSaturationLimitMessage: vi.fn(() => 'Local VM saturated'), - isLocalVmSaturationLimitError: vi.fn((error: unknown) => - Boolean( - error && - typeof error === 'object' && - (error as { code?: string }).code === 'LOCAL_VM_SATURATION_LIMIT' - ) - ), - })) }) it('rejects requests without internal auth', async () => { @@ -146,6 +79,7 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(401) expect(payload.success).toBe(false) expect(payload.error).toBe('Unauthorized') + expect(executeFunctionRequestMock).not.toHaveBeenCalled() }) it('accepts exactly one execution scope', async () => { @@ -159,6 +93,16 @@ describe('Function Execute API Route', () => { expect(workspaceResponse.status).toBe(200) expect(readWorkflowByIdMock).not.toHaveBeenCalled() + expect(executeFunctionRequestMock).toHaveBeenCalledOnce() + expect(executeFunctionRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'return "ok"', + workflowId: undefined, + workspaceId: 'workspace-1', + userId: 'user-1', + requestId: 'request-1', + }) + ) const mixedScopeResponse = await POST( createMockRequest('POST', { @@ -173,7 +117,7 @@ describe('Function Execute API Route', () => { expect(mixedScopePayload.error).toBe( 'Function execution accepts either workflow or workspace context, not both' ) - expect(executeFunctionWithRuntimeGateMock).toHaveBeenCalledOnce() + expect(executeFunctionRequestMock).toHaveBeenCalledOnce() }) it('executes under workflow context', async () => { @@ -184,21 +128,17 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(200) expect(payload.success).toBe(true) expect(payload.output.result).toBe('ok') - expect(checkServerSideUsageLimitsMock).toHaveBeenCalledWith({ - userId: 'user-1', - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - }) expect(checkWorkspaceAccessMock).toHaveBeenCalledWith('workspace-1', 'user-1') - expect(listCustomIndicatorRuntimeEntriesMock).toHaveBeenCalledWith('workspace-1') - expect(executeFunctionWithRuntimeGateMock).toHaveBeenCalledWith( + expect(executeFunctionRequestMock).toHaveBeenCalledWith( expect.objectContaining({ - indicatorRuntimeManifest: expect.objectContaining({ - indicators: expect.arrayContaining([expect.objectContaining({ id: 'indicator-1' })]), - }), + code: 'return "ok"', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + userId: 'user-1', + requestId: 'request-1', }) ) - expect(executeFunctionWithRuntimeGateMock).toHaveBeenCalledOnce() + expect(executeFunctionRequestMock).toHaveBeenCalledOnce() }) it('rejects workflow requests when workspace access is denied', async () => { @@ -211,7 +151,7 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(403) expect(payload.success).toBe(false) expect(payload.error).toBe('Access denied') - expect(executeFunctionWithRuntimeGateMock).not.toHaveBeenCalled() + expect(executeFunctionRequestMock).not.toHaveBeenCalled() }) it('rejects workspace-scoped function execution for read-only workspace members', async () => { @@ -229,15 +169,21 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(403) expect(payload.success).toBe(false) expect(payload.error).toBe('Access denied') - expect(executeFunctionWithRuntimeGateMock).not.toHaveBeenCalled() + expect(executeFunctionRequestMock).not.toHaveBeenCalled() }) - it('blocks before runtime when workflow usage is exceeded', async () => { - checkServerSideUsageLimitsMock.mockResolvedValueOnce({ - isExceeded: true, - currentUsage: 101, - limit: 100, - message: 'Usage limit exceeded', + it('forwards execution service failures', async () => { + executeFunctionRequestMock.mockResolvedValueOnce({ + statusCode: 402, + body: { + success: false, + error: 'Usage limit exceeded', + output: { + result: null, + stdout: '', + executionTime: 10, + }, + }, }) const { POST } = await import('@/app/api/function/execute/route') @@ -247,47 +193,21 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(402) expect(payload.success).toBe(false) expect(payload.error).toBe('Usage limit exceeded') - expect(executeFunctionWithRuntimeGateMock).not.toHaveBeenCalled() - }) - - it('accrues workflow-scoped function execution cost after runtime finishes', async () => { - isBillingEnabledForRuntimeMock.mockResolvedValueOnce(true) - - const { POST } = await import('@/app/api/function/execute/route') - const response = await POST(createFunctionRequest()) - - expect(response.status).toBe(200) - expect(accrueUserUsageCostMock).toHaveBeenCalledWith({ - userId: 'user-1', - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - cost: 0.3, - reason: 'function_execution', - }) - }) - - it('keeps runtime success when post-run billing accrual fails', async () => { - isBillingEnabledForRuntimeMock.mockResolvedValueOnce(true) - accrueUserUsageCostMock.mockRejectedValueOnce(new Error('billing unavailable')) - - const { POST } = await import('@/app/api/function/execute/route') - const response = await POST(createFunctionRequest()) - const payload = await response.json() - - expect(response.status).toBe(200) - expect(payload.success).toBe(true) - expect(payload.output.result).toBe('ok') + expect(executeFunctionRequestMock).toHaveBeenCalledOnce() }) - it('returns runtime failures without retrying through pending execution', async () => { - executeFunctionWithRuntimeGateMock.mockResolvedValueOnce({ - engine: 'local_vm', - success: false, - result: null, - stdout: 'failure stdout', - executionTime: 500, - error: 'Boom', - userCodeStartLine: 3, + it('returns runtime failures from the execution service', async () => { + executeFunctionRequestMock.mockResolvedValueOnce({ + statusCode: 500, + body: { + success: false, + output: { + result: null, + stdout: 'failure stdout', + executionTime: 500, + }, + error: 'Boom', + }, }) const { POST } = await import('@/app/api/function/execute/route') diff --git a/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.test.ts b/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.test.ts index b3804bc6e..12b122c72 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.test.ts @@ -50,6 +50,9 @@ describe('readEnvironmentVariablesServerTool', () => { ) ).resolves.toEqual({ variableNames: ['PERSONAL_KEY', 'WORKSPACE_KEY'], + personalVariableNames: ['PERSONAL_KEY'], + workspaceVariableNames: ['WORKSPACE_KEY'], + conflicts: [], count: 2, }) From ce2b89a9575c76cde4d13bb5022b79fe62d16bf2 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 23 Jun 2026 16:43:51 -0600 Subject: [PATCH 092/284] fix(copilot): normalize entity document fields Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/copilot/entity-documents.ts | 10 ++-- .../tools/server/entities/mcp-server.ts | 49 +++++-------------- 2 files changed, 16 insertions(+), 43 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/entity-documents.ts b/apps/tradinggoose/lib/copilot/entity-documents.ts index 243dd3d9b..26790ca72 100644 --- a/apps/tradinggoose/lib/copilot/entity-documents.ts +++ b/apps/tradinggoose/lib/copilot/entity-documents.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' -import { inferInputMetaFromPineCode } from '@/lib/indicators/input-meta' +import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' import { validateMcpServerUrl } from '@/lib/mcp/url-validator' export const SKILL_DOCUMENT_FORMAT = 'tg-skill-document-v1' as const export const CUSTOM_TOOL_DOCUMENT_FORMAT = 'tg-custom-tool-document-v1' as const @@ -131,13 +131,13 @@ export function normalizeEntityFields( return { name: typeof source.name === 'string' ? source.name.trim() : '', pineCode, - inputMeta: inferInputMetaFromPineCode(pineCode) ?? null, + inputMeta: normalizeInputMetaMap(source.inputMeta) ?? null, } } case 'mcp_server': { const rawUrl = typeof source.url === 'string' ? source.url.trim() : '' - const validation = rawUrl ? validateMcpServerUrl(rawUrl) : null - if (validation && !validation.isValid) { + const validation = validateMcpServerUrl(rawUrl) + if (!validation.isValid) { throw new Error(`Invalid MCP server URL: ${validation.error}`) } @@ -150,7 +150,7 @@ export function normalizeEntityFields( source.transport === 'streamable-http' ? source.transport : 'http', - url: validation?.normalizedUrl ?? rawUrl, + url: validation.normalizedUrl ?? rawUrl, headers: source.headers && typeof source.headers === 'object' && !Array.isArray(source.headers) ? Object.fromEntries( diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts index d77457a2a..9bd039f53 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -1,12 +1,11 @@ import { db } from '@tradinggoose/db' import { mcpServers } from '@tradinggoose/db/schema' import { and, eq, isNull } from 'drizzle-orm' -import { ENTITY_SECRET_PLACEHOLDER } from '@/lib/copilot/entity-documents' +import { ENTITY_SECRET_PLACEHOLDER, normalizeEntityFields } from '@/lib/copilot/entity-documents' import { ENTITY_KIND_MCP_SERVER } from '@/lib/copilot/review-sessions/types' import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' import { mcpService } from '@/lib/mcp/service' import type { McpTransport } from '@/lib/mcp/types' -import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { applySavedEntityDocument, @@ -22,10 +21,6 @@ import { verifyWorkspaceContext, } from './shared' -function isMcpTransport(value: unknown): value is McpTransport { - return value === 'http' || value === 'sse' || value === 'streamable-http' -} - function normalizeStringRecord(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {} @@ -39,32 +34,10 @@ function normalizeStringRecord(value: unknown): Record { ) } -function normalizeMcpServerFields(fields: Record): Record { - const transport = isMcpTransport(fields.transport) ? fields.transport : 'http' - const rawUrl = typeof fields.url === 'string' ? fields.url.trim() : '' - let normalizedUrl = rawUrl - - if (rawUrl) { - const validation = validateMcpServerUrl(rawUrl) - if (!validation.isValid) { - throw new Error(`Invalid MCP server URL: ${validation.error}`) - } - normalizedUrl = validation.normalizedUrl ?? rawUrl - } - - return { - name: typeof fields.name === 'string' ? fields.name : '', - description: typeof fields.description === 'string' ? fields.description : '', - transport, - url: normalizedUrl, - headers: normalizeStringRecord(fields.headers), - command: typeof fields.command === 'string' ? fields.command : '', - args: Array.isArray(fields.args) ? fields.args.map(String) : [], - env: normalizeStringRecord(fields.env), - timeout: typeof fields.timeout === 'number' ? fields.timeout : 30000, - retries: typeof fields.retries === 'number' ? fields.retries : 3, - enabled: typeof fields.enabled === 'boolean' ? fields.enabled : true, - } +function normalizeMcpServerDocumentFields( + fields: Record +): Record { + return normalizeEntityFields(ENTITY_KIND_MCP_SERVER, fields) } function preserveSecretRecordPlaceholders( @@ -104,8 +77,8 @@ function preserveMcpServerSecretPlaceholders( nextFields: Record, currentFields: Record ): Record { - const normalizedNext = normalizeMcpServerFields(nextFields) - const normalizedCurrent = normalizeMcpServerFields(currentFields) + const normalizedNext = normalizeMcpServerDocumentFields(nextFields) + const normalizedCurrent = normalizeMcpServerDocumentFields(currentFields) return { ...normalizedNext, @@ -123,7 +96,7 @@ function preserveMcpServerSecretPlaceholders( } function prepareNewMcpServerFields(fields: Record): Record { - const normalized = normalizeMcpServerFields(fields) + const normalized = normalizeMcpServerDocumentFields(fields) assertNoSecretPlaceholders(normalized) return normalized } @@ -145,7 +118,7 @@ async function createMcpServerEntity( ): Promise { const { userId, workspaceId } = await verifyWorkspaceContext(context, 'write') const entityId = crypto.randomUUID() - const normalized = normalizeMcpServerFields(fields) + const normalized = normalizeMcpServerDocumentFields(fields) const [row] = await db .insert(mcpServers) @@ -268,7 +241,7 @@ export const editMcpServerServerTool: EntityServerTool = { args, context, applyMcpServerDocument, - normalizeMcpServerFields + normalizeMcpServerDocumentFields ) }, } @@ -282,7 +255,7 @@ export const renameMcpServerServerTool: EntityServerTool = { args, context, applyMcpServerDocument, - normalizeMcpServerFields + normalizeMcpServerDocumentFields ) }, } From 479e5851c406676d99f95a74869bb463fc90f86c Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 23 Jun 2026 16:44:13 -0600 Subject: [PATCH 093/284] test(copilot): cover entity document normalization Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../tools/server/entities/shared.test.ts | 99 ++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts index 9d6cdf0f7..1d7a83c94 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { MCP_SERVER_DOCUMENT_FORMAT, SKILL_DOCUMENT_FORMAT } from '@/lib/copilot/entity-documents' +import { + INDICATOR_DOCUMENT_FORMAT, + MCP_SERVER_DOCUMENT_FORMAT, + SKILL_DOCUMENT_FORMAT, +} from '@/lib/copilot/entity-documents' import { hashServerToolReviewBase } from '@/lib/copilot/tools/server/base-tool' import { buildDocumentEnvelope, @@ -112,6 +116,99 @@ describe('entity document mutation helpers', () => { expect(mockApplySavedEntityPersistedState).toHaveBeenCalledWith('skill', 'skill-1', nextFields) }) + it('preserves indicator input metadata when applying document updates', async () => { + const inputMeta = { + Mode: { + title: 'Mode', + type: 'string', + defval: 'fast', + options: ['fast', 'slow'], + value: 'slow', + }, + } + + await executeUpdateEntityDocumentMutation( + 'indicator', + 'edit_indicator', + { + entityId: 'indicator-1', + documentFormat: INDICATOR_DOCUMENT_FORMAT, + entityDocument: JSON.stringify({ + name: 'Updated Indicator', + pineCode: "const mode = input.string('fast', 'Mode')", + inputMeta, + }), + }, + { userId: 'user-1', accessLevel: 'full' } + ) + + expect(mockApplySavedEntityPersistedState).toHaveBeenCalledWith('indicator', 'indicator-1', { + name: 'Updated Indicator', + pineCode: "const mode = input.string('fast', 'Mode')", + inputMeta, + }) + }) + + it('rejects MCP server create documents without a URL', async () => { + const create = vi.fn() + + await expect( + executeCreateEntityDocumentMutation( + 'mcp_server', + { + workspaceId: 'workspace-1', + documentFormat: MCP_SERVER_DOCUMENT_FORMAT, + entityDocument: JSON.stringify({ + name: 'Missing URL MCP', + description: '', + transport: 'http', + url: '', + headers: {}, + command: '', + args: [], + env: {}, + timeout: 30000, + retries: 3, + enabled: true, + }), + }, + { userId: 'user-1', accessLevel: 'full' }, + create + ) + ).rejects.toThrow('Invalid MCP server URL: URL is required and must be a string') + + expect(create).not.toHaveBeenCalled() + }) + + it('rejects MCP server edit documents without a URL before persistence', async () => { + await expect( + executeUpdateEntityDocumentMutation( + 'mcp_server', + 'edit_mcp_server', + { + entityId: 'mcp-1', + documentFormat: MCP_SERVER_DOCUMENT_FORMAT, + entityDocument: JSON.stringify({ + name: 'Missing URL MCP', + description: '', + transport: 'streamable-http', + url: ' ', + headers: {}, + command: '', + args: [], + env: {}, + timeout: 30000, + retries: 3, + enabled: true, + }), + }, + { userId: 'user-1', accessLevel: 'full' } + ) + ).rejects.toThrow('Invalid MCP server URL: URL is required and must be a string') + + expect(mockApplySavedEntityPersistedState).not.toHaveBeenCalled() + }) + it('keeps Studio create mutations in review mode', async () => { const result = await executeCreateEntityDocumentMutation( 'skill', From 8f957ef6b447f4dc188daea8653ff0e9d0215936 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 23 Jun 2026 17:47:08 -0600 Subject: [PATCH 094/284] fix(mcp): require urls for URL transports Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/mcp/servers/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 9031bb384..908d1747d 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -77,8 +77,8 @@ export const POST = withMcpAuth('write')( workspaceId, }) - if (isUrlBasedTransport(body.transport as McpTransport) && body.url) { - const urlValidation = validateMcpServerUrl(body.url) + if (isUrlBasedTransport(body.transport as McpTransport)) { + const urlValidation = validateMcpServerUrl(body.url ?? '') if (!urlValidation.isValid) { return createMcpErrorResponse( new Error(`Invalid MCP server URL: ${urlValidation.error}`), From d812e46a6c5f7e653959ed021875f786b00c6344 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 23 Jun 2026 17:47:29 -0600 Subject: [PATCH 095/284] feat(copilot): support personal scoped credential reads Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../tradinggoose/app/api/copilot/mcp/route.ts | 2 +- apps/tradinggoose/lib/copilot/registry.ts | 16 ++++-- .../lib/copilot/tool-prompt-metadata.ts | 6 +-- .../lib/copilot/tools/server/router.test.ts | 52 ++++++++++++++----- .../tools/server/user/read-credentials.ts | 51 ++++++++++-------- .../user/read-environment-variables.test.ts | 28 +++++++++- .../server/user/read-environment-variables.ts | 33 ++++++------ .../server/user/read-oauth-credentials.ts | 32 +++++++----- 8 files changed, 143 insertions(+), 77 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index b630a1810..1e83a8f72 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -97,7 +97,7 @@ async function buildInstructions(userId: string) { return [ 'TradingGoose Copilot MCP exposes the same server-side Copilot tools used by TradingGoose Studio.', 'Local MCP config stores only this user auth token. Do not store workspaceId, entityId, or entity targets in the local MCP config.', - 'Use entityId for read/edit/rename tools that target an existing entity. Use workspaceId for workspace-scoped tools, including list/create, credential, OAuth, Google Drive, workspace account reads, and workspace-scoped environment writes. Environment writes require scope="personal" or scope="workspace".', + 'Use entityId for read/edit/rename tools that target an existing entity. Credential, OAuth, and environment reads require scope="personal" for the authenticated user or scope="workspace" with workspaceId. Workspace-scoped tools, including list/create, Google Drive, and workspace account reads, require workspaceId. Environment writes use the same personal/workspace scope rule.', 'MCP server documents redact header/env secret values as [redacted]. Keep [redacted] to preserve an existing secret, send a concrete value to replace it, or omit the key to delete it.', 'Accessible workspaces for the authenticated user:', ...workspaceLines, diff --git a/apps/tradinggoose/lib/copilot/registry.ts b/apps/tradinggoose/lib/copilot/registry.ts index 9c4198a23..b8ccc1177 100644 --- a/apps/tradinggoose/lib/copilot/registry.ts +++ b/apps/tradinggoose/lib/copilot/registry.ts @@ -126,6 +126,16 @@ const EntityTargetArgs = z.object({ const WorkspaceTargetArgs = z.object({ workspaceId: RequiredId, }) +const PersonalOrWorkspaceReadArgs = z.discriminatedUnion('scope', [ + z + .object({ + scope: z.literal('personal'), + }) + .strict(), + WorkspaceTargetArgs.extend({ + scope: z.literal('workspace'), + }).strict(), +]) const SetEnvironmentVariablesArgs = z.discriminatedUnion('scope', [ z .object({ @@ -389,13 +399,13 @@ export const ToolArgSchemas = { body: z.union([z.record(z.any()), z.string()]).optional(), }), - [CopilotTool.read_environment_variables]: WorkspaceTargetArgs.strict(), + [CopilotTool.read_environment_variables]: PersonalOrWorkspaceReadArgs, set_environment_variables: SetEnvironmentVariablesArgs, - [CopilotTool.read_oauth_credentials]: WorkspaceTargetArgs.strict(), + [CopilotTool.read_oauth_credentials]: PersonalOrWorkspaceReadArgs, - [CopilotTool.read_credentials]: WorkspaceTargetArgs.strict(), + [CopilotTool.read_credentials]: PersonalOrWorkspaceReadArgs, gdrive_request_access: z.object({}), diff --git a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts index 71e3760ce..7c002a8c7 100644 --- a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts +++ b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts @@ -116,7 +116,7 @@ export const TOOL_PROMPT_METADATA: Record = { }, [CopilotTool.read_environment_variables]: { description: - 'Read personal and workspace environment variable names for the selected workspace. Use returned names with the exact `{{ENV_VAR_NAME}}` syntax in block inputs.', + 'Read environment variable names through an explicit personal or workspace scope. Use returned names with the exact `{{ENV_VAR_NAME}}` syntax in block inputs.', kind: 'read', entityKind: 'environment', }, @@ -126,13 +126,13 @@ export const TOOL_PROMPT_METADATA: Record = { entityKind: 'environment', }, [CopilotTool.read_oauth_credentials]: { - description: 'Read OAuth credentials for the selected workspace.', + description: 'Read OAuth credentials through an explicit personal or workspace scope.', kind: 'read', entityKind: 'credential', }, [CopilotTool.read_credentials]: { description: - 'Read OAuth credentials and related environment variable names for the selected workspace.', + 'Read OAuth credentials and related environment variable names through an explicit personal or workspace scope.', kind: 'read', entityKind: 'credential', }, diff --git a/apps/tradinggoose/lib/copilot/tools/server/router.test.ts b/apps/tradinggoose/lib/copilot/tools/server/router.test.ts index b979c4b2e..430a5e041 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/router.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/router.test.ts @@ -192,6 +192,25 @@ describe('copilot contract registry', () => { expect(getToolContract('unknown_tool')).toBeUndefined() }) + it('requires personal or workspace scope for credential and environment reads', () => { + for (const toolName of [ + 'read_environment_variables', + 'read_credentials', + 'read_oauth_credentials', + ] as const) { + const args = getToolContract(toolName)?.args + expect(args?.parse({ scope: 'personal' })).toEqual({ + scope: 'personal', + }) + expect(args?.parse({ scope: 'workspace', workspaceId: 'workspace-123' })).toEqual({ + scope: 'workspace', + workspaceId: 'workspace-123', + }) + expect(() => args?.parse({ scope: 'workflow', entityId: 'workflow-123' })).toThrow() + expect(() => args?.parse({})).toThrow() + } + }) + it('reuses the shared block schemas in the central contract', () => { const contract = getToolContract('get_available_blocks') @@ -482,22 +501,12 @@ describe('routeExecution', () => { expect(readWorkflowLogsExecute).toHaveBeenCalledWith(payload, undefined) }) - it('injects hosted workspace context for workspace-targeted tools', async () => { + it('injects hosted workspace context for workspace-scoped writes', async () => { const context = { userId: 'user-1', workspaceId: 'workspace-1', } - await expect(routeExecution('read_environment_variables', {}, context)).resolves.toMatchObject({ - variableNames: expect.any(Array), - count: expect.any(Number), - }) - - expect(readEnvironmentVariablesExecute).toHaveBeenCalledWith( - { workspaceId: 'workspace-1' }, - context - ) - await expect( routeExecution( 'set_environment_variables', @@ -517,7 +526,7 @@ describe('routeExecution', () => { it.each([ { toolName: 'read_environment_variables', - payload: { workspaceId: 'workspace-123' }, + payload: { scope: 'workspace', workspaceId: 'workspace-123' }, execute: readEnvironmentVariablesExecute, }, { @@ -531,7 +540,7 @@ describe('routeExecution', () => { }, { toolName: 'read_credentials', - payload: { workspaceId: 'workspace-123' }, + payload: { scope: 'workspace', workspaceId: 'workspace-123' }, execute: readCredentialsExecute, }, { @@ -562,7 +571,22 @@ describe('routeExecution', () => { }, { toolName: 'read_oauth_credentials', - payload: { workspaceId: 'workspace-123' }, + payload: { scope: 'workspace', workspaceId: 'workspace-123' }, + execute: readOAuthCredentialsExecute, + }, + { + toolName: 'read_environment_variables', + payload: { scope: 'personal' }, + execute: readEnvironmentVariablesExecute, + }, + { + toolName: 'read_credentials', + payload: { scope: 'personal' }, + execute: readCredentialsExecute, + }, + { + toolName: 'read_oauth_credentials', + payload: { scope: 'personal' }, execute: readOAuthCredentialsExecute, }, ])( diff --git a/apps/tradinggoose/lib/copilot/tools/server/user/read-credentials.ts b/apps/tradinggoose/lib/copilot/tools/server/user/read-credentials.ts index f1380441b..fd77ad797 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/read-credentials.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/read-credentials.ts @@ -4,41 +4,43 @@ import type { ServerToolExecutionContext, } from '@/lib/copilot/tools/server/base-tool' import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' -import { listOAuthCredentialsForUser } from '@/lib/credentials/oauth' +import { + listOAuthConnectionsForUser, + listOAuthCredentialsForUser, +} from '@/lib/credentials/oauth' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { createLogger } from '@/lib/logs/console/logger' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' import { checkWorkspaceAccess } from '@/lib/permissions/utils' -interface ReadCredentialsParams { - workspaceId?: string -} +type ReadCredentialsParams = + | { scope: 'personal' } + | { scope: 'workspace'; workspaceId: string } export const readCredentialsServerTool: BaseServerTool = { name: CopilotTool.read_credentials, async execute(params: ReadCredentialsParams, context?: ServerToolExecutionContext): Promise { const logger = createLogger('ReadCredentialsServerTool') - const scopedContext = withWorkspaceArgContext(context, params) - - if (!scopedContext?.userId) { - logger.error('Unauthorized attempt to access credentials - no authenticated user context') + if (!context?.userId) { throw new Error('Authentication required') } - const userId = scopedContext.userId - const workspaceId = scopedContext.workspaceId - if (!workspaceId) { - throw new Error('workspaceId is required') - } - - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { - throw new Error('Access denied: You do not have permission to use this workspace') + const userId = context.userId + const scopedContext = + params.scope === 'workspace' ? withWorkspaceArgContext(context, params) : context + const workspaceId = params.scope === 'workspace' ? scopedContext?.workspaceId : undefined + if (params.scope === 'workspace') { + if (!workspaceId) throw new Error('workspaceId is required') + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { + throw new Error('Access denied: You do not have permission to use this workspace') + } } logger.info('Fetching credentials for authenticated user', { userId, + scope: params.scope, workspaceId, }) @@ -54,12 +56,15 @@ export const readCredentialsServerTool: BaseServerTool() - const connectedCredentials = ( - await listOAuthCredentialsForUser({ - userId, - workspaceId, - }) - ).map((credential) => { + const rawCredentials = + params.scope === 'workspace' + ? await listOAuthCredentialsForUser({ + userId, + workspaceId, + }) + : await listOAuthConnectionsForUser({ userId }) + + const connectedCredentials = rawCredentials.map((credential) => { connectedProviderIds.add(credential.provider) const service = allOAuthServices.find((entry) => entry.providerId === credential.provider) return { diff --git a/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.test.ts b/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.test.ts index 12b122c72..c7416d837 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.test.ts @@ -28,6 +28,32 @@ describe('readEnvironmentVariablesServerTool', () => { vi.clearAllMocks() }) + it('uses personal scope to include authenticated user variables only', async () => { + mocks.getPersonalAndWorkspaceEnv.mockResolvedValue({ + personalEncrypted: { PERSONAL_KEY: 'encrypted-1' }, + workspaceEncrypted: {}, + conflicts: [], + }) + + await expect( + readEnvironmentVariablesServerTool.execute( + { scope: 'personal' }, + { + userId: 'auth-user', + } + ) + ).resolves.toEqual({ + variableNames: ['PERSONAL_KEY'], + personalVariableNames: ['PERSONAL_KEY'], + workspaceVariableNames: [], + conflicts: [], + count: 1, + }) + + expect(mocks.checkWorkspaceAccess).not.toHaveBeenCalled() + expect(mocks.getPersonalAndWorkspaceEnv).toHaveBeenCalledWith('auth-user', undefined) + }) + it('uses explicit workspace context to include workspace variables', async () => { mocks.checkWorkspaceAccess.mockResolvedValue({ exists: true, @@ -43,7 +69,7 @@ describe('readEnvironmentVariablesServerTool', () => { await expect( readEnvironmentVariablesServerTool.execute( - { workspaceId: 'workspace-1' }, + { scope: 'workspace', workspaceId: 'workspace-1' }, { userId: 'auth-user', } diff --git a/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.ts b/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.ts index cdccf9f74..2207d260c 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/read-environment-variables.ts @@ -8,9 +8,9 @@ import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' -interface ReadEnvironmentVariablesParams { - workspaceId?: string -} +type ReadEnvironmentVariablesParams = + | { scope: 'personal' } + | { scope: 'workspace'; workspaceId: string } export const readEnvironmentVariablesServerTool: BaseServerTool< ReadEnvironmentVariablesParams, @@ -23,28 +23,25 @@ export const readEnvironmentVariablesServerTool: BaseServerTool< ): Promise { const logger = createLogger('ReadEnvironmentVariablesServerTool') - const scopedContext = withWorkspaceArgContext(context, params) - - if (!scopedContext?.userId) { - logger.error( - 'Unauthorized attempt to access environment variables - no authenticated user context' - ) + if (!context?.userId) { throw new Error('Authentication required') } - const userId = scopedContext.userId - const workspaceId = scopedContext.workspaceId - if (!workspaceId) { - throw new Error('workspaceId is required') - } - - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { - throw new Error('Access denied: You do not have permission to use this workspace') + const userId = context.userId + const scopedContext = + params.scope === 'workspace' ? withWorkspaceArgContext(context, params) : context + const workspaceId = params.scope === 'workspace' ? scopedContext?.workspaceId : undefined + if (params.scope === 'workspace') { + if (!workspaceId) throw new Error('workspaceId is required') + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { + throw new Error('Access denied: You do not have permission to use this workspace') + } } logger.info('Reading environment variables for authenticated user', { userId, + scope: params.scope, workspaceId, }) diff --git a/apps/tradinggoose/lib/copilot/tools/server/user/read-oauth-credentials.ts b/apps/tradinggoose/lib/copilot/tools/server/user/read-oauth-credentials.ts index 5cef0ca30..75c14631a 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/read-oauth-credentials.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/read-oauth-credentials.ts @@ -4,13 +4,16 @@ import type { ServerToolExecutionContext, } from '@/lib/copilot/tools/server/base-tool' import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' -import { listOAuthCredentialsForUser } from '@/lib/credentials/oauth' +import { + listOAuthConnectionsForUser, + listOAuthCredentialsForUser, +} from '@/lib/credentials/oauth' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' -interface ReadOAuthCredentialsParams { - workspaceId?: string -} +type ReadOAuthCredentialsParams = + | { scope: 'personal' } + | { scope: 'workspace'; workspaceId: string } export const readOAuthCredentialsServerTool: BaseServerTool = { name: CopilotTool.read_oauth_credentials, @@ -20,20 +23,21 @@ export const readOAuthCredentialsServerTool: BaseServerTool { const logger = createLogger('ReadOAuthCredentialsServerTool') - const scopedContext = withWorkspaceArgContext(context, params) - - if (!scopedContext?.userId) { - logger.error( - 'Unauthorized attempt to access OAuth credentials - no authenticated user context' - ) + if (!context?.userId) { throw new Error('Authentication required') } - const userId = scopedContext.userId - const workspaceId = scopedContext.workspaceId - if (!workspaceId) { - throw new Error('workspaceId is required') + const userId = context.userId + if (params.scope === 'personal') { + const credentials = await listOAuthConnectionsForUser({ userId }) + logger.info('Fetched personal OAuth credentials', { userId, count: credentials.length }) + return { credentials, total: credentials.length } } + + const scopedContext = withWorkspaceArgContext(context, params) + const workspaceId = scopedContext?.workspaceId + if (!workspaceId) throw new Error('workspaceId is required') + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { throw new Error('Access denied: You do not have permission to use this workspace') From 321e6c68cb55d34136e3189d5cb48832bca91a61 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 23 Jun 2026 17:47:53 -0600 Subject: [PATCH 096/284] refactor(yjs): simplify saved entity state sync Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../tradinggoose/app/api/mcp/servers/route.ts | 13 ----- .../tools/server/entities/mcp-server.ts | 10 +--- .../tools/server/entities/shared.test.ts | 26 +++++----- .../copilot/tools/server/entities/shared.ts | 8 ++-- .../lib/custom-tools/operations.ts | 47 +++++++------------ .../lib/indicators/custom/operations.ts | 33 ++++++------- apps/tradinggoose/lib/knowledge/service.ts | 26 ---------- .../lib/skills/operations.test.ts | 18 +------ apps/tradinggoose/lib/skills/operations.ts | 31 ++++++------ apps/tradinggoose/lib/workflows/db-helpers.ts | 9 ---- apps/tradinggoose/lib/yjs/entity-state.ts | 18 ------- .../lib/yjs/server/apply-entity-state.ts | 10 +--- .../lib/yjs/server/bootstrap-review-target.ts | 31 +----------- .../lib/yjs/server/entity-loaders.ts | 13 ----- 14 files changed, 69 insertions(+), 224 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 908d1747d..a7423a8a3 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -8,8 +8,6 @@ import { mcpService } from '@/lib/mcp/service' import type { McpTransport } from '@/lib/mcp/types' import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { savedEntityRowToFields } from '@/lib/yjs/entity-state' -import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' import { tryDeleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { CreateMcpServerSchema } from './schema' @@ -113,17 +111,6 @@ export const POST = withMcpAuth('write')( }) .returning() - try { - await applySavedEntityPersistedState( - 'mcp_server', - server.id, - savedEntityRowToFields('mcp_server', server) - ) - } catch (error) { - await db.delete(mcpServers).where(eq(mcpServers.id, server.id)) - throw error - } - mcpService.clearCache(workspaceId) logger.info(`[${requestId}] Successfully registered MCP server: ${body.name}`) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts index 9bd039f53..c1826c3df 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -8,7 +8,7 @@ import { mcpService } from '@/lib/mcp/service' import type { McpTransport } from '@/lib/mcp/types' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { - applySavedEntityDocument, + applySavedEntityDocumentToYjs, buildDocumentEnvelope, type EntityCreateResult, type EntityListEntry, @@ -147,12 +147,6 @@ async function createMcpServerEntity( } const savedFields = savedEntityRowToFields(ENTITY_KIND_MCP_SERVER, row) - try { - await applySavedEntityDocument(ENTITY_KIND_MCP_SERVER, row.id, savedFields) - } catch (error) { - await db.delete(mcpServers).where(eq(mcpServers.id, row.id)) - throw error - } mcpService.clearCache(workspaceId) return { @@ -171,7 +165,7 @@ async function applyMcpServerDocument(input: { input.entityId, input.workspaceId ) - await applySavedEntityDocument( + await applySavedEntityDocumentToYjs( ENTITY_KIND_MCP_SERVER, input.entityId, preserveMcpServerSecretPlaceholders(input.fields, currentFields) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts index 1d7a83c94..dae6bc251 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts @@ -12,8 +12,8 @@ import { executeUpdateEntityDocumentMutation, } from './shared' -const { mockApplySavedEntityPersistedState } = vi.hoisted(() => ({ - mockApplySavedEntityPersistedState: vi.fn(), +const { mockApplyEntityStateInSocketServer } = vi.hoisted(() => ({ + mockApplyEntityStateInSocketServer: vi.fn(), })) const mockCheckWorkspaceAccess = vi.hoisted(() => vi.fn()) const mockReadBootstrappedSavedEntityFields = vi.hoisted(() => vi.fn()) @@ -27,9 +27,9 @@ vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ verifyReviewTargetAccess: (...args: unknown[]) => mockVerifyReviewTargetAccess(...args), })) -vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ - applySavedEntityPersistedState: (...args: unknown[]) => - mockApplySavedEntityPersistedState(...args), +vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ + applyEntityStateInSocketServer: (...args: unknown[]) => + mockApplyEntityStateInSocketServer(...args), })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ @@ -77,7 +77,7 @@ describe('entity document mutation helpers', () => { }) expect(result).not.toHaveProperty('requiresReview') expect(result).not.toHaveProperty('preview') - expect(mockApplySavedEntityPersistedState).toHaveBeenCalledWith('skill', 'skill-1', { + expect(mockApplyEntityStateInSocketServer).toHaveBeenCalledWith('skill-1', 'skill', { name: 'Updated Skill', description: 'Updated description', content: 'Use the updated process.', @@ -85,7 +85,7 @@ describe('entity document mutation helpers', () => { expect(mockReadBootstrappedSavedEntityFields).not.toHaveBeenCalled() }) - it('persists accepted reviewed updates after verifying the reviewed base', async () => { + it('applies accepted reviewed updates to Yjs after verifying the reviewed base', async () => { const currentFields = { name: 'Existing Skill', description: 'Existing description', @@ -113,7 +113,11 @@ describe('entity document mutation helpers', () => { } ) - expect(mockApplySavedEntityPersistedState).toHaveBeenCalledWith('skill', 'skill-1', nextFields) + expect(mockApplyEntityStateInSocketServer).toHaveBeenCalledWith( + 'skill-1', + 'skill', + nextFields + ) }) it('preserves indicator input metadata when applying document updates', async () => { @@ -142,7 +146,7 @@ describe('entity document mutation helpers', () => { { userId: 'user-1', accessLevel: 'full' } ) - expect(mockApplySavedEntityPersistedState).toHaveBeenCalledWith('indicator', 'indicator-1', { + expect(mockApplyEntityStateInSocketServer).toHaveBeenCalledWith('indicator-1', 'indicator', { name: 'Updated Indicator', pineCode: "const mode = input.string('fast', 'Mode')", inputMeta, @@ -180,7 +184,7 @@ describe('entity document mutation helpers', () => { expect(create).not.toHaveBeenCalled() }) - it('rejects MCP server edit documents without a URL before persistence', async () => { + it('rejects MCP server edit documents without a URL before applying Yjs state', async () => { await expect( executeUpdateEntityDocumentMutation( 'mcp_server', @@ -206,7 +210,7 @@ describe('entity document mutation helpers', () => { ) ).rejects.toThrow('Invalid MCP server URL: URL is required and must be a string') - expect(mockApplySavedEntityPersistedState).not.toHaveBeenCalled() + expect(mockApplyEntityStateInSocketServer).not.toHaveBeenCalled() }) it('keeps Studio create mutations in review mode', async () => { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index 8ded14139..e9d7cd82a 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -18,8 +18,8 @@ import { } from '@/lib/copilot/tools/server/base-tool' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import type { SavedEntityKind } from '@/lib/yjs/entity-state' -import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' import { readBootstrappedSavedEntityFields } from '@/lib/yjs/server/bootstrap-review-target' +import { applyEntityStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' export type SavedEntityDocumentKind = EntityDocumentKind export type EntityDocumentArgs = { @@ -203,12 +203,12 @@ export async function readSavedEntityDocumentFields( return readBootstrappedSavedEntityFields(kind as SavedEntityKind, entityId, workspaceId) } -export async function applySavedEntityDocument( +export async function applySavedEntityDocumentToYjs( kind: SavedEntityDocumentKind, entityId: string, fields: Record ): Promise { - await applySavedEntityPersistedState(kind as SavedEntityKind, entityId, fields) + await applyEntityStateInSocketServer(entityId, kind, fields) } export async function executeCreateEntityDocumentMutation( @@ -287,7 +287,7 @@ export async function executeUpdateEntityDocumentMutation( if (apply) { await apply({ entityId, fields, workspaceId }) } else { - await applySavedEntityDocument(kind, entityId, fields) + await applySavedEntityDocumentToYjs(kind, entityId, fields) } return { success: true, diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index 39d853ac3..34238a9b5 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -8,7 +8,7 @@ import { } from '@/lib/custom-tools/import-export' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { syncSavedEntityRowsToYjs } from '@/lib/yjs/entity-state' +import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' const logger = createLogger('CustomToolsOperations') @@ -48,8 +48,11 @@ export async function upsertCustomTools({ userId, requestId = generateRequestId(), }: UpsertCustomToolsParams) { - const createdRows: Array = [] - const updatedRows: Array = [] + const updates: Array<{ + id: string + fields: Record + }> = [] + await db.transaction(async (tx) => { for (const tool of tools) { const nowTime = new Date() @@ -71,31 +74,14 @@ export async function upsertCustomTools({ .limit(1) if (existingTool.length > 0) { - const duplicateTitle = await tx - .select({ id: customTools.id }) - .from(customTools) - .where(and(eq(customTools.workspaceId, workspaceId), eq(customTools.title, tool.title))) - .limit(1) - - if (duplicateTitle.length > 0 && duplicateTitle[0].id !== tool.id) { - throw new Error( - `A tool with the title "${tool.title}" already exists in this workspace` - ) - } - - const [updatedTool] = await tx - .update(customTools) - .set({ + updates.push({ + id: tool.id, + fields: { title: tool.title, - schema: tool.schema, - code: tool.code, - updatedAt: nowTime, - }) - .where(and(eq(customTools.id, tool.id), eq(customTools.workspaceId, workspaceId))) - .returning() - if (updatedTool) { - updatedRows.push(updatedTool) - } + schemaText: JSON.stringify(tool.schema, null, 2), + codeText: tool.code, + }, + }) logger.info(`[${requestId}] Updated custom tool ${tool.id}`) continue } @@ -115,11 +101,12 @@ export async function upsertCustomTools({ await tx.insert(customTools).values(newTool) logger.info(`[${requestId}] Created custom tool ${tool.title}`) - createdRows.push(newTool) } }) - await syncSavedEntityRowsToYjs('custom_tool', [...createdRows, ...updatedRows]) + await Promise.all( + updates.map(({ id, fields }) => applySavedEntityPersistedState('custom_tool', id, fields)) + ) return listCustomTools({ workspaceId }) } @@ -171,7 +158,5 @@ export async function importCustomTools({ } }) - await syncSavedEntityRowsToYjs('custom_tool', result.tools) - return result } diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index 659360105..bba8ad039 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -9,7 +9,7 @@ import { import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { syncSavedEntityRowsToYjs } from '@/lib/yjs/entity-state' +import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' const logger = createLogger('IndicatorsOperations') @@ -51,8 +51,11 @@ export async function upsertIndicators({ userId, requestId = generateRequestId(), }: UpsertIndicatorsParams) { - const createdRows: Array = [] - const updatedRows: Array = [] + const updates: Array<{ + id: string + fields: Record + }> = [] + await db.transaction(async (tx) => { for (const indicator of indicators) { const nowTime = new Date() @@ -69,22 +72,15 @@ export async function upsertIndicators({ if (existing.length > 0) { const existingColor = existing[0]?.color - const [updatedIndicator] = await tx - .update(pineIndicators) - .set({ + updates.push({ + id: indicator.id, + fields: { name: indicator.name, color: existingColor ?? getStableVibrantColor(indicator.id), pineCode: indicator.pineCode, inputMeta: indicator.inputMeta ?? null, - updatedAt: nowTime, - }) - .where( - and(eq(pineIndicators.id, indicator.id), eq(pineIndicators.workspaceId, workspaceId)) - ) - .returning() - if (updatedIndicator) { - updatedRows.push(updatedIndicator) - } + }, + }) logger.info(`[${requestId}] Updated Indicator ${indicator.id}`) continue } @@ -105,11 +101,12 @@ export async function upsertIndicators({ await tx.insert(pineIndicators).values(newIndicator) logger.info(`[${requestId}] Created Indicator ${indicator.name}`) - createdRows.push(newIndicator) } }) - await syncSavedEntityRowsToYjs('indicator', [...createdRows, ...updatedRows]) + await Promise.all( + updates.map(({ id, fields }) => applySavedEntityPersistedState('indicator', id, fields)) + ) return db .select() @@ -171,7 +168,5 @@ export async function importIndicators({ } }) - await syncSavedEntityRowsToYjs('indicator', result.indicators) - return result } diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index f5ecd6d34..e4ee4d12b 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -21,7 +21,6 @@ import type { } from '@/lib/knowledge/types' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/permissions/utils' -import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' import { tryDeleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' @@ -117,17 +116,6 @@ export async function createKnowledgeBase( docCount: 0, } - try { - await applySavedEntityPersistedState( - ENTITY_KIND_KNOWLEDGE_BASE, - created.id, - savedEntityRowToFields(ENTITY_KIND_KNOWLEDGE_BASE, created) - ) - } catch (error) { - await db.delete(knowledgeBase).where(eq(knowledgeBase.id, kbId)) - throw error - } - return created } @@ -348,20 +336,6 @@ export async function copyKnowledgeBaseToWorkspace( docCount: sourceDocuments.length, } - try { - await applySavedEntityPersistedState( - ENTITY_KIND_KNOWLEDGE_BASE, - copied.id, - savedEntityRowToFields(ENTITY_KIND_KNOWLEDGE_BASE, copied) - ) - } catch (error) { - await db.delete(knowledgeBase).where(eq(knowledgeBase.id, newKnowledgeBaseId)) - if (copiedDocuments.length > 0) { - await deleteKnowledgeDocumentFiles(copiedDocuments.map(({ fileUrl }) => fileUrl)) - } - throw error - } - if (totalDocumentSize > 0) { try { await incrementStorageUsage(userId, totalDocumentSize, targetWorkspaceId) diff --git a/apps/tradinggoose/lib/skills/operations.test.ts b/apps/tradinggoose/lib/skills/operations.test.ts index 320bbed55..2fdf2a0dc 100644 --- a/apps/tradinggoose/lib/skills/operations.test.ts +++ b/apps/tradinggoose/lib/skills/operations.test.ts @@ -1,9 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockTransaction, mockNanoid, mockApplySavedEntityDraftState } = vi.hoisted(() => ({ +const { mockTransaction, mockNanoid } = vi.hoisted(() => ({ mockTransaction: vi.fn(), mockNanoid: vi.fn(), - mockApplySavedEntityDraftState: vi.fn(), })) vi.mock('@tradinggoose/db', () => ({ @@ -31,10 +30,6 @@ vi.mock('nanoid', () => ({ nanoid: (...args: unknown[]) => mockNanoid(...args), })) -vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ - applySavedEntityDraftState: (...args: unknown[]) => mockApplySavedEntityDraftState(...args), -})) - import { importSkills } from '@/lib/skills/operations' const createQueryChain = (result: unknown) => { @@ -148,16 +143,5 @@ describe('skills import operations', () => { ]) expect(result.importedCount).toBe(2) expect(result.renamedCount).toBe(1) - expect(mockApplySavedEntityDraftState).toHaveBeenCalledTimes(2) - expect(mockApplySavedEntityDraftState).toHaveBeenCalledWith('skill', 'skill-b', { - name: 'Execution Plan (imported) 1', - description: 'Create the execution plan.', - content: 'Follow the checklist.', - }) - expect(mockApplySavedEntityDraftState).toHaveBeenCalledWith('skill', 'skill-a', { - name: 'Market Research', - description: 'Research the market.', - content: 'Review catalysts.', - }) }) }) diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index acf6b9cc1..499f8daa2 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -9,7 +9,7 @@ import { type SkillTransferRecord, } from '@/lib/skills/import-export' import { generateRequestId } from '@/lib/utils' -import { syncSavedEntityRowsToYjs } from '@/lib/yjs/entity-state' +import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' import { tryDeleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('SkillsOperations') @@ -66,8 +66,11 @@ export async function upsertSkills({ userId, requestId = generateRequestId(), }: UpsertSkillsParams) { - const createdRows: Array = [] - const updatedRows: Array = [] + const updates: Array<{ + id: string + fields: Record + }> = [] + await db.transaction(async (tx) => { for (const currentSkill of skills) { const nowTime = new Date() @@ -100,19 +103,14 @@ export async function upsertSkills({ } } - const [updatedSkill] = await tx - .update(skill) - .set({ + updates.push({ + id: currentSkill.id, + fields: { name: currentSkill.name, description: currentSkill.description, content: currentSkill.content, - updatedAt: nowTime, - }) - .where(and(eq(skill.id, currentSkill.id), eq(skill.workspaceId, workspaceId))) - .returning() - if (updatedSkill) { - updatedRows.push(updatedSkill) - } + }, + }) logger.info(`[${requestId}] Updated skill ${currentSkill.id}`) continue } @@ -144,11 +142,12 @@ export async function upsertSkills({ await tx.insert(skill).values(newSkill) logger.info(`[${requestId}] Created skill "${currentSkill.name}"`) - createdRows.push(newSkill) } }) - await syncSavedEntityRowsToYjs('skill', [...createdRows, ...updatedRows]) + await Promise.all( + updates.map(({ id, fields }) => applySavedEntityPersistedState('skill', id, fields)) + ) return listSkills({ workspaceId }) } @@ -213,7 +212,5 @@ export async function importSkills({ } }) - await syncSavedEntityRowsToYjs('skill', result.skills) - return result } diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 20c07e6e3..d7cd596a3 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -134,15 +134,6 @@ export type WorkflowStateWithSource = PersistedWorkflowState & { source: 'yjs' | 'db' } -export async function readWorkflowUpdatedAt(workflowId: string): Promise { - const [row] = await db - .select({ updatedAt: workflow.updatedAt }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - return row?.updatedAt ?? null -} - export async function loadWorkflowState( workflowId: string ): Promise { diff --git a/apps/tradinggoose/lib/yjs/entity-state.ts b/apps/tradinggoose/lib/yjs/entity-state.ts index b193bc0a6..0d7222413 100644 --- a/apps/tradinggoose/lib/yjs/entity-state.ts +++ b/apps/tradinggoose/lib/yjs/entity-state.ts @@ -1,5 +1,4 @@ import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' -import { applySavedEntityDraftState } from '@/lib/yjs/server/apply-entity-state' export type SavedEntityKind = Exclude @@ -62,20 +61,3 @@ export function savedEntityRowToFields( } } } - -export async function syncSavedEntityRowsToYjs( - entityKind: SavedEntityKind, - rows: T[] -): Promise { - await Promise.all( - rows.map(async (row) => { - try { - await applySavedEntityDraftState( - entityKind, - row.id, - savedEntityRowToFields(entityKind, row) - ) - } catch {} - }) - ) -} diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index 99d4303d3..58dc2b2b5 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -136,21 +136,13 @@ async function persistSavedEntityState( } } -export async function applySavedEntityDraftState( - entityKind: SavedEntityKind, - entityId: string, - fields: Record -): Promise { - await applyEntityStateInSocketServer(entityId, entityKind, fields) -} - export async function applySavedEntityPersistedState( entityKind: SavedEntityKind, entityId: string, fields: Record ): Promise { const normalizedFields = normalizeSavedEntityFields(entityKind, fields) - await applySavedEntityDraftState(entityKind, entityId, normalizedFields) + await applyEntityStateInSocketServer(entityId, entityKind, normalizedFields) try { await persistSavedEntityState(entityKind, entityId, normalizedFields) } catch (error) { diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index dbe93e2ba..b9e3cc1c6 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -9,19 +9,16 @@ import type { ReviewTargetDescriptor, ReviewTargetRuntimeState, } from '@/lib/copilot/review-sessions/types' -import { loadWorkflowStateFromSavedTables, readWorkflowUpdatedAt } from '@/lib/workflows/db-helpers' +import { loadWorkflowStateFromSavedTables } from '@/lib/workflows/db-helpers' import { getEntityFields, seedEntitySession } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { readSavedEntityFieldsFromDb, - readSavedEntityUpdatedAt, resolveEntityWorkspaceId, } from '@/lib/yjs/server/entity-loaders' import { - deleteYjsSessionInSocketServer, getYjsSnapshot, SocketServerBridgeError, - type YjsSnapshotResponse, } from '@/lib/yjs/server/snapshot-bridge' import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' import { @@ -63,11 +60,7 @@ export function getRuntimeStateFromUpdate(update: Uint8Array): ReviewTargetRunti export async function readBootstrappedReviewTargetSnapshot(descriptor: ReviewTargetDescriptor) { const bridgeParams = serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor)) try { - const snapshot = await getYjsSnapshot(descriptor.yjsSessionId, bridgeParams) - if (await isSavedTargetSnapshotFresh(descriptor, snapshot)) { - return snapshot - } - await deleteYjsSessionInSocketServer(descriptor.yjsSessionId) + return await getYjsSnapshot(descriptor.yjsSessionId, bridgeParams) } catch (error) { if (!(error instanceof SocketServerBridgeError) || error.status !== 404) { throw error @@ -99,26 +92,6 @@ export async function readBootstrappedReviewTargetSnapshot(descriptor: ReviewTar } } -async function isSavedTargetSnapshotFresh( - descriptor: ReviewTargetDescriptor, - snapshot: YjsSnapshotResponse -): Promise { - if (descriptor.reviewSessionId || !descriptor.entityId) { - return true - } - - const savedAt = - descriptor.entityKind === 'workflow' - ? await readWorkflowUpdatedAt(descriptor.entityId) - : await readSavedEntityUpdatedAt( - descriptor.entityKind as SavedEntityKind, - descriptor.entityId - ) - return Boolean( - savedAt && typeof snapshot.touchedAt === 'number' && snapshot.touchedAt >= savedAt.getTime() - ) -} - export async function readBootstrappedSavedEntityFields( entityKind: SavedEntityKind, entityId: string, diff --git a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts index ab0f61eb0..7861c94fa 100644 --- a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts +++ b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts @@ -105,16 +105,3 @@ export async function readSavedEntityFieldsFromDb( return savedEntityRowToFields(entityKind, row) } - -export async function readSavedEntityUpdatedAt( - entityKind: SavedEntityKind, - entityId: string -): Promise { - const table = entityTable(entityKind) - const [row] = await db - .select({ updatedAt: table.updatedAt }) - .from(table) - .where(entityIdCondition(entityKind, entityId)) - .limit(1) - return row?.updatedAt ?? null -} From 24a6c27a8a9746bee1ff8c2f87e271317856e513 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 23 Jun 2026 18:27:27 -0600 Subject: [PATCH 097/284] fix(yjs): persist applied snapshots from live documents Read back the applied Yjs document before saving normalized entity and workflow state so DB writes reflect the actual post-apply document, and remove the canonical local-store path. Co-authored-by: Codex \nCo-authored-by: BWJ2310 \nCo-authored-by: BWJ2310-backup --- .../app/api/mcp/servers/[id]/route.ts | 1 + .../sessions/[sessionId]/snapshot/route.ts | 1 + .../tools/server/entities/mcp-server.ts | 5 +- .../tools/server/entities/shared.test.ts | 51 ++++--- .../copilot/tools/server/entities/shared.ts | 12 +- .../lib/custom-tools/operations.ts | 4 +- .../lib/indicators/custom/operations.ts | 4 +- apps/tradinggoose/lib/knowledge/service.ts | 7 +- apps/tradinggoose/lib/skills/operations.ts | 4 +- .../lib/workflows/db-helpers.test.ts | 117 --------------- apps/tradinggoose/lib/workflows/db-helpers.ts | 48 ------ .../lib/yjs/server/apply-entity-state.test.ts | 139 ++++++++++++++++++ .../lib/yjs/server/apply-entity-state.ts | 48 +++++- .../yjs/server/apply-workflow-state.test.ts | 64 +++++++- .../lib/yjs/server/apply-workflow-state.ts | 56 ++++++- .../lib/yjs/server/bootstrap-review-target.ts | 6 +- apps/tradinggoose/socket-server/index.test.ts | 10 -- .../tradinggoose/socket-server/routes/http.ts | 3 +- .../socket-server/yjs/persistence.ts | 21 --- .../socket-server/yjs/ws-handler.test.ts | 5 +- .../socket-server/yjs/ws-handler.ts | 12 +- 21 files changed, 360 insertions(+), 258 deletions(-) create mode 100644 apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts index d7913481c..d5c99c821 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts @@ -93,6 +93,7 @@ export const PATCH = withMcpAuth('write')( await applySavedEntityPersistedState( 'mcp_server', nextServer.id, + workspaceId, savedEntityRowToFields('mcp_server', nextServer) ) diff --git a/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts b/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts index 844002b10..a9255aeba 100644 --- a/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts +++ b/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts @@ -131,6 +131,7 @@ export async function POST( await applySavedEntityPersistedState( entityKind, descriptor.entityId, + descriptor.workspaceId, getEntityFields(doc, entityKind) ) } finally { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts index c1826c3df..bb4e36633 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -7,8 +7,8 @@ import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' import { mcpService } from '@/lib/mcp/service' import type { McpTransport } from '@/lib/mcp/types' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' +import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' import { - applySavedEntityDocumentToYjs, buildDocumentEnvelope, type EntityCreateResult, type EntityListEntry, @@ -165,9 +165,10 @@ async function applyMcpServerDocument(input: { input.entityId, input.workspaceId ) - await applySavedEntityDocumentToYjs( + await applySavedEntityPersistedState( ENTITY_KIND_MCP_SERVER, input.entityId, + input.workspaceId, preserveMcpServerSecretPlaceholders(input.fields, currentFields) ) mcpService.clearCache(input.workspaceId) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts index dae6bc251..a9059b82c 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts @@ -12,8 +12,8 @@ import { executeUpdateEntityDocumentMutation, } from './shared' -const { mockApplyEntityStateInSocketServer } = vi.hoisted(() => ({ - mockApplyEntityStateInSocketServer: vi.fn(), +const { mockApplySavedEntityPersistedState } = vi.hoisted(() => ({ + mockApplySavedEntityPersistedState: vi.fn(), })) const mockCheckWorkspaceAccess = vi.hoisted(() => vi.fn()) const mockReadBootstrappedSavedEntityFields = vi.hoisted(() => vi.fn()) @@ -27,9 +27,9 @@ vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ verifyReviewTargetAccess: (...args: unknown[]) => mockVerifyReviewTargetAccess(...args), })) -vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ - applyEntityStateInSocketServer: (...args: unknown[]) => - mockApplyEntityStateInSocketServer(...args), +vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ + applySavedEntityPersistedState: (...args: unknown[]) => + mockApplySavedEntityPersistedState(...args), })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ @@ -77,15 +77,20 @@ describe('entity document mutation helpers', () => { }) expect(result).not.toHaveProperty('requiresReview') expect(result).not.toHaveProperty('preview') - expect(mockApplyEntityStateInSocketServer).toHaveBeenCalledWith('skill-1', 'skill', { - name: 'Updated Skill', - description: 'Updated description', - content: 'Use the updated process.', - }) + expect(mockApplySavedEntityPersistedState).toHaveBeenCalledWith( + 'skill', + 'skill-1', + 'workspace-1', + { + name: 'Updated Skill', + description: 'Updated description', + content: 'Use the updated process.', + } + ) expect(mockReadBootstrappedSavedEntityFields).not.toHaveBeenCalled() }) - it('applies accepted reviewed updates to Yjs after verifying the reviewed base', async () => { + it('persists accepted reviewed updates after verifying the reviewed base', async () => { const currentFields = { name: 'Existing Skill', description: 'Existing description', @@ -113,9 +118,10 @@ describe('entity document mutation helpers', () => { } ) - expect(mockApplyEntityStateInSocketServer).toHaveBeenCalledWith( - 'skill-1', + expect(mockApplySavedEntityPersistedState).toHaveBeenCalledWith( 'skill', + 'skill-1', + 'workspace-1', nextFields ) }) @@ -146,11 +152,16 @@ describe('entity document mutation helpers', () => { { userId: 'user-1', accessLevel: 'full' } ) - expect(mockApplyEntityStateInSocketServer).toHaveBeenCalledWith('indicator-1', 'indicator', { - name: 'Updated Indicator', - pineCode: "const mode = input.string('fast', 'Mode')", - inputMeta, - }) + expect(mockApplySavedEntityPersistedState).toHaveBeenCalledWith( + 'indicator', + 'indicator-1', + 'workspace-1', + { + name: 'Updated Indicator', + pineCode: "const mode = input.string('fast', 'Mode')", + inputMeta, + } + ) }) it('rejects MCP server create documents without a URL', async () => { @@ -184,7 +195,7 @@ describe('entity document mutation helpers', () => { expect(create).not.toHaveBeenCalled() }) - it('rejects MCP server edit documents without a URL before applying Yjs state', async () => { + it('rejects MCP server edit documents without a URL before persisting state', async () => { await expect( executeUpdateEntityDocumentMutation( 'mcp_server', @@ -210,7 +221,7 @@ describe('entity document mutation helpers', () => { ) ).rejects.toThrow('Invalid MCP server URL: URL is required and must be a string') - expect(mockApplyEntityStateInSocketServer).not.toHaveBeenCalled() + expect(mockApplySavedEntityPersistedState).not.toHaveBeenCalled() }) it('keeps Studio create mutations in review mode', async () => { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index e9d7cd82a..c2f35b50a 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -19,7 +19,7 @@ import { import { checkWorkspaceAccess } from '@/lib/permissions/utils' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { readBootstrappedSavedEntityFields } from '@/lib/yjs/server/bootstrap-review-target' -import { applyEntityStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' export type SavedEntityDocumentKind = EntityDocumentKind export type EntityDocumentArgs = { @@ -203,14 +203,6 @@ export async function readSavedEntityDocumentFields( return readBootstrappedSavedEntityFields(kind as SavedEntityKind, entityId, workspaceId) } -export async function applySavedEntityDocumentToYjs( - kind: SavedEntityDocumentKind, - entityId: string, - fields: Record -): Promise { - await applyEntityStateInSocketServer(entityId, kind, fields) -} - export async function executeCreateEntityDocumentMutation( kind: SavedEntityDocumentKind, args: EntityDocumentArgs, @@ -287,7 +279,7 @@ export async function executeUpdateEntityDocumentMutation( if (apply) { await apply({ entityId, fields, workspaceId }) } else { - await applySavedEntityDocumentToYjs(kind, entityId, fields) + await applySavedEntityPersistedState(kind, entityId, workspaceId, fields) } return { success: true, diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index 34238a9b5..d623a00aa 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -105,7 +105,9 @@ export async function upsertCustomTools({ }) await Promise.all( - updates.map(({ id, fields }) => applySavedEntityPersistedState('custom_tool', id, fields)) + updates.map(({ id, fields }) => + applySavedEntityPersistedState('custom_tool', id, workspaceId, fields) + ) ) return listCustomTools({ workspaceId }) diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index bba8ad039..943a65921 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -105,7 +105,9 @@ export async function upsertIndicators({ }) await Promise.all( - updates.map(({ id, fields }) => applySavedEntityPersistedState('indicator', id, fields)) + updates.map(({ id, fields }) => + applySavedEntityPersistedState('indicator', id, workspaceId, fields) + ) ) return db diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index e4ee4d12b..4262edb51 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -369,7 +369,12 @@ export async function applyKnowledgeBaseMetadata( throw new Error(`Knowledge base ${knowledgeBaseId} not found`) } - await applySavedEntityPersistedState(ENTITY_KIND_KNOWLEDGE_BASE, knowledgeBaseId, fields) + await applySavedEntityPersistedState( + ENTITY_KIND_KNOWLEDGE_BASE, + knowledgeBaseId, + existing.workspaceId, + fields + ) logger.info(`[${requestId}] Applied knowledge base metadata through Yjs: ${knowledgeBaseId}`) diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index 499f8daa2..2d6cde8e5 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -146,7 +146,9 @@ export async function upsertSkills({ }) await Promise.all( - updates.map(({ id, fields }) => applySavedEntityPersistedState('skill', id, fields)) + updates.map(({ id, fields }) => + applySavedEntityPersistedState('skill', id, workspaceId, fields) + ) ) return listSkills({ workspaceId }) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index 07c2213c8..d83a2f0af 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -796,123 +796,6 @@ describe('Database Helpers', () => { }) }) - describe('workflowExistsInNormalizedTables', () => { - it('should return true when workflow exists in normalized tables', async () => { - mockDb.select.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ id: 'block-1' }]), - }), - }), - }) - - const result = await dbHelpers.workflowExistsInNormalizedTables(mockWorkflowId) - - expect(result).toBe(true) - }) - - it('should return false when workflow does not exist in normalized tables', async () => { - mockDb.select.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([]), - }), - }), - }) - - const result = await dbHelpers.workflowExistsInNormalizedTables(mockWorkflowId) - - expect(result).toBe(false) - }) - - it('should return false when database query fails', async () => { - mockDb.select.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockRejectedValue(new Error('Database error')), - }), - }), - }) - - const result = await dbHelpers.workflowExistsInNormalizedTables(mockWorkflowId) - - expect(result).toBe(false) - }) - }) - - describe('migrateWorkflowToNormalizedTables', () => { - beforeEach(() => { - mockNoConflictingBlockIds() - }) - - const mockJsonState = { - blocks: mockWorkflowState.blocks, - edges: mockWorkflowState.edges, - loops: mockWorkflowState.loops, - parallels: mockWorkflowState.parallels, - lastSaved: Date.now(), - isDeployed: false, - deploymentStatuses: {}, - } - - it('should successfully migrate workflow from JSON to normalized tables', async () => { - const mockTransaction = vi - .fn() - .mockImplementation(async (callback) => callback(createMockTx())) - - mockDb.transaction = mockTransaction - - const result = await dbHelpers.migrateWorkflowToNormalizedTables( - mockWorkflowId, - mockJsonState - ) - - expect(result.success).toBe(true) - expect(result.error).toBeUndefined() - }) - - it('should return error when migration fails', async () => { - const mockTransaction = vi.fn().mockRejectedValue(new Error('Migration failed')) - mockDb.transaction = mockTransaction - - const result = await dbHelpers.migrateWorkflowToNormalizedTables( - mockWorkflowId, - mockJsonState - ) - - expect(result.success).toBe(false) - expect(result.error).toBe('Migration failed') - }) - - it('should handle missing properties in JSON state gracefully', async () => { - const incompleteJsonState = { - blocks: mockWorkflowState.blocks, - edges: mockWorkflowState.edges, - // Missing loops, parallels, and other properties - } - - const mockTransaction = vi - .fn() - .mockImplementation(async (callback) => callback(createMockTx())) - - mockDb.transaction = mockTransaction - - const result = await dbHelpers.migrateWorkflowToNormalizedTables( - mockWorkflowId, - incompleteJsonState - ) - - expect(result.success).toBe(true) - }) - - it('should handle null/undefined JSON state', async () => { - const result = await dbHelpers.migrateWorkflowToNormalizedTables(mockWorkflowId, null) - - expect(result.success).toBe(false) - expect(result.error).toContain('Cannot read properties') - }) - }) - describe('error handling and edge cases', () => { beforeEach(() => { mockNoConflictingBlockIds() diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index d7cd596a3..6fc9e39cc 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -934,54 +934,6 @@ export async function saveWorkflowToNormalizedTables( } } -/** - * Check if a workflow exists in normalized tables - */ -export async function workflowExistsInNormalizedTables(workflowId: string): Promise { - try { - const blocks = await db - .select({ id: workflowBlocks.id }) - .from(workflowBlocks) - .where(eq(workflowBlocks.workflowId, workflowId)) - .limit(1) - - return blocks.length > 0 - } catch (error) { - logger.error(`Error checking if workflow ${workflowId} exists in normalized tables:`, error) - return false - } -} - -/** - * Migrate a workflow from JSON blob to normalized tables - */ -export async function migrateWorkflowToNormalizedTables( - workflowId: string, - jsonState: any -): Promise<{ success: boolean; error?: string }> { - try { - // Convert JSON state to WorkflowState format - // Only include fields that are actually persisted to normalized tables - const workflowState: WorkflowState = { - blocks: jsonState.blocks || {}, - edges: jsonState.edges || [], - loops: jsonState.loops || {}, - parallels: jsonState.parallels || {}, - lastSaved: jsonState.lastSaved, - isDeployed: jsonState.isDeployed, - deployedAt: jsonState.deployedAt, - } - - return await saveWorkflowToNormalizedTables(workflowId, workflowState) - } catch (error) { - logger.error(`Error migrating workflow ${workflowId} to normalized tables:`, error) - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - } - } -} - /** * Deploy a workflow by creating a new deployment version */ diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts new file mode 100644 index 000000000..0b0eb6e6f --- /dev/null +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts @@ -0,0 +1,139 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' + +const { + events, + mockApplyEntityStateInSocketServer, + mockDbUpdate, + mockDeleteYjsSessionInSocketServer, + mockGetYjsSnapshot, + mockUpdateReturning, + mockUpdateSet, + mockUpdateWhere, +} = vi.hoisted(() => ({ + events: [] as string[], + mockApplyEntityStateInSocketServer: vi.fn(), + mockDbUpdate: vi.fn(), + mockDeleteYjsSessionInSocketServer: vi.fn(), + mockGetYjsSnapshot: vi.fn(), + mockUpdateReturning: vi.fn(), + mockUpdateSet: vi.fn(), + mockUpdateWhere: vi.fn(), +})) + +vi.mock('@tradinggoose/db', () => ({ + db: { + update: mockDbUpdate, + }, +})) + +vi.mock('@tradinggoose/db/schema', () => ({ + customTools: { id: 'customTools.id' }, + knowledgeBase: { id: 'knowledgeBase.id' }, + mcpServers: { id: 'mcpServers.id' }, + pineIndicators: { id: 'pineIndicators.id' }, + skill: { id: 'skill.id' }, +})) + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((field, value) => ({ field, value })), +})) + +vi.mock('@/lib/copilot/entity-documents', () => ({ + normalizeEntityFields: vi.fn((_entityKind, fields) => fields), +})) + +vi.mock('@/lib/custom-tools/schema', () => ({ + parseCustomToolSchemaText: vi.fn((schemaText) => schemaText), +})) + +vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ + applyEntityStateInSocketServer: mockApplyEntityStateInSocketServer, + deleteYjsSessionInSocketServer: mockDeleteYjsSessionInSocketServer, + getYjsSnapshot: mockGetYjsSnapshot, +})) + +function buildSkillSnapshotBase64(fields: { + name: string + description: string + content: string +}) { + const doc = new Y.Doc() + try { + const map = doc.getMap('fields') + map.set('name', fields.name) + map.set('description', fields.description) + map.set('content', fields.content) + return Buffer.from(Y.encodeStateAsUpdate(doc)).toString('base64') + } finally { + doc.destroy() + } +} + +describe('applySavedEntityPersistedState', () => { + beforeEach(() => { + vi.clearAllMocks() + events.length = 0 + mockApplyEntityStateInSocketServer.mockImplementation(async () => { + events.push('yjs') + }) + mockGetYjsSnapshot.mockImplementation(async () => { + events.push('snapshot') + return { + snapshotBase64: buildSkillSnapshotBase64({ + name: 'Yjs Skill', + description: 'Yjs description', + content: 'Use the Yjs document.', + }), + descriptor: {}, + runtime: {}, + touchedAt: Date.now(), + } + }) + mockUpdateReturning.mockResolvedValue([{ id: 'skill-1' }]) + mockUpdateWhere.mockReturnValue({ returning: mockUpdateReturning }) + mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }) + mockDbUpdate.mockImplementation(() => { + events.push('db') + return { set: mockUpdateSet } + }) + }) + + it('applies entity changes to Yjs before persisting the post-apply Yjs snapshot to DB', async () => { + const { applySavedEntityPersistedState } = await import('./apply-entity-state') + + await applySavedEntityPersistedState('skill', 'skill-1', 'workspace-1', { + name: 'Copilot Skill', + description: 'Copilot description', + content: 'Use the Copilot input.', + }) + + expect(mockApplyEntityStateInSocketServer).toHaveBeenCalledWith('skill-1', 'skill', { + name: 'Copilot Skill', + description: 'Copilot description', + content: 'Use the Copilot input.', + }) + expect(mockGetYjsSnapshot).toHaveBeenCalledWith( + 'skill-1', + expect.objectContaining({ + targetKind: 'entity', + sessionId: 'skill-1', + workspaceId: 'workspace-1', + entityKind: 'skill', + entityId: 'skill-1', + }) + ) + expect(mockUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Yjs Skill', + description: 'Yjs description', + content: 'Use the Yjs document.', + }) + ) + expect(events).toEqual(['yjs', 'snapshot', 'db']) + }) +}) diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index 58dc2b2b5..af9977d24 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -7,12 +7,19 @@ import { skill, } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' +import * as Y from 'yjs' import { normalizeEntityFields } from '@/lib/copilot/entity-documents' +import { + buildYjsTransportEnvelope, + serializeYjsTransportEnvelope, +} from '@/lib/copilot/review-sessions/identity' import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' +import { getEntityFields } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { applyEntityStateInSocketServer, deleteYjsSessionInSocketServer, + getYjsSnapshot, } from '@/lib/yjs/server/snapshot-bridge' export class SavedEntityPersistenceError extends Error { @@ -136,15 +143,54 @@ async function persistSavedEntityState( } } +async function readAppliedYjsEntityFields( + entityKind: SavedEntityKind, + entityId: string, + workspaceId: string +): Promise> { + const snapshot = await getYjsSnapshot( + entityId, + serializeYjsTransportEnvelope( + buildYjsTransportEnvelope({ + workspaceId, + entityKind, + entityId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: entityId, + }) + ) + ) + if (!snapshot.snapshotBase64) { + throw new SavedEntityPersistenceError( + 404, + `Saved ${entityKind} ${entityId} Yjs state is missing` + ) + } + + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) + return getEntityFields(doc, entityKind) + } finally { + doc.destroy() + } +} + export async function applySavedEntityPersistedState( entityKind: SavedEntityKind, entityId: string, + workspaceId: string, fields: Record ): Promise { const normalizedFields = normalizeSavedEntityFields(entityKind, fields) await applyEntityStateInSocketServer(entityId, entityKind, normalizedFields) try { - await persistSavedEntityState(entityKind, entityId, normalizedFields) + const yjsFields = normalizeSavedEntityFields( + entityKind, + await readAppliedYjsEntityFields(entityKind, entityId, workspaceId) + ) + await persistSavedEntityState(entityKind, entityId, yjsFields) } catch (error) { await deleteYjsSessionInSocketServer(entityId) throw error diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts index 9500ac0ec..6c166aa55 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts @@ -3,12 +3,15 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' +import { replaceWorkflowDocumentState } from '@/lib/yjs/workflow-session' const { mockApplyWorkflowStateInSocketServer, mockDbUpdate, mockEnsureUniqueBlockIds, mockEnsureUniqueEdgeIds, + mockGetYjsSnapshot, mockSaveWorkflowToNormalizedTables, mockDeleteYjsSessionInSocketServer, mockUpdateReturning, @@ -20,6 +23,7 @@ const { mockDbUpdate: vi.fn(), mockEnsureUniqueBlockIds: vi.fn(), mockEnsureUniqueEdgeIds: vi.fn(), + mockGetYjsSnapshot: vi.fn(), mockSaveWorkflowToNormalizedTables: vi.fn(), mockDeleteYjsSessionInSocketServer: vi.fn(), mockUpdateReturning: vi.fn(), @@ -50,10 +54,24 @@ vi.mock('@/lib/workflows/db-helpers', () => ({ vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ applyWorkflowStateInSocketServer: mockApplyWorkflowStateInSocketServer, deleteYjsSessionInSocketServer: mockDeleteYjsSessionInSocketServer, + getYjsSnapshot: mockGetYjsSnapshot, })) const emptyWorkflowState = { blocks: {}, edges: [], loops: {}, parallels: {} } +function buildWorkflowSnapshotBase64( + workflowState: Parameters[1], + variables: Record = {} +): string { + const doc = new Y.Doc() + try { + replaceWorkflowDocumentState(doc, workflowState, variables, 'Workflow Name') + return Buffer.from(Y.encodeStateAsUpdate(doc)).toString('base64') + } finally { + doc.destroy() + } +} + describe('applyWorkflowState', () => { beforeEach(() => { vi.clearAllMocks() @@ -64,6 +82,9 @@ describe('applyWorkflowState', () => { await commit?.({ update: mockDbUpdate }, state) return { success: true } }) + mockGetYjsSnapshot.mockImplementation(async () => ({ + snapshotBase64: buildWorkflowSnapshotBase64(emptyWorkflowState), + })) mockDeleteYjsSessionInSocketServer.mockResolvedValue(undefined) mockUpdateReturning.mockResolvedValue([{ id: 'workflow-1' }]) mockUpdateWhere.mockReturnValue({ returning: mockUpdateReturning }) @@ -71,7 +92,7 @@ describe('applyWorkflowState', () => { mockDbUpdate.mockReturnValue({ set: mockUpdateSet }) }) - it('publishes the normalized workflow state to Yjs before committing DB changes', async () => { + it('persists the applied Yjs workflow state after publishing to Yjs', async () => { mockEnsureUniqueBlockIds.mockImplementationOnce(async () => ({ blocks: { 'normalized-block': { @@ -88,6 +109,27 @@ describe('applyWorkflowState', () => { loops: {}, parallels: {}, })) + mockGetYjsSnapshot.mockResolvedValueOnce({ + snapshotBase64: buildWorkflowSnapshotBase64( + { + blocks: { + 'yjs-block': { + id: 'yjs-block', + type: 'agent', + name: 'Yjs Agent', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }, + { apiKey: { id: 'apiKey', value: 'from-yjs' } } + ), + }) const { applyWorkflowState } = await import('./apply-workflow-state') @@ -124,22 +166,35 @@ describe('applyWorkflowState', () => { 'Workflow Name' ) + expect(mockGetYjsSnapshot).toHaveBeenCalledWith( + 'workflow-1', + expect.objectContaining({ + targetKind: 'workflow', + sessionId: 'workflow-1', + workflowId: 'workflow-1', + entityKind: 'workflow', + entityId: 'workflow-1', + }) + ) expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledOnce() expect(mockSaveWorkflowToNormalizedTables.mock.calls[0][1]).toMatchObject({ blocks: { - 'normalized-block': expect.objectContaining({ id: 'normalized-block' }), + 'yjs-block': expect.objectContaining({ id: 'yjs-block' }), }, }) expect(mockSaveWorkflowToNormalizedTables.mock.calls[0][2]).toEqual(expect.any(Function)) expect(mockApplyWorkflowStateInSocketServer.mock.invocationCallOrder[0]).toBeLessThan( + mockGetYjsSnapshot.mock.invocationCallOrder[0] + ) + expect(mockGetYjsSnapshot.mock.invocationCallOrder[0]).toBeLessThan( mockSaveWorkflowToNormalizedTables.mock.invocationCallOrder[0] ) expect(mockSaveWorkflowToNormalizedTables.mock.invocationCallOrder[0]).toBeLessThan( mockDbUpdate.mock.invocationCallOrder[0] ) expect(mockUpdateSet).toHaveBeenCalledWith( - expect.not.objectContaining({ - variables: expect.anything(), + expect.objectContaining({ + variables: { apiKey: { id: 'apiKey', value: 'from-yjs' } }, }) ) }) @@ -154,6 +209,7 @@ describe('applyWorkflowState', () => { ) expect(mockSaveWorkflowToNormalizedTables).not.toHaveBeenCalled() + expect(mockGetYjsSnapshot).not.toHaveBeenCalled() expect(mockDbUpdate).not.toHaveBeenCalled() expect(mockDeleteYjsSessionInSocketServer).not.toHaveBeenCalled() }) diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index 2276a69da..20aa2d40f 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -1,5 +1,10 @@ import { db, workflow } from '@tradinggoose/db' import { eq } from 'drizzle-orm' +import * as Y from 'yjs' +import { + buildYjsTransportEnvelope, + serializeYjsTransportEnvelope, +} from '@/lib/copilot/review-sessions/identity' import { ensureUniqueBlockIds, ensureUniqueEdgeIds, @@ -8,12 +13,58 @@ import { import { applyWorkflowStateInSocketServer, deleteYjsSessionInSocketServer, + getYjsSnapshot, } from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot, + extractPersistedStateFromDoc, type WorkflowSnapshot, } from '@/lib/yjs/workflow-session' +async function readAppliedYjsWorkflowState(workflowId: string): Promise<{ + workflowState: WorkflowSnapshot + variables: Record +}> { + const snapshot = await getYjsSnapshot( + workflowId, + serializeYjsTransportEnvelope( + buildYjsTransportEnvelope({ + workspaceId: null, + entityKind: 'workflow', + entityId: workflowId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: workflowId, + }) + ) + ) + + if (!snapshot.snapshotBase64) { + throw new Error(`Workflow ${workflowId} Yjs state is missing`) + } + + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) + const state = extractPersistedStateFromDoc(doc) + return { + workflowState: createWorkflowSnapshot({ + ...(state.direction !== undefined ? { direction: state.direction } : {}), + blocks: state.blocks, + edges: state.edges, + loops: state.loops, + parallels: state.parallels, + lastSaved: new Date(state.lastSaved).toISOString(), + isDeployed: state.isDeployed, + deployedAt: state.deployedAt, + }), + variables: state.variables, + } + } finally { + doc.destroy() + } +} + export async function applyWorkflowState( workflowId: string, workflowState: WorkflowSnapshot, @@ -42,16 +93,17 @@ export async function applyWorkflowState( await applyWorkflowStateInSocketServer(workflowId, storedWorkflowState, variables, entityName) try { + const appliedState = await readAppliedYjsWorkflowState(workflowId) const saveResult = await saveWorkflowToNormalizedTables( workflowId, - storedWorkflowState, + appliedState.workflowState, async (tx) => { const [updatedWorkflow] = await tx .update(workflow) .set({ lastSynced: syncedAt, updatedAt: syncedAt, - ...(variables === undefined ? {} : { variables }), + variables: appliedState.variables, }) .where(eq(workflow.id, workflowId)) .returning({ id: workflow.id }) diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index b9e3cc1c6..d10361aa5 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -29,7 +29,6 @@ import { } from '@/lib/yjs/workflow-session' import { getState as getPersistedYjsState, - storeCanonicalState, storeState, } from '@/socket-server/yjs/persistence' @@ -199,10 +198,7 @@ async function bootstrapSavedEntityFromDb( metadata.set('entityName', workflowName) } const state = Y.encodeStateAsUpdate(doc) - await (descriptor.entityKind === 'workflow' ? storeCanonicalState : storeState)( - descriptor.yjsSessionId, - state - ) + await storeState(descriptor.yjsSessionId, state) return { descriptor, diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 6de1e5ead..da4826ef7 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -19,7 +19,6 @@ import { createHttpHandler } from '@/socket-server/routes/http' import { cleanupPersistence, getState, - storeCanonicalState, storeState, } from '@/socket-server/yjs/persistence' import { @@ -278,15 +277,6 @@ describe('Socket Server Index Integration', () => { expect(workflowReverted.statusCode).toBe(404) }) - it('bounds canonical local persistence entries', async () => { - for (let index = 0; index < 101; index++) { - await storeCanonicalState(`canonical-${index}`, new Uint8Array([index])) - } - - expect(await getState('canonical-0')).toBeNull() - expect(await getState('canonical-100')).toEqual(new Uint8Array([100])) - }) - it('should apply workflow state through the internal Yjs route', async () => { const response = await sendHttpRequestWithOptions( PORT, diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index 078e0ad82..7b5f3a005 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -18,7 +18,6 @@ import { deleteSession, getLastTouchedAt, getState, - storeCanonicalState, storeState, } from '@/socket-server/yjs/persistence' import { @@ -233,7 +232,7 @@ async function handleInternalYjsWorkflowApplyRequest( try { replaceWorkflowDocumentState(doc, body.workflowState, body.variables, body.entityName) - await storeCanonicalState(workflowId, Y.encodeStateAsUpdate(doc)) + await storeState(workflowId, Y.encodeStateAsUpdate(doc)) } finally { if (!liveDoc) doc.destroy() } diff --git a/apps/tradinggoose/socket-server/yjs/persistence.ts b/apps/tradinggoose/socket-server/yjs/persistence.ts index 10b1fdafe..604a3b84b 100644 --- a/apps/tradinggoose/socket-server/yjs/persistence.ts +++ b/apps/tradinggoose/socket-server/yjs/persistence.ts @@ -137,27 +137,6 @@ export async function storeState(sessionId: string, state: Uint8Array): Promise< evictOldestLocalEntries() } -export async function storeCanonicalState(sessionId: string, state: Uint8Array): Promise { - await storeState(sessionId, state) - - const mode = getRedisStorageMode() - if (mode === 'redis') { - const redis = getRedisClient() - if (!redis) { - return - } - - await redis.multi().persist(stateKey(sessionId)).persist(updatedAtKey(sessionId)).exec() - return - } - - const blob = localStore.get(sessionId) - if (blob) { - blob.expiresAt = null - } - evictOldestLocalEntries() -} - export async function hasSession(sessionId: string): Promise { const mode = getRedisStorageMode() diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts index 1454f1acd..326aaa61c 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts @@ -23,7 +23,6 @@ const mockSetPersistence = vi.fn() const mockSetupWSConnection = vi.fn() const mockGetState = vi.fn() const mockStoreState = vi.fn() -const mockStoreCanonicalState = vi.fn() class MockYjsAuthError extends Error { constructor( @@ -75,7 +74,6 @@ beforeEach(() => { mockSetupWSConnection.mockReset() mockGetState.mockReset() mockStoreState.mockReset() - mockStoreCanonicalState.mockReset() vi.doMock('@/lib/logs/console/logger', () => ({ createLogger: vi.fn(() => mockLogger), @@ -116,7 +114,6 @@ beforeEach(() => { vi.doMock('./persistence', () => ({ getState: mockGetState, storeState: mockStoreState, - storeCanonicalState: mockStoreCanonicalState, })) }) @@ -226,7 +223,7 @@ describe('handleYjsUpgrade', () => { sessionId, expect.objectContaining({ getState: expect.any(Function), - storeState: mockStoreCanonicalState, + storeState: mockStoreState, }) ) expect(wss.handleUpgrade).toHaveBeenCalledTimes(1) diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.ts index 3ee5f5689..ce2d682b2 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.ts @@ -9,7 +9,7 @@ import { getRuntimeStateFromUpdate, } from '@/lib/yjs/server/bootstrap-review-target' import { authenticateYjsConnection, YjsAuthError } from './auth' -import { getState, storeCanonicalState, storeState } from './persistence' +import { getState, storeState } from './persistence' import { getExistingDocument, setPersistence, setupWSConnection } from './upstream-utils' const logger = createLogger('YjsWsHandler') @@ -37,10 +37,10 @@ export function handleYjsUpgrade( const yjsSessionId = decodeURIComponent(match[1]) void authenticateAndPrepareUpgrade(yjsSessionId, url) - .then(({ userId, resolvedSessionId, canonical }) => { + .then(({ userId, resolvedSessionId }) => { setPersistence(resolvedSessionId, { getState, - storeState: canonical ? storeCanonicalState : storeState, + storeState, }) const yjsReq = request as YjsIncomingMessage @@ -66,7 +66,7 @@ export function handleYjsUpgrade( async function authenticateAndPrepareUpgrade( pathSessionId: string, url: URL -): Promise<{ userId: string; resolvedSessionId: string; canonical: boolean }> { +): Promise<{ userId: string; resolvedSessionId: string }> { const accessMode = parseAccessMode(url) const { userId, envelope } = await authenticateYjsConnection(url) @@ -114,10 +114,6 @@ async function authenticateAndPrepareUpgrade( return { userId, resolvedSessionId: pathSessionId, - canonical: - descriptor.reviewSessionId === null && - descriptor.entityKind === 'workflow' && - descriptor.entityId !== null, } } From aa089d31477fa68705e0be807a2e5bd715cbd2ae Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 23 Jun 2026 18:27:41 -0600 Subject: [PATCH 098/284] fix(api-key): reject malformed keys before lookup Trim incoming headers, fail fast on malformed API keys, and keep the stored-key lookup narrowed to the parsed key id. Co-authored-by: Codex \nCo-authored-by: BWJ2310 \nCo-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/api-key/service.test.ts | 68 +++++++++++++++++++ apps/tradinggoose/lib/api-key/service.ts | 15 ++-- 2 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 apps/tradinggoose/lib/api-key/service.test.ts diff --git a/apps/tradinggoose/lib/api-key/service.test.ts b/apps/tradinggoose/lib/api-key/service.test.ts new file mode 100644 index 000000000..19e41f3bc --- /dev/null +++ b/apps/tradinggoose/lib/api-key/service.test.ts @@ -0,0 +1,68 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { authenticateApiKeyMock, fromMock, selectMock, whereMock } = vi.hoisted(() => ({ + authenticateApiKeyMock: vi.fn(), + fromMock: vi.fn(), + selectMock: vi.fn(), + whereMock: vi.fn(), +})) + +vi.mock('@tradinggoose/db', () => ({ + db: { + select: selectMock, + }, +})) + +vi.mock('@/lib/api-key/auth', () => ({ + authenticateApiKey: (...args: unknown[]) => authenticateApiKeyMock(...args), +})) + +import { authenticateApiKeyFromHeader } from '@/lib/api-key/service' + +describe('authenticateApiKeyFromHeader', () => { + beforeEach(() => { + vi.resetAllMocks() + selectMock.mockReturnValue({ from: fromMock }) + fromMock.mockReturnValue({ where: whereMock }) + }) + + it('rejects malformed keys before querying stored keys', async () => { + await expect( + authenticateApiKeyFromHeader('sk-tradinggoose-malformed', { keyTypes: ['personal'] }) + ).resolves.toEqual({ success: false, error: 'Invalid API key' }) + + expect(selectMock).not.toHaveBeenCalled() + expect(authenticateApiKeyMock).not.toHaveBeenCalled() + }) + + it('authenticates valid keyed personal API keys through a narrowed lookup', async () => { + const token = 'sk-tradinggoose-key-1.secret' + whereMock.mockResolvedValue([ + { + id: 'key-1', + userId: 'user-1', + workspaceId: null, + type: 'personal', + key: 'stored-key', + expiresAt: null, + }, + ]) + authenticateApiKeyMock.mockResolvedValue(true) + + await expect( + authenticateApiKeyFromHeader(token, { keyTypes: ['personal'] }) + ).resolves.toEqual({ + success: true, + userId: 'user-1', + keyId: 'key-1', + keyType: 'personal', + workspaceId: undefined, + }) + + expect(whereMock).toHaveBeenCalledOnce() + expect(authenticateApiKeyMock).toHaveBeenCalledWith(token, 'stored-key') + }) +}) diff --git a/apps/tradinggoose/lib/api-key/service.ts b/apps/tradinggoose/lib/api-key/service.ts index 8948d58b6..9e2fdacb8 100644 --- a/apps/tradinggoose/lib/api-key/service.ts +++ b/apps/tradinggoose/lib/api-key/service.ts @@ -8,6 +8,7 @@ import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('ApiKeyService') +const API_KEY_WITH_ID_PATTERN = /^(?:sk-tradinggoose-|tradinggoose_)([^.]+)\.[^.]+$/ export interface ApiKeyAuthOptions { userId?: string @@ -111,9 +112,14 @@ export async function authenticateApiKeyFromHeader( apiKeyHeader: string, options: ApiKeyAuthOptions = {} ): Promise { - if (!apiKeyHeader) { + const apiKey = apiKeyHeader.trim() + if (!apiKey) { return { success: false, error: 'API key required' } } + const keyId = API_KEY_WITH_ID_PATTERN.exec(apiKey)?.[1] + if (!keyId) { + return { success: false, error: 'Invalid API key' } + } try { // Build query based on options @@ -135,10 +141,7 @@ export async function authenticateApiKeyFromHeader( // Apply filters const conditions = [] - const keyId = /^(?:sk-tradinggoose-|tradinggoose_)([^.]+)\.[^.]+$/.exec(apiKeyHeader)?.[1] - if (keyId) { - conditions.push(eq(apiKeyTable.id, keyId)) - } + conditions.push(eq(apiKeyTable.id, keyId)) if (options.userId) { conditions.push(eq(apiKeyTable.userId, options.userId)) @@ -176,7 +179,7 @@ export async function authenticateApiKeyFromHeader( } try { - const isValid = await authenticateApiKey(apiKeyHeader, storedKey.key) + const isValid = await authenticateApiKey(apiKey, storedKey.key) if (isValid) { return { success: true, From 866dc2ad249f794aff9736c2b74fa9f1b73c90c9 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 23 Jun 2026 19:21:02 -0600 Subject: [PATCH 099/284] feat(yjs): persist workflow session state through the socket bridge Keep workflow and entity session documents live in the socket server, route Yjs updates through the bridge, and carry workflow variables through saved state. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/indicators/custom/route.ts | 4 +- .../tradinggoose/app/api/mcp/servers/route.ts | 4 +- .../app/api/tools/custom/route.ts | 4 +- .../deployments/[version]/revert/route.ts | 2 +- .../app/api/workflows/[id]/route.test.ts | 18 +- .../app/api/workflows/[id]/route.ts | 44 ++-- .../api/workflows/[id]/state/route.test.ts | 16 +- .../app/api/workflows/[id]/state/route.ts | 2 +- .../sessions/[sessionId]/snapshot/route.ts | 28 +- .../workflow/workflow-mutation-utils.test.ts | 17 +- .../workflow/workflow-mutation-utils.ts | 14 +- apps/tradinggoose/lib/knowledge/service.ts | 4 +- apps/tradinggoose/lib/skills/operations.ts | 4 +- apps/tradinggoose/lib/workspaces/service.ts | 2 +- .../lib/yjs/server/apply-entity-state.ts | 18 +- .../yjs/server/apply-workflow-state.test.ts | 10 +- .../lib/yjs/server/apply-workflow-state.ts | 2 +- .../lib/yjs/server/snapshot-bridge.ts | 82 ++---- apps/tradinggoose/socket-server/index.test.ts | 90 +------ .../tradinggoose/socket-server/routes/http.ts | 245 +++++++++--------- .../components/control-bar/auto-layout.ts | 5 +- 21 files changed, 267 insertions(+), 348 deletions(-) diff --git a/apps/tradinggoose/app/api/indicators/custom/route.ts b/apps/tradinggoose/app/api/indicators/custom/route.ts index 50c9ea7ad..2217c010a 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -6,7 +6,7 @@ import { z } from 'zod' import { upsertIndicators } from '@/lib/indicators/custom/operations' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { tryDeleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { authenticateIndicatorRequest, checkWorkspacePermission } from '../utils' const logger = createLogger('IndicatorsAPI') @@ -230,7 +230,7 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Indicator not found' }, { status: 404 }) } - await tryDeleteYjsSessionInSocketServer(indicatorId) + await deleteYjsSessionInSocketServer(indicatorId) logger.info(`[${requestId}] Deleted indicator ${indicatorId}`) return NextResponse.json({ success: true }, { status: 200 }) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index a7423a8a3..23b4c0058 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -8,7 +8,7 @@ import { mcpService } from '@/lib/mcp/service' import type { McpTransport } from '@/lib/mcp/types' import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { tryDeleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { CreateMcpServerSchema } from './schema' const logger = createLogger('McpServersAPI') @@ -172,7 +172,7 @@ export const DELETE = withMcpAuth('write')( ) } - await tryDeleteYjsSessionInSocketServer(serverId) + await deleteYjsSessionInSocketServer(serverId) mcpService.clearCache(workspaceId) logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) diff --git a/apps/tradinggoose/app/api/tools/custom/route.ts b/apps/tradinggoose/app/api/tools/custom/route.ts index bc2f0be19..96d3b5721 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.ts @@ -9,7 +9,7 @@ import { CustomToolUpsertRequestSchema } from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' -import { tryDeleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('CustomToolsAPI') @@ -184,7 +184,7 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Tool not found' }, { status: 404 }) } - await tryDeleteYjsSessionInSocketServer(toolId) + await deleteYjsSessionInSocketServer(toolId) logger.info(`[${requestId}] Deleted tool: ${toolId}`) return NextResponse.json({ success: true }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts index 1a2e56199..a891d1322 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -74,7 +74,7 @@ export async function POST( } const now = new Date() - const revertVariables = deployedState.variables || undefined + const revertVariables = deployedState.variables || {} const revertedState = { blocks: deployedState.blocks, diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index b0f140e71..b95d4a830 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -18,7 +18,7 @@ describe('Workflow By ID API Route', () => { const mockReadWorkflowById = vi.fn() const mockReadWorkflowAccessContext = vi.fn() - const mockTryDeleteYjsSessionInSocketServer = vi.fn() + const mockDeleteYjsSessionInSocketServer = vi.fn() const mockLoadWorkflowState = vi.fn() const mockApplyWorkflowEntityName = vi.fn() const mockWorkflowRenameState = { @@ -74,10 +74,10 @@ describe('Workflow By ID API Route', () => { mockReadWorkflowById.mockReset() mockReadWorkflowAccessContext.mockReset() - mockTryDeleteYjsSessionInSocketServer.mockReset() + mockDeleteYjsSessionInSocketServer.mockReset() mockLoadWorkflowState.mockReset() mockApplyWorkflowEntityName.mockReset() - mockTryDeleteYjsSessionInSocketServer.mockResolvedValue(undefined) + mockDeleteYjsSessionInSocketServer.mockResolvedValue(undefined) mockLoadWorkflowState.mockResolvedValue(null) mockApplyWorkflowEntityName.mockResolvedValue({ id: 'workflow-123', @@ -90,7 +90,7 @@ describe('Workflow By ID API Route', () => { })) vi.doMock('@/lib/yjs/server/snapshot-bridge', () => ({ - tryDeleteYjsSessionInSocketServer: mockTryDeleteYjsSessionInSocketServer, + deleteYjsSessionInSocketServer: mockDeleteYjsSessionInSocketServer, })) vi.doMock('@/lib/workflows/utils', () => ({ @@ -391,7 +391,7 @@ describe('Workflow By ID API Route', () => { isOwner: true, isWorkspaceOwner: false, }) - mockTryDeleteYjsSessionInSocketServer.mockImplementationOnce(async () => { + mockDeleteYjsSessionInSocketServer.mockImplementationOnce(async () => { events.push('socket-delete') }) @@ -418,7 +418,7 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(200) const data = await response.json() expect(data.success).toBe(true) - expect(mockTryDeleteYjsSessionInSocketServer).toHaveBeenCalledWith('workflow-123') + expect(mockDeleteYjsSessionInSocketServer).toHaveBeenCalledWith('workflow-123') expect(events).toEqual(['db-delete', 'socket-delete']) }) @@ -467,7 +467,7 @@ describe('Workflow By ID API Route', () => { const data = await response.json() expect(data.error).toBe('Internal server error') expect(deleteWhereMock).toHaveBeenCalledOnce() - expect(mockTryDeleteYjsSessionInSocketServer).not.toHaveBeenCalled() + expect(mockDeleteYjsSessionInSocketServer).not.toHaveBeenCalled() }) it('should allow admin to delete workspace workflow', async () => { @@ -538,7 +538,7 @@ describe('Workflow By ID API Route', () => { isOwner: true, isWorkspaceOwner: false, }) - mockTryDeleteYjsSessionInSocketServer.mockResolvedValueOnce(undefined) + mockDeleteYjsSessionInSocketServer.mockResolvedValueOnce(undefined) vi.doMock('@tradinggoose/db', () => ({ db: { @@ -561,7 +561,7 @@ describe('Workflow By ID API Route', () => { const data = await response.json() expect(data.success).toBe(true) expect(deleteWhereMock).toHaveBeenCalledOnce() - expect(mockTryDeleteYjsSessionInSocketServer).toHaveBeenCalledWith('workflow-123') + expect(mockDeleteYjsSessionInSocketServer).toHaveBeenCalledWith('workflow-123') }) it('should deny deletion for non-admin users', async () => { diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index be6764f34..8b6bea411 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -12,7 +12,7 @@ import { generateRequestId } from '@/lib/utils' import { loadWorkflowState } from '@/lib/workflows/db-helpers' import { readWorkflowAccessContext, readWorkflowById } from '@/lib/workflows/utils' import { applyWorkflowEntityName } from '@/lib/yjs/server/apply-workflow-state' -import { tryDeleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' const logger = createLogger('WorkflowByIdAPI') @@ -287,7 +287,7 @@ export async function DELETE( await db.delete(workflow).where(eq(workflow.id, workflowId)) - await tryDeleteYjsSessionInSocketServer(workflowId) + await deleteYjsSessionInSocketServer(workflowId) const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully deleted workflow ${workflowId} in ${elapsed}ms`) @@ -363,33 +363,21 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ if (updates.description !== undefined) updateData.description = updates.description if (updates.folderId !== undefined) updateData.folderId = updates.folderId - let updatedWorkflow = null - if (updates.name !== undefined) { - const workflowState = await loadWorkflowState(workflowId) - if (!workflowState) { - logger.warn(`[${requestId}] Workflow ${workflowId} is missing saved state for rename`) - return NextResponse.json({ error: 'Workflow state is missing' }, { status: 409 }) - } - - updatedWorkflow = await applyWorkflowEntityName( - workflowId, - createWorkflowSnapshot({ - ...workflowState, - lastSaved: new Date(workflowState.lastSaved).toISOString(), - }), - workflowState.variables, - updates.name, - updateData - ) - } - - if (!updatedWorkflow) { - ;[updatedWorkflow] = await db - .update(workflow) - .set(updateData) - .where(eq(workflow.id, workflowId)) - .returning() + const workflowState = await loadWorkflowState(workflowId) + if (!workflowState) { + logger.warn(`[${requestId}] Workflow ${workflowId} is missing saved state for update`) + return NextResponse.json({ error: 'Workflow state is missing' }, { status: 409 }) } + const updatedWorkflow = await applyWorkflowEntityName( + workflowId, + createWorkflowSnapshot({ + ...workflowState, + lastSaved: new Date(workflowState.lastSaved).toISOString(), + }), + workflowState.variables, + updates.name ?? workflowData.name, + updateData + ) const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully updated workflow ${workflowId} in ${elapsed}ms`, { diff --git a/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts index d6beccf2d..fe114fb9d 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts @@ -31,6 +31,7 @@ describe('Workflow State API Route', () => { edges: [], loops: {}, parallels: {}, + variables: {}, } beforeEach(() => { @@ -110,19 +111,16 @@ describe('Workflow State API Route', () => { vi.clearAllMocks() }) - it('preserves current Yjs variables when the request body omits them', async () => { + it('rejects workflow saves that omit variables', async () => { const { PUT } = await import('@/app/api/workflows/[id]/state/route') - const response = await PUT(createRequest(validStateBody), { + const bodyWithoutVariables = { ...validStateBody } as Record + delete bodyWithoutVariables.variables + const response = await PUT(createRequest(bodyWithoutVariables), { params: Promise.resolve({ id: 'workflow-id' }), }) - expect(response.status).toBe(200) - expect(applyWorkflowStateMock).toHaveBeenCalledWith( - 'workflow-id', - expect.any(Object), - undefined, - 'Workflow' - ) + expect(response.status).toBe(400) + expect(applyWorkflowStateMock).not.toHaveBeenCalled() }) it('replaces variables when the request body includes them', async () => { diff --git a/apps/tradinggoose/app/api/workflows/[id]/state/route.ts b/apps/tradinggoose/app/api/workflows/[id]/state/route.ts index 11074de6c..f4b626d21 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/state/route.ts @@ -109,7 +109,7 @@ const WorkflowStateSchema = z.object({ lastSaved: z.number().optional(), isDeployed: z.boolean().optional(), deployedAt: z.coerce.date().optional(), - variables: z.record(z.any()).optional(), + variables: z.record(z.any()), }) /** diff --git a/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts b/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts index a9255aeba..145b9af21 100644 --- a/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts +++ b/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts @@ -1,5 +1,4 @@ import { type NextRequest, NextResponse } from 'next/server' -import * as Y from 'yjs' import { getSession } from '@/lib/auth' import { buildReviewTargetDescriptorFromEnvelope, @@ -7,16 +6,16 @@ import { } from '@/lib/copilot/review-sessions/identity' import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' import { mcpService } from '@/lib/mcp/service' -import { getEntityFields } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { - applySavedEntityPersistedState, + persistSavedEntityYjsState, SavedEntityPersistenceError, } from '@/lib/yjs/server/apply-entity-state' import { ReviewTargetBootstrapError, readBootstrappedReviewTargetSnapshot, } from '@/lib/yjs/server/bootstrap-review-target' +import { applyYjsUpdateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' export const dynamic = 'force-dynamic' @@ -121,22 +120,13 @@ export async function POST( return NextResponse.json({ error: 'updateBase64 is required' }, { status: 400 }) } - const snapshot = await readBootstrappedReviewTargetSnapshot(descriptor) - const doc = new Y.Doc() - try { - if (snapshot.snapshotBase64) { - Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) - } - Y.applyUpdate(doc, Buffer.from(updateBase64, 'base64')) - await applySavedEntityPersistedState( - entityKind, - descriptor.entityId, - descriptor.workspaceId, - getEntityFields(doc, entityKind) - ) - } finally { - doc.destroy() - } + await applyYjsUpdateInSocketServer( + descriptor.yjsSessionId, + request.nextUrl.search, + updateBase64 + ) + await persistSavedEntityYjsState(entityKind, descriptor.entityId, descriptor.workspaceId) + if (descriptor.entityKind === 'mcp_server') { mcpService.clearCache(descriptor.workspaceId) } diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.test.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.test.ts index 67e67b9ef..1b3b6f77b 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import * as Y from 'yjs' import { loadBaseWorkflowState } from '@/lib/copilot/tools/server/workflow/workflow-mutation-utils' -import { setWorkflowState, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { setVariables, setWorkflowState, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' const mocks = vi.hoisted(() => ({ readBootstrappedReviewTargetSnapshot: vi.fn(), @@ -17,10 +17,14 @@ vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ mocks.readBootstrappedReviewTargetSnapshot(...args), })) -function encodeWorkflowSnapshot(workflowState: WorkflowSnapshot): string { +function encodeWorkflowSnapshot( + workflowState: WorkflowSnapshot, + variables: Record = {} +): string { const doc = new Y.Doc() try { setWorkflowState(doc, workflowState, 'test') + setVariables(doc, variables, 'test') return Buffer.from(Y.encodeStateAsUpdate(doc)).toString('base64') } finally { doc.destroy() @@ -59,7 +63,9 @@ describe('workflow mutation Yjs loader', () => { isOwner: false, }) mocks.readBootstrappedReviewTargetSnapshot.mockResolvedValue({ - snapshotBase64: encodeWorkflowSnapshot(workflowState), + snapshotBase64: encodeWorkflowSnapshot(workflowState, { + token: { id: 'token', name: 'token', value: 'secret' }, + }), descriptor: {}, runtime: { docState: 'active', replaySafe: true, reseededFromCanonical: false }, }) @@ -81,6 +87,11 @@ describe('workflow mutation Yjs loader', () => { }) ) expect(result.blocks.fn1.name).toBe('Function') + expect(result.variables.token).toMatchObject({ + id: 'token', + name: 'token', + value: 'secret', + }) }) it('rejects workflow edits without authenticated user context', async () => { diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts index 8db409bdf..b4252f028 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts @@ -12,6 +12,7 @@ import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' import { createWorkflowSnapshot, + getVariablesSnapshot, readWorkflowSnapshot, type WorkflowSnapshot, } from '@/lib/yjs/workflow-session' @@ -101,7 +102,7 @@ function buildWorkflowDocumentPreviewDiff( export async function loadBaseWorkflowState( workflowId: string, context?: ServerToolExecutionContext -): Promise { +): Promise }> { const userId = context?.userId?.trim() if (!userId) { throw new Error('Authenticated user is required to edit workflow state') @@ -129,7 +130,10 @@ export async function loadBaseWorkflowState( const doc = new Y.Doc() try { Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) - return createWorkflowSnapshot(readWorkflowSnapshot(doc)) + return { + ...createWorkflowSnapshot(readWorkflowSnapshot(doc)), + variables: getVariablesSnapshot(doc), + } } finally { doc.destroy() } @@ -137,7 +141,7 @@ export async function loadBaseWorkflowState( export function buildWorkflowMutationResult(params: { workflowId: string - baseWorkflowState: WorkflowSnapshot + baseWorkflowState: WorkflowSnapshot & { variables: Record } nextWorkflowState: WorkflowSnapshot renderEntityDocument: (workflowState: WorkflowSnapshot) => string documentFormat: string @@ -173,6 +177,7 @@ export function buildWorkflowMutationResult(params: { entityDocument, documentFormat: params.documentFormat, workflowState: finalWorkflowState, + variables: params.baseWorkflowState.variables, reviewBaseStateHash: hashServerToolReviewBase(baseWorkflowState), preview: { ...preview, @@ -196,7 +201,8 @@ export async function resolveWorkflowMutationResultForExecution( assertAcceptedServerToolReviewBase(context, result.reviewBaseStateHash) await applyWorkflowState( result.entityId, - createWorkflowSnapshot(result.workflowState as Partial) + createWorkflowSnapshot(result.workflowState as Partial), + result.variables ) const { diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index 4262edb51..3a3053e79 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -22,7 +22,7 @@ import type { import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/permissions/utils' import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' -import { tryDeleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('KnowledgeBaseService') @@ -437,7 +437,7 @@ export async function deleteKnowledgeBase( updatedAt: now, }) .where(eq(knowledgeBase.id, knowledgeBaseId)) - await tryDeleteYjsSessionInSocketServer(knowledgeBaseId) + await deleteYjsSessionInSocketServer(knowledgeBaseId) logger.info(`[${requestId}] Soft deleted knowledge base: ${knowledgeBaseId}`) } diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index 2d6cde8e5..55f25428f 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -10,7 +10,7 @@ import { } from '@/lib/skills/import-export' import { generateRequestId } from '@/lib/utils' import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' -import { tryDeleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('SkillsOperations') @@ -54,7 +54,7 @@ export async function deleteSkill(params: { return false } - await tryDeleteYjsSessionInSocketServer(params.skillId) + await deleteYjsSessionInSocketServer(params.skillId) logger.info(`Deleted skill ${params.skillId}`) return true diff --git a/apps/tradinggoose/lib/workspaces/service.ts b/apps/tradinggoose/lib/workspaces/service.ts index 3aebfefeb..19689cb84 100644 --- a/apps/tradinggoose/lib/workspaces/service.ts +++ b/apps/tradinggoose/lib/workspaces/service.ts @@ -117,7 +117,7 @@ export async function createWorkspace(userId: string, name: string) { lastSaved, isDeployed: false, }), - undefined, + {}, 'default-agent' ) } catch (error) { diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index af9977d24..f380ed484 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -186,13 +186,21 @@ export async function applySavedEntityPersistedState( const normalizedFields = normalizeSavedEntityFields(entityKind, fields) await applyEntityStateInSocketServer(entityId, entityKind, normalizedFields) try { - const yjsFields = normalizeSavedEntityFields( - entityKind, - await readAppliedYjsEntityFields(entityKind, entityId, workspaceId) - ) - await persistSavedEntityState(entityKind, entityId, yjsFields) + await persistSavedEntityYjsState(entityKind, entityId, workspaceId) } catch (error) { await deleteYjsSessionInSocketServer(entityId) throw error } } + +export async function persistSavedEntityYjsState( + entityKind: SavedEntityKind, + entityId: string, + workspaceId: string +): Promise { + const yjsFields = normalizeSavedEntityFields( + entityKind, + await readAppliedYjsEntityFields(entityKind, entityId, workspaceId) + ) + await persistSavedEntityState(entityKind, entityId, yjsFields) +} diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts index 6c166aa55..a08929199 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts @@ -151,7 +151,7 @@ describe('applyWorkflowState', () => { loops: {}, parallels: {}, }, - undefined, + {}, 'Workflow Name' ) @@ -162,7 +162,7 @@ describe('applyWorkflowState', () => { 'normalized-block': expect.objectContaining({ id: 'normalized-block' }), }, }), - undefined, + {}, 'Workflow Name' ) @@ -204,7 +204,7 @@ describe('applyWorkflowState', () => { const { applyWorkflowState } = await import('./apply-workflow-state') - await expect(applyWorkflowState('workflow-1', emptyWorkflowState)).rejects.toThrow( + await expect(applyWorkflowState('workflow-1', emptyWorkflowState, {})).rejects.toThrow( 'fetch failed' ) @@ -222,7 +222,9 @@ describe('applyWorkflowState', () => { const { applyWorkflowState } = await import('./apply-workflow-state') - await expect(applyWorkflowState('workflow-1', emptyWorkflowState)).rejects.toThrow('db failed') + await expect(applyWorkflowState('workflow-1', emptyWorkflowState, {})).rejects.toThrow( + 'db failed' + ) expect(mockDeleteYjsSessionInSocketServer).toHaveBeenCalledWith('workflow-1') expect(mockDbUpdate).not.toHaveBeenCalled() diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index 20aa2d40f..213f313cf 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -68,7 +68,7 @@ async function readAppliedYjsWorkflowState(workflowId: string): Promise<{ export async function applyWorkflowState( workflowId: string, workflowState: WorkflowSnapshot, - variables?: Record, + variables: Record, entityName?: string ): Promise { const syncedAt = new Date() diff --git a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts index a27ffc498..dfadc8622 100644 --- a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts +++ b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts @@ -58,6 +58,18 @@ async function fetchFromSocketServer( return response } +async function postJsonToSocketServer(path: string, body: unknown): Promise { + await fetchFromSocketServer( + new URL(path, getSocketServerUrl()), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + 10000 + ) +} + export async function getYjsSnapshot( sessionId: string, params?: Record @@ -82,25 +94,13 @@ export async function applyWorkflowStateInSocketServer( variables?: Record, entityName?: string ): Promise { - const url = new URL( + await postJsonToSocketServer( `/internal/yjs/workflows/${encodeURIComponent(workflowId)}/apply-state`, - getSocketServerUrl() - ) - - await fetchFromSocketServer( - url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - workflowState, - ...(variables === undefined ? {} : { variables }), - ...(entityName ? { entityName } : {}), - }), - }, - 10000 + workflowState, + ...(variables === undefined ? {} : { variables }), + ...(entityName ? { entityName } : {}), + } ) } @@ -109,21 +109,20 @@ export async function applyEntityStateInSocketServer( entityKind: string, fields: Record ): Promise { - const url = new URL( + await postJsonToSocketServer( `/internal/yjs/entities/${encodeURIComponent(entityId)}/apply-state`, - getSocketServerUrl() + { entityKind, fields } ) +} - await fetchFromSocketServer( - url, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ entityKind, fields }), - }, - 10000 +export async function applyYjsUpdateInSocketServer( + sessionId: string, + search: string, + updateBase64: string +): Promise { + await postJsonToSocketServer( + `/internal/yjs/sessions/${encodeURIComponent(sessionId)}/apply-update${search}`, + { updateBase64 } ) } @@ -141,28 +140,3 @@ export async function deleteYjsSessionInSocketServer(sessionId: string): Promise 10000 ) } - -export async function tryDeleteYjsSessionInSocketServer(sessionId: string): Promise { - try { - await deleteYjsSessionInSocketServer(sessionId) - } catch { - // Yjs session cleanup must not decide durable DB deletion. - } -} - -export async function clearYjsSessionReseededFromCanonicalInSocketServer( - sessionId: string -): Promise { - const url = new URL( - `/internal/yjs/sessions/${encodeURIComponent(sessionId)}/clear-reseeded`, - getSocketServerUrl() - ) - - await fetchFromSocketServer( - url, - { - method: 'POST', - }, - 10000 - ) -} diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index da4826ef7..8d50c0b8b 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -47,6 +47,14 @@ vi.mock('@/lib/redis', () => ({ })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ + bootstrapReviewTarget: vi.fn(async (descriptor) => ({ + descriptor, + runtime: { + docState: 'active', + replaySafe: false, + reseededFromCanonical: false, + }, + })), getRuntimeStateFromDoc: vi.fn(() => ({ docState: 'active', replaySafe: false, @@ -320,7 +328,7 @@ describe('Socket Server Index Integration', () => { ) expect(response.statusCode).toBe(200) - expect(await getExistingDocument('workflow-1')).toBeNull() + expect(await getExistingDocument('workflow-1')).toBeTruthy() const persisted = await getState('workflow-1') expect(persisted).toBeTruthy() @@ -369,7 +377,7 @@ describe('Socket Server Index Integration', () => { ) expect(response.statusCode).toBe(200) - expect(await getExistingDocument('skill-1')).toBeNull() + expect(await getExistingDocument('skill-1')).toBeTruthy() const persisted = await getState('skill-1') expect(persisted).toBeTruthy() @@ -485,84 +493,6 @@ describe('Socket Server Index Integration', () => { }) }) - it('should clear reseededFromCanonical on the live Yjs session doc', async () => { - setPersistence('review-session-live', { getState, storeState }) - getDocument('review-session-live') - const liveDoc = await getExistingDocument('review-session-live') - - liveDoc!.transact(() => { - liveDoc!.getMap('fields').set('title', 'Shared Tool') - liveDoc!.getMap('metadata').set('reseededFromCanonical', true) - }, 'test') - await storeState('review-session-live', Y.encodeStateAsUpdate(liveDoc!)) - - const response = await sendHttpRequestWithOptions( - PORT, - '/internal/yjs/sessions/review-session-live/clear-reseeded', - { - method: 'POST', - headers: { - 'x-internal-secret': INTERNAL_SECRET, - }, - } - ) - - expect(response.statusCode).toBe(200) - expect(JSON.parse(response.body)).toEqual({ success: true, updated: true }) - expect(await getExistingDocument('review-session-live')).toBe(liveDoc) - expect(liveDoc!.getMap('metadata').get('reseededFromCanonical')).toBeUndefined() - - const persisted = await getState('review-session-live') - const doc = new Y.Doc() - try { - Y.applyUpdate(doc, persisted!) - expect(doc.getMap('fields').get('title')).toBe('Shared Tool') - expect(doc.getMap('metadata').get('reseededFromCanonical')).toBeUndefined() - } finally { - doc.destroy() - } - }) - - it('should clear reseededFromCanonical from persisted session state without overwriting fields', async () => { - const persistedDoc = new Y.Doc() - try { - persistedDoc.transact(() => { - persistedDoc.getMap('fields').set('title', 'Persisted Tool') - persistedDoc.getMap('metadata').set('reseededFromCanonical', true) - }, 'test') - await storeState('review-session-cold', Y.encodeStateAsUpdate(persistedDoc)) - } finally { - persistedDoc.destroy() - } - - expect(await getExistingDocument('review-session-cold')).toBeNull() - - const response = await sendHttpRequestWithOptions( - PORT, - '/internal/yjs/sessions/review-session-cold/clear-reseeded', - { - method: 'POST', - headers: { - 'x-internal-secret': INTERNAL_SECRET, - }, - } - ) - - expect(response.statusCode).toBe(200) - expect(JSON.parse(response.body)).toEqual({ success: true, updated: true }) - expect(await getExistingDocument('review-session-cold')).toBeNull() - - const persisted = await getState('review-session-cold') - const doc = new Y.Doc() - try { - Y.applyUpdate(doc, persisted!) - expect(doc.getMap('fields').get('title')).toBe('Persisted Tool') - expect(doc.getMap('metadata').get('reseededFromCanonical')).toBeUndefined() - } finally { - doc.destroy() - } - }) - it('should delete the live workflow doc and persisted session through the internal Yjs route', async () => { setPersistence('workflow-2', { getState, storeState }) getDocument('workflow-2') diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index 7b5f3a005..85646bc10 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -8,6 +8,7 @@ import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' import { env } from '@/lib/env' import { seedEntitySession } from '@/lib/yjs/entity-session' import { + bootstrapReviewTarget, getRuntimeStateFromDoc, getRuntimeStateFromUpdate, } from '@/lib/yjs/server/bootstrap-review-target' @@ -22,8 +23,10 @@ import { } from '@/socket-server/yjs/persistence' import { flushDocumentPersistence, + getDocument, getExistingDocument, removeDocument, + setPersistence, } from '@/socket-server/yjs/upstream-utils' interface Logger { @@ -45,8 +48,8 @@ const INTERNAL_SECRET_HEADER = 'x-internal-secret' const INTERNAL_YJS_WORKFLOW_APPLY_PATH = /^\/internal\/yjs\/workflows\/([^/]+)\/apply-state$/ const INTERNAL_YJS_ENTITY_APPLY_PATH = /^\/internal\/yjs\/entities\/([^/]+)\/apply-state$/ const INTERNAL_YJS_SNAPSHOT_PATH = /^\/internal\/yjs\/sessions\/([^/]+)\/snapshot$/ -const INTERNAL_YJS_SESSION_CLEAR_RESEEDED_PATH = - /^\/internal\/yjs\/sessions\/([^/]+)\/clear-reseeded$/ +const INTERNAL_YJS_SESSION_APPLY_UPDATE_PATH = + /^\/internal\/yjs\/sessions\/([^/]+)\/apply-update$/ const INTERNAL_YJS_SESSION_PATH = /^\/internal\/yjs\/sessions\/([^/]+)$/ type ApplyWorkflowStateRequest = { @@ -84,6 +87,11 @@ function isInternalRequestAuthorized(req: IncomingMessage): boolean { return typeof providedHeader === 'string' && providedHeader === expectedSecret } +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(body)) +} + function rejectUnauthorizedRequest( req: IncomingMessage, res: ServerResponse, @@ -97,8 +105,7 @@ function rejectUnauthorizedRequest( path: req.url, method: req.method, }) - res.writeHead(401, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Unauthorized' })) + sendJson(res, 401, { error: 'Unauthorized' }) return true } @@ -219,6 +226,29 @@ function clearSessionReseededFromCanonical(doc: Y.Doc): void { }, YJS_ORIGINS.SYSTEM) } +async function getInitializedSessionDocument(sessionId: string): Promise { + setPersistence(sessionId, { getState, storeState }) + const doc = getDocument(sessionId) as Y.Doc & { whenInitialized?: Promise } + await doc.whenInitialized + return doc +} + +async function getBootstrappedApplyDocument( + descriptor: ReturnType +): Promise { + if (!(await getExistingDocument(descriptor.yjsSessionId)) && !(await getState(descriptor.yjsSessionId))) { + if (!descriptor.entityId) { + throw new InvalidInternalYjsRequestError('Saved Yjs session required') + } + const bootstrapped = await bootstrapReviewTarget(descriptor) + if (!bootstrapped.runtime || bootstrapped.runtime.docState !== 'active') { + throw new Error('Yjs review target is not active') + } + } + + return getInitializedSessionDocument(descriptor.yjsSessionId) +} + async function handleInternalYjsWorkflowApplyRequest( req: IncomingMessage, res: ServerResponse, @@ -227,27 +257,25 @@ async function handleInternalYjsWorkflowApplyRequest( ): Promise { try { const body = parseApplyWorkflowStateRequest(await readJsonBody(req)) - const liveDoc = await getExistingDocument(workflowId) - const doc = liveDoc ?? new Y.Doc() - - try { - replaceWorkflowDocumentState(doc, body.workflowState, body.variables, body.entityName) - await storeState(workflowId, Y.encodeStateAsUpdate(doc)) - } finally { - if (!liveDoc) doc.destroy() - } - - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ success: true })) + const doc = await getBootstrappedApplyDocument({ + workspaceId: null, + entityKind: 'workflow', + entityId: workflowId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: workflowId, + }) + + replaceWorkflowDocumentState(doc, body.workflowState, body.variables, body.entityName) + await flushDocumentPersistence(workflowId) + + sendJson(res, 200, { success: true }) } catch (error) { logger.error('Error applying workflow state', { error, workflowId }) const status = error instanceof InvalidInternalYjsRequestError ? 400 : 500 - res.writeHead(status, { 'Content-Type': 'application/json' }) - res.end( - JSON.stringify({ - error: error instanceof Error ? error.message : 'Failed to apply workflow state', - }) - ) + sendJson(res, status, { + error: error instanceof Error ? error.message : 'Failed to apply workflow state', + }) } } @@ -259,91 +287,84 @@ async function handleInternalYjsEntityApplyRequest( ): Promise { try { const body = parseApplyEntityStateRequest(await readJsonBody(req)) - const liveDoc = await getExistingDocument(entityId) - const doc = liveDoc ?? new Y.Doc() - - try { - seedEntitySession(doc, { - entityKind: body.entityKind, - payload: body.fields, - }) - clearSessionReseededFromCanonical(doc) - await storeState(entityId, Y.encodeStateAsUpdate(doc)) - } finally { - if (!liveDoc) doc.destroy() - } - - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ success: true })) + const doc = await getBootstrappedApplyDocument({ + workspaceId: null, + entityKind: body.entityKind, + entityId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: entityId, + }) + + seedEntitySession(doc, { + entityKind: body.entityKind, + payload: body.fields, + }) + clearSessionReseededFromCanonical(doc) + await flushDocumentPersistence(entityId) + + sendJson(res, 200, { success: true }) } catch (error) { logger.error('Error applying entity state', { error, entityId }) const status = error instanceof InvalidInternalYjsRequestError ? 400 : 500 - res.writeHead(status, { 'Content-Type': 'application/json' }) - res.end( - JSON.stringify({ - error: error instanceof Error ? error.message : 'Failed to apply entity state', - }) - ) + sendJson(res, status, { + error: error instanceof Error ? error.message : 'Failed to apply entity state', + }) } } -async function handleInternalYjsSessionDeleteRequest( +async function handleInternalYjsSessionApplyUpdateRequest( + req: IncomingMessage, + parsedUrl: URL, res: ServerResponse, logger: Logger, sessionId: string ): Promise { try { - removeDocument(sessionId) - await deleteSession(sessionId) + const envelope = parseYjsTransportEnvelope(Object.fromEntries(parsedUrl.searchParams)) + if (envelope.sessionId !== sessionId) { + sendJson(res, 409, { error: 'Session ID mismatch', sessionId }) + return + } + + const descriptor = buildReviewTargetDescriptorFromEnvelope(envelope) + const body = await readJsonBody(req) + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new InvalidInternalYjsRequestError('Invalid apply session update body') + } + const updateBase64 = (body as Record).updateBase64 + if (typeof updateBase64 !== 'string' || !updateBase64) { + throw new InvalidInternalYjsRequestError('updateBase64 is required') + } + const doc = await getBootstrappedApplyDocument(descriptor) + + Y.applyUpdate(doc, Buffer.from(updateBase64, 'base64'), YJS_ORIGINS.SAVE) + clearSessionReseededFromCanonical(doc) + await flushDocumentPersistence(sessionId) - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ success: true })) + sendJson(res, 200, { success: true }) } catch (error) { - logger.error('Error deleting Yjs session', { error, sessionId }) - res.writeHead(500, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Failed to delete Yjs session' })) + logger.error('Error applying Yjs session update', { error, path: parsedUrl.pathname }) + const status = error instanceof InvalidInternalYjsRequestError ? 400 : 500 + sendJson(res, status, { + error: error instanceof Error ? error.message : 'Failed to apply session update', + }) } } -async function handleInternalYjsSessionClearReseededRequest( +async function handleInternalYjsSessionDeleteRequest( res: ServerResponse, logger: Logger, sessionId: string ): Promise { try { - const liveDoc = await getExistingDocument(sessionId) - if (liveDoc) { - clearSessionReseededFromCanonical(liveDoc) - await storeState(sessionId, Y.encodeStateAsUpdate(liveDoc)) - - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ success: true, updated: true })) - return - } - - const state = await getState(sessionId) - if (!state) { - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ success: true, updated: false })) - return - } - - const doc = new Y.Doc() - - try { - Y.applyUpdate(doc, state) - clearSessionReseededFromCanonical(doc) - await storeState(sessionId, Y.encodeStateAsUpdate(doc)) - } finally { - doc.destroy() - } + removeDocument(sessionId) + await deleteSession(sessionId) - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ success: true, updated: true })) + sendJson(res, 200, { success: true }) } catch (error) { - logger.error('Error clearing reseeded flag from Yjs session', { error, sessionId }) - res.writeHead(500, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Failed to clear reseeded flag' })) + logger.error('Error deleting Yjs session', { error, sessionId }) + sendJson(res, 500, { error: 'Failed to delete Yjs session' }) } } @@ -372,8 +393,7 @@ async function handleInternalYjsSnapshotRequest( try { const envelope = parseYjsTransportEnvelope(Object.fromEntries(parsedUrl.searchParams)) if (envelope.sessionId !== sessionId) { - res.writeHead(409, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Session ID mismatch', sessionId })) + sendJson(res, 409, { error: 'Session ID mismatch', sessionId }) return } @@ -381,26 +401,21 @@ async function handleInternalYjsSnapshotRequest( const { liveDoc, state, touchedAt } = await getLiveOrPersistedYjsState(sessionId) if (!state) { - res.writeHead(404, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Session not found', sessionId })) + sendJson(res, 404, { error: 'Session not found', sessionId }) return } const runtime = liveDoc ? getRuntimeStateFromDoc(liveDoc) : getRuntimeStateFromUpdate(state) - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end( - JSON.stringify({ - snapshotBase64: Buffer.from(state).toString('base64'), - descriptor, - runtime, - touchedAt, - }) - ) + sendJson(res, 200, { + snapshotBase64: Buffer.from(state).toString('base64'), + descriptor, + runtime, + touchedAt, + }) } catch (error) { logger.error('Error getting Yjs snapshot', { error, path: parsedUrl.pathname }) - res.writeHead(400, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Failed to get snapshot' })) + sendJson(res, 400, { error: 'Failed to get snapshot' }) } } @@ -454,14 +469,14 @@ async function handleInternalYjsRequest( return true } - const clearReseededId = matchInternalRoute( + const applyUpdateId = matchInternalRoute( parsedUrl.pathname, - INTERNAL_YJS_SESSION_CLEAR_RESEEDED_PATH, + INTERNAL_YJS_SESSION_APPLY_UPDATE_PATH, 'POST', req.method ) - if (clearReseededId) { - await handleInternalYjsSessionClearReseededRequest(res, logger, clearReseededId) + if (applyUpdateId) { + await handleInternalYjsSessionApplyUpdateRequest(req, parsedUrl, res, logger, applyUpdateId) return true } @@ -495,15 +510,12 @@ export function createHttpHandler(logger: Logger, options?: HttpHandlerOptions) } if (req.method === 'GET' && req.url === '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end( - JSON.stringify({ - status: 'ok', - timestamp: new Date().toISOString(), - connections: resolveConnectionCount(), - monitorRuntime: resolveMonitorRuntimeHealth(), - }) - ) + sendJson(res, 200, { + status: 'ok', + timestamp: new Date().toISOString(), + connections: resolveConnectionCount(), + monitorRuntime: resolveMonitorRuntimeHealth(), + }) return } @@ -513,12 +525,10 @@ export function createHttpHandler(logger: Logger, options?: HttpHandlerOptions) try { await triggerMonitorsReconcile?.() logger.info('Accepted monitor reconcile request') - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ success: true })) + sendJson(res, 200, { success: true }) } catch (error) { logger.error('Failed to process monitor reconcile request', { error }) - res.writeHead(500, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Failed to process reconcile request' })) + sendJson(res, 500, { error: 'Failed to process reconcile request' }) } return } @@ -533,7 +543,6 @@ export function createHttpHandler(logger: Logger, options?: HttpHandlerOptions) } } - res.writeHead(404, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Not found' })) + sendJson(res, 404, { error: 'Not found' }) } } diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts index e3ae61fef..416aa3397 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts @@ -156,7 +156,9 @@ export async function applyAutoLayoutAndUpdateStore({ try { const { getRegisteredWorkflowSession } = await import('@/lib/yjs/workflow-session-registry') - const { readWorkflowSnapshot, readWorkflowMap } = await import('@/lib/yjs/workflow-session') + const { getVariablesSnapshot, readWorkflowSnapshot, readWorkflowMap } = await import( + '@/lib/yjs/workflow-session' + ) const { YJS_ORIGINS } = await import('@/lib/yjs/transaction-origins') const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') @@ -246,6 +248,7 @@ export async function applyAutoLayoutAndUpdateStore({ loops: stateToSave.loops || {}, parallels: stateToSave.parallels || {}, edges: sanitizeEdgesForStateSave(stateToSave.edges || []), + variables: getVariablesSnapshot(doc), } const response = await fetch(`/api/workflows/${resolvedWorkflowId}/state`, { From 1fcbbff2157d01fd4fdfd7a3d75d1a6c52bf49e2 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 23 Jun 2026 19:21:26 -0600 Subject: [PATCH 100/284] feat(api-key): remove embedded ids from generated api keys Generate API keys as fixed-prefix 32-character tokens and authenticate them without narrowing on an embedded database id. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/workspaces/[id]/api-keys/route.ts | 2 +- apps/tradinggoose/lib/api-key/auth.ts | 7 ++----- apps/tradinggoose/lib/api-key/service.test.ts | 15 ++++++++++--- apps/tradinggoose/lib/api-key/service.ts | 21 ++++++++----------- apps/tradinggoose/lib/mcp/auth.ts | 2 +- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts b/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts index 0cccfa6ea..4402104da 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts @@ -123,7 +123,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } const keyId = nanoid() - const { key: plainKey, encryptedKey } = await createApiKey(true, keyId) + const { key: plainKey, encryptedKey } = await createApiKey(true) if (!encryptedKey) { throw new Error('Failed to encrypt API key for storage') diff --git a/apps/tradinggoose/lib/api-key/auth.ts b/apps/tradinggoose/lib/api-key/auth.ts index eaa4af761..fff54366b 100644 --- a/apps/tradinggoose/lib/api-key/auth.ts +++ b/apps/tradinggoose/lib/api-key/auth.ts @@ -83,14 +83,11 @@ export async function authenticateApiKey(inputKey: string, storedKey: string): P * @param useStorage - Whether to encrypt the key before storage (default: true) * @returns Promise<{key: string, encryptedKey?: string}> - The plain key and optionally encrypted version */ -export async function createApiKey( - useStorage = true, - keyId?: string -): Promise<{ +export async function createApiKey(useStorage = true): Promise<{ key: string encryptedKey?: string }> { - return createApiKeyMaterial(useStorage, keyId) + return createApiKeyMaterial(useStorage) } /** diff --git a/apps/tradinggoose/lib/api-key/service.test.ts b/apps/tradinggoose/lib/api-key/service.test.ts index 19e41f3bc..11dffbd5d 100644 --- a/apps/tradinggoose/lib/api-key/service.test.ts +++ b/apps/tradinggoose/lib/api-key/service.test.ts @@ -20,7 +20,11 @@ vi.mock('@/lib/api-key/auth', () => ({ authenticateApiKey: (...args: unknown[]) => authenticateApiKeyMock(...args), })) -import { authenticateApiKeyFromHeader } from '@/lib/api-key/service' +import { + authenticateApiKeyFromHeader, + generateApiKey, + generateEncryptedApiKey, +} from '@/lib/api-key/service' describe('authenticateApiKeyFromHeader', () => { beforeEach(() => { @@ -38,8 +42,8 @@ describe('authenticateApiKeyFromHeader', () => { expect(authenticateApiKeyMock).not.toHaveBeenCalled() }) - it('authenticates valid keyed personal API keys through a narrowed lookup', async () => { - const token = 'sk-tradinggoose-key-1.secret' + it('authenticates staging-format personal API keys', async () => { + const token = `sk-tradinggoose-${'a'.repeat(32)}` whereMock.mockResolvedValue([ { id: 'key-1', @@ -65,4 +69,9 @@ describe('authenticateApiKeyFromHeader', () => { expect(whereMock).toHaveBeenCalledOnce() expect(authenticateApiKeyMock).toHaveBeenCalledWith(token, 'stored-key') }) + + it('generates staging-format API keys without embedded database ids', () => { + expect(generateApiKey()).toMatch(/^tradinggoose_[A-Za-z0-9_-]{32}$/) + expect(generateEncryptedApiKey()).toMatch(/^sk-tradinggoose-[A-Za-z0-9_-]{32}$/) + }) }) diff --git a/apps/tradinggoose/lib/api-key/service.ts b/apps/tradinggoose/lib/api-key/service.ts index 9e2fdacb8..83745b73c 100644 --- a/apps/tradinggoose/lib/api-key/service.ts +++ b/apps/tradinggoose/lib/api-key/service.ts @@ -8,7 +8,7 @@ import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('ApiKeyService') -const API_KEY_WITH_ID_PATTERN = /^(?:sk-tradinggoose-|tradinggoose_)([^.]+)\.[^.]+$/ +const API_KEY_PATTERN = /^(?:sk-tradinggoose-|tradinggoose_)[A-Za-z0-9_-]{32}$/ export interface ApiKeyAuthOptions { userId?: string @@ -39,15 +39,14 @@ export interface CreatedPersonalApiKey { } export async function createApiKeyMaterial( - useStorage = true, - keyId?: string + useStorage = true ): Promise<{ key: string encryptedKey?: string }> { try { const hasEncryptionKey = env.API_ENCRYPTION_KEY !== undefined - const plainKey = hasEncryptionKey ? generateEncryptedApiKey(keyId) : generateApiKey(keyId) + const plainKey = hasEncryptionKey ? generateEncryptedApiKey() : generateApiKey() if (useStorage) { const { encrypted } = await encryptApiKey(plainKey) @@ -72,7 +71,7 @@ export async function createPersonalApiKey({ } const keyId = nanoid() - const { key: plainKey, encryptedKey } = await createApiKeyMaterial(true, keyId) + const { key: plainKey, encryptedKey } = await createApiKeyMaterial(true) if (!encryptedKey) { throw new Error('Failed to encrypt API key for storage') } @@ -116,8 +115,7 @@ export async function authenticateApiKeyFromHeader( if (!apiKey) { return { success: false, error: 'API key required' } } - const keyId = API_KEY_WITH_ID_PATTERN.exec(apiKey)?.[1] - if (!keyId) { + if (!API_KEY_PATTERN.test(apiKey)) { return { success: false, error: 'Invalid API key' } } @@ -141,7 +139,6 @@ export async function authenticateApiKeyFromHeader( // Apply filters const conditions = [] - conditions.push(eq(apiKeyTable.id, keyId)) if (options.userId) { conditions.push(eq(apiKeyTable.userId, options.userId)) @@ -329,16 +326,16 @@ export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted * Generates a standardized API key with the 'tradinggoose_' prefix (plain-text format) * @returns A new API key string */ -export function generateApiKey(keyId = nanoid()): string { - return `tradinggoose_${keyId}.${nanoid(32)}` +export function generateApiKey(): string { + return `tradinggoose_${nanoid(32)}` } /** * Generates a new encrypted API key with the 'sk-tradinggoose-' prefix * @returns A new encrypted API key string */ -export function generateEncryptedApiKey(keyId = nanoid()): string { - return `sk-tradinggoose-${keyId}.${nanoid(32)}` +export function generateEncryptedApiKey(): string { + return `sk-tradinggoose-${nanoid(32)}` } /** diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 86bb28d86..198be03a1 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -306,7 +306,7 @@ export async function approveMcpDeviceLogin({ const now = new Date() const approvedAt = now.toISOString() const apiKeyId = nanoid() - const { encryptedKey } = await createApiKeyMaterial(true, apiKeyId) + const { encryptedKey } = await createApiKeyMaterial(true) if (!encryptedKey) { throw new Error('Failed to create MCP personal API key') } From 138ca5bda281779de3d76edfd7317c86df6d4482 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 23 Jun 2026 20:23:04 -0600 Subject: [PATCH 101/284] fix(copilot): keep review acceptance bound to the staged execution context Persist the execution context with staged server tool reviews and require it again when accepting the review. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../copilot/tools/server/review-acceptance.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts index 208d50712..83a250c9c 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts @@ -20,6 +20,7 @@ type StagedServerToolReview = { toolName?: unknown encryptedPayload?: unknown baseStateHash?: unknown + executionContext?: Pick reviewClaimId?: unknown } @@ -59,6 +60,7 @@ export async function stageServerManagedToolReview( string, unknown > + const { contextEntityKind, contextEntityId, workspaceId } = context const encryptedPayload = (await encryptSecret(JSON.stringify(payload ?? null))).encrypted await db.insert(verification).values({ id: nanoid(), @@ -68,6 +70,7 @@ export async function stageServerManagedToolReview( toolName, encryptedPayload, baseStateHash: readBaseStateHash(result), + executionContext: { contextEntityKind, contextEntityId, workspaceId }, }), expiresAt: new Date(now.getTime() + REVIEW_TOKEN_TTL_MS), createdAt: now, @@ -120,18 +123,26 @@ export async function acceptServerManagedToolReview( if (typeof staged.encryptedPayload !== 'string' || !staged.encryptedPayload) { throw new Error('Server tool review token is invalid or expired') } + if (!staged.executionContext || typeof staged.executionContext !== 'object') { + throw new Error('Server tool review token does not match this request') + } if (typeof staged.reviewClaimId === 'string') { throw new Error('Server tool review token is already being accepted') } const { decrypted } = await decryptSecret(staged.encryptedPayload) const payload = JSON.parse(decrypted) + const executionContext = { + ...staged.executionContext, + userId: context.userId, + signal: context.signal, + } const currentReview = await routeExecution(toolName, payload, { - ...context, + ...executionContext, accessLevel: 'limited', }) assertAcceptedServerToolReviewBase( - { ...context, acceptedReviewBaseStateHash: staged.baseStateHash }, + { ...executionContext, acceptedReviewBaseStateHash: staged.baseStateHash }, readBaseStateHash(currentReview) ) @@ -150,7 +161,7 @@ export async function acceptServerManagedToolReview( let acceptedResult: unknown try { acceptedResult = await routeExecution(toolName, payload, { - ...context, + ...executionContext, accessLevel: 'full', acceptedReviewBaseStateHash: staged.baseStateHash, }) From 1f4b72c98b1e4787d00d1b534454605a7b002d6c Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Tue, 23 Jun 2026 22:36:26 -0600 Subject: [PATCH 102/284] feat(auth): simplify MCP device login approval flow Remove the approval-token challenge flow, approve and cancel device logins directly from the posted code, and keep expired verification rows trimmed during login start. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../(auth)/mcp/authorize/page.test.tsx | 17 ++-- .../[locale]/(auth)/mcp/authorize/page.tsx | 13 +-- .../app/api/auth/mcp/authorize/route.test.ts | 13 +-- .../app/api/auth/mcp/authorize/route.ts | 13 +-- apps/tradinggoose/lib/mcp/auth.ts | 94 +++++++------------ 5 files changed, 49 insertions(+), 101 deletions(-) diff --git a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx index b3eeb38ce..82b5550b2 100644 --- a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx +++ b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx @@ -3,16 +3,16 @@ import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockCreateMcpDeviceLoginApprovalChallenge, mockGetSession, mockGetSessionCookie, mockHeaders, + mockReadMcpDeviceLoginApprovalStatus, mockRedirect, } = vi.hoisted(() => ({ - mockCreateMcpDeviceLoginApprovalChallenge: vi.fn(), mockGetSession: vi.fn(), mockGetSessionCookie: vi.fn(), mockHeaders: vi.fn(), + mockReadMcpDeviceLoginApprovalStatus: vi.fn(), mockRedirect: vi.fn(), })) @@ -29,8 +29,8 @@ vi.mock('@/lib/auth', () => ({ })) vi.mock('@/lib/mcp/auth', () => ({ - createMcpDeviceLoginApprovalChallenge: (...args: unknown[]) => - mockCreateMcpDeviceLoginApprovalChallenge(...args), + readMcpDeviceLoginApprovalStatus: (...args: unknown[]) => + mockReadMcpDeviceLoginApprovalStatus(...args), })) vi.mock('@/app/(auth)/components/auth-page-header', () => ({ @@ -72,14 +72,13 @@ describe('MCP authorize page', () => { mockHeaders.mockResolvedValue(new Headers()) mockGetSessionCookie.mockReturnValue(null) mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - mockCreateMcpDeviceLoginApprovalChallenge.mockResolvedValue({ + mockReadMcpDeviceLoginApprovalStatus.mockResolvedValue({ status: 'pending', - approvalToken: 'approval-token', expiresAt: '2026-06-19T12:00:00.000Z', }) }) - it('renders a confirmation form instead of approving on page load', async () => { + it('renders a confirmation form without binding approval on page load', async () => { const McpAuthorizePage = (await import('./page')).default const result = await McpAuthorizePage({ @@ -88,11 +87,9 @@ describe('MCP authorize page', () => { }) const markup = renderToStaticMarkup(result) - expect(mockCreateMcpDeviceLoginApprovalChallenge).toHaveBeenCalledWith('login-code', 'user-1') + expect(mockReadMcpDeviceLoginApprovalStatus).toHaveBeenCalledWith('login-code') expect(markup).toContain('Aprobar clave API personal') expect(markup).toContain('method="post"') expect(markup).toContain('action="/api/auth/mcp/authorize"') - expect(markup).toContain('name="approvalToken"') - expect(markup).toContain('value="approval-token"') }) }) diff --git a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx index 232314985..f350f2a76 100644 --- a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx +++ b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx @@ -2,7 +2,7 @@ import { getSessionCookie } from 'better-auth/cookies' import { headers } from 'next/headers' import { Button } from '@/components/ui/button' import { getSession } from '@/lib/auth' -import { createMcpDeviceLoginApprovalChallenge } from '@/lib/mcp/auth' +import { readMcpDeviceLoginApprovalStatus } from '@/lib/mcp/auth' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' import { inter } from '@/app/fonts/inter' import { redirect } from '@/i18n/navigation' @@ -70,17 +70,13 @@ export default async function McpAuthorizePage({ }) } - const challenge = await createMcpDeviceLoginApprovalChallenge(code, session.user.id) + const approvalStatus = await readMcpDeviceLoginApprovalStatus(code) - if (challenge.status === 'expired') { + if (approvalStatus.status === 'expired') { return renderStatus(mcpCopy.expired) } - if (challenge.status === 'invalid') { - return renderStatus(mcpCopy.invalid) - } - - if (challenge.status === 'approved') { + if (approvalStatus.status === 'approved') { return renderStatus(mcpCopy.approved) } @@ -93,7 +89,6 @@ export default async function McpAuthorizePage({ />
-
- {renameError && ( -

{renameError}

- )} + {renameError &&

{renameError}

}
) : (
@@ -475,7 +419,9 @@ const WorkspaceApiKeysCardComponent = ( disabled={isUpdatingKeyName || (isWorkspaceScope && !workspaceId)} > - {t('labels.rename', { scope: scopeLabel })} + + {t('labels.rename', { scope: scopeLabel })} + )}
@@ -483,39 +429,9 @@ const WorkspaceApiKeysCardComponent = (
-
- +
- )} @@ -600,23 +516,14 @@ const WorkspaceApiKeysCardComponent = ( } return filteredKeys.map((key) => { - const rawKeyValue = key.key || key.displayKey || '' - const isRevealed = Boolean(revealedKeys[key.id]) - const displayValue = rawKeyValue - ? isRevealed - ? rawKeyValue - : getMaskedKeyValue(key) - : key.displayKey || '—' - const canRevealOrCopy = Boolean(rawKeyValue) - const isCopied = copiedKeyId === key.id const isEditing = canRenameKeys && editingKeyId === key.id return ( - + {formatDate(key.createdAt)} - + {canRenameKeys && editingKeyId === key.id ? (
@@ -646,58 +553,24 @@ const WorkspaceApiKeysCardComponent = (

{renameError}

)}
- ) : ( -
-

{key.name}

-
- )} + ) : ( +
+

{key.name}

+
+ )}
-
- +
-
- + {formatDate(key.lastUsed)} -
+
{isEditing ? ( <>
@@ -960,21 +831,22 @@ const WorkspaceApiKeysCardComponent = ( {t('dialogs.newKeyTitle', { scope: scopeLabel })} - - {t('dialogs.newKeyDescription')} - + {t('dialogs.newKeyDescription')} {newKey && (
- {newKey.key} + {newKey.key || '—'}
@@ -987,16 +859,12 @@ const WorkspaceApiKeysCardComponent = ( {t('dialogs.deleteTitle', { scope: scopeLabel })} - - {t('dialogs.deleteDescription')} - + {t('dialogs.deleteDescription')} {deleteKey && (
-

- {t('dialogs.deletePrompt', { name: deleteKey.name })} -

+

{t('dialogs.deletePrompt', { name: deleteKey.name })}

Date: Wed, 24 Jun 2026 16:43:29 -0600 Subject: [PATCH 130/284] refactor(operations): prefetch existing workspace entities during upsert Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/custom-tools/operations.ts | 60 +++++++----- .../lib/skills/operations.test.ts | 1 - apps/tradinggoose/lib/skills/operations.ts | 92 +++++++++---------- 3 files changed, 78 insertions(+), 75 deletions(-) diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index d623a00aa..dc755c8b9 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -1,6 +1,6 @@ import { db } from '@tradinggoose/db' import { customTools } from '@tradinggoose/db/schema' -import { and, desc, eq } from 'drizzle-orm' +import { desc, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import { type CustomToolTransferRecord, @@ -54,40 +54,50 @@ export async function upsertCustomTools({ }> = [] await db.transaction(async (tx) => { + const existingTools = await tx + .select({ + id: customTools.id, + title: customTools.title, + }) + .from(customTools) + .where(eq(customTools.workspaceId, workspaceId)) + + const existingById = new Map( + existingTools.map((tool) => [tool.id, { id: tool.id, title: tool.title }]) + ) + const plannedTitles = new Map(existingTools.map((tool) => [tool.title, tool.id])) + for (const tool of tools) { const nowTime = new Date() - const duplicateTitle = await tx - .select({ id: customTools.id }) - .from(customTools) - .where(and(eq(customTools.workspaceId, workspaceId), eq(customTools.title, tool.title))) - .limit(1) + const existingTool = tool.id ? existingById.get(tool.id) : null + const conflictingToolId = plannedTitles.get(tool.title) - if (duplicateTitle[0] && duplicateTitle[0].id !== tool.id) { + if (conflictingToolId && conflictingToolId !== tool.id) { throw new Error(`A tool with the title "${tool.title}" already exists in this workspace`) } - if (tool.id) { - const existingTool = await tx - .select() - .from(customTools) - .where(and(eq(customTools.id, tool.id), eq(customTools.workspaceId, workspaceId))) - .limit(1) - - if (existingTool.length > 0) { - updates.push({ - id: tool.id, - fields: { - title: tool.title, - schemaText: JSON.stringify(tool.schema, null, 2), - codeText: tool.code, - }, - }) - logger.info(`[${requestId}] Updated custom tool ${tool.id}`) - continue + if (existingTool && tool.id) { + if (existingTool.title !== tool.title) { + plannedTitles.delete(existingTool.title) + plannedTitles.set(tool.title, tool.id) + existingTool.title = tool.title } + + updates.push({ + id: tool.id, + fields: { + title: tool.title, + schemaText: JSON.stringify(tool.schema, null, 2), + codeText: tool.code, + }, + }) + logger.info(`[${requestId}] Updated custom tool ${tool.id}`) + continue } const toolId = tool.id || nanoid() + plannedTitles.set(tool.title, toolId) + existingById.set(toolId, { id: toolId, title: tool.title }) const newTool = { id: toolId, workspaceId, diff --git a/apps/tradinggoose/lib/skills/operations.test.ts b/apps/tradinggoose/lib/skills/operations.test.ts index ff75d5811..57218b68b 100644 --- a/apps/tradinggoose/lib/skills/operations.test.ts +++ b/apps/tradinggoose/lib/skills/operations.test.ts @@ -23,7 +23,6 @@ vi.mock('drizzle-orm', () => ({ and: vi.fn((...conditions: unknown[]) => ({ kind: 'and', conditions })), desc: vi.fn((value: unknown) => ({ kind: 'desc', value })), eq: vi.fn((left: unknown, right: unknown) => ({ kind: 'eq', left, right })), - ne: vi.fn((left: unknown, right: unknown) => ({ kind: 'ne', left, right })), })) vi.mock('nanoid', () => ({ diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index a9cff5e8a..4f72f57b4 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -1,6 +1,6 @@ import { db } from '@tradinggoose/db' import { skill } from '@tradinggoose/db/schema' -import { and, desc, eq, ne } from 'drizzle-orm' +import { and, desc, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import { createLogger } from '@/lib/logs/console/logger' import { @@ -69,63 +69,57 @@ export async function upsertSkills({ }> = [] await db.transaction(async (tx) => { - for (const currentSkill of skills) { - const nowTime = new Date() + const existingSkills = await tx + .select({ + id: skill.id, + name: skill.name, + }) + .from(skill) + .where(eq(skill.workspaceId, workspaceId)) - if (currentSkill.id) { - const existingSkill = await tx - .select() - .from(skill) - .where(and(eq(skill.id, currentSkill.id), eq(skill.workspaceId, workspaceId))) - .limit(1) - - if (existingSkill.length > 0) { - if (currentSkill.name !== existingSkill[0].name) { - const nameConflict = await tx - .select({ id: skill.id }) - .from(skill) - .where( - and( - eq(skill.workspaceId, workspaceId), - eq(skill.name, currentSkill.name), - ne(skill.id, currentSkill.id) - ) - ) - .limit(1) - - if (nameConflict.length > 0) { - throw new Error( - `A skill with the name "${currentSkill.name}" already exists in this workspace` - ) - } - } - - updates.push({ - id: currentSkill.id, - fields: { - name: currentSkill.name, - description: currentSkill.description, - content: currentSkill.content, - }, - }) - logger.info(`[${requestId}] Updated skill ${currentSkill.id}`) - continue - } - } + const existingById = new Map( + existingSkills.map((currentSkill) => [ + currentSkill.id, + { id: currentSkill.id, name: currentSkill.name }, + ]) + ) + const plannedNames = new Map( + existingSkills.map((currentSkill) => [currentSkill.name, currentSkill.id]) + ) - const duplicateName = await tx - .select({ id: skill.id }) - .from(skill) - .where(and(eq(skill.workspaceId, workspaceId), eq(skill.name, currentSkill.name))) - .limit(1) + for (const currentSkill of skills) { + const nowTime = new Date() + const existingSkill = currentSkill.id ? existingById.get(currentSkill.id) : null + const conflictingSkillId = plannedNames.get(currentSkill.name) - if (duplicateName.length > 0) { + if (conflictingSkillId && conflictingSkillId !== currentSkill.id) { throw new Error( `A skill with the name "${currentSkill.name}" already exists in this workspace` ) } + if (existingSkill && currentSkill.id) { + if (existingSkill.name !== currentSkill.name) { + plannedNames.delete(existingSkill.name) + plannedNames.set(currentSkill.name, currentSkill.id) + existingSkill.name = currentSkill.name + } + + updates.push({ + id: currentSkill.id, + fields: { + name: currentSkill.name, + description: currentSkill.description, + content: currentSkill.content, + }, + }) + logger.info(`[${requestId}] Updated skill ${currentSkill.id}`) + continue + } + const skillId = currentSkill.id || nanoid() + plannedNames.set(currentSkill.name, skillId) + existingById.set(skillId, { id: skillId, name: currentSkill.name }) const newSkill = { id: skillId, workspaceId, From 26c08031691b8cc73f4ad571d127411a8806019c Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 16:43:43 -0600 Subject: [PATCH 131/284] fix(auth): generate MCP api keys at approval time Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/mcp/auth.ts | 47 ++++++------------------------- 1 file changed, 9 insertions(+), 38 deletions(-) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 7a938b0a2..ac0127885 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -26,10 +26,6 @@ type ApprovedDeviceLogin = { verificationKeyHash: string approvedAt: string userId: string - apiKeyId: string - apiKey: string - storedApiKey: string - deliveredAt?: string } type DeviceLoginState = PendingDeviceLogin | ApprovedDeviceLogin @@ -95,11 +91,7 @@ function parseDeviceLoginState(value: string): DeviceLoginState | null { typeof parsed.createdAt === 'string' && typeof parsed.verificationKeyHash === 'string' && typeof parsed.approvedAt === 'string' && - typeof parsed.userId === 'string' && - typeof parsed.apiKeyId === 'string' && - typeof parsed.apiKey === 'string' && - typeof parsed.storedApiKey === 'string' && - (parsed.deliveredAt === undefined || typeof parsed.deliveredAt === 'string') + typeof parsed.userId === 'string' ) { return parsed as ApprovedDeviceLogin } @@ -275,39 +267,23 @@ export async function pollMcpDeviceLogin( } const approvedState = login.state - if (approvedState.deliveredAt) { - const [existingKey] = await db - .select({ id: apiKey.id }) - .from(apiKey) - .where(eq(apiKey.id, approvedState.apiKeyId)) - .limit(1) - if (!existingKey) { - return { status: 'expired' } - } - return { - status: 'approved', - apiKey: approvedState.apiKey, - expiresAt: login.expiresAt.toISOString(), - } - } - const now = new Date() - const deliveredState = { ...approvedState, deliveredAt: now.toISOString() } + const apiKeyId = nanoid() + const { key, storedKey } = await createApiKeyMaterial() const delivered = await db.transaction(async (tx) => { - const [updated] = await tx - .update(verification) - .set({ value: JSON.stringify(deliveredState), updatedAt: now }) + const [deleted] = await tx + .delete(verification) .where(deviceLoginMatches(login)) .returning({ id: verification.id }) - if (!updated) { + if (!deleted) { return false } await tx.insert(apiKey).values({ - id: approvedState.apiKeyId, + id: apiKeyId, userId: approvedState.userId, workspaceId: null, name: `TradingGoose MCP Access ${now.toISOString()}`, - key: approvedState.storedApiKey, + key: storedKey, type: 'personal', createdAt: now, updatedAt: now, @@ -320,7 +296,7 @@ export async function pollMcpDeviceLogin( return { status: 'approved', - apiKey: approvedState.apiKey, + apiKey: key, expiresAt: login.expiresAt.toISOString(), } } @@ -362,17 +338,12 @@ export async function approveMcpDeviceLogin({ const now = new Date() const approvedAt = now.toISOString() - const apiKeyId = nanoid() - const { key, storedKey } = await createApiKeyMaterial() const approvedState = { status: 'approved', createdAt: login.state.createdAt, verificationKeyHash: login.state.verificationKeyHash, approvedAt, userId, - apiKeyId, - apiKey: key, - storedApiKey: storedKey, } satisfies ApprovedDeviceLogin if (!(await updateDeviceLoginState(login, approvedState))) { From 421f7aa8c2d81febbde9977bc521e31832ec0c33 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 17:09:44 -0600 Subject: [PATCH 132/284] fix(mcp): scope public MCP login starts to the deployment Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/auth/mcp/start/route.test.ts | 36 ++++++------------- .../app/api/auth/mcp/start/route.ts | 15 ++------ apps/tradinggoose/lib/mcp/auth.ts | 16 ++++----- 3 files changed, 21 insertions(+), 46 deletions(-) diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts index fa66f94e9..faa2a6eb5 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node */ -import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { MockMcpDeviceLoginRateLimitError, mockStartMcpDeviceLogin } = vi.hoisted(() => ({ @@ -15,15 +14,6 @@ vi.mock('@/lib/mcp/auth', () => ({ startMcpDeviceLogin: (...args: unknown[]) => mockStartMcpDeviceLogin(...args), })) -function createStartRequest(requester = '203.0.113.10') { - return new NextRequest('https://studio.example.test/api/auth/mcp/start', { - method: 'POST', - headers: { - 'x-forwarded-for': requester, - }, - }) -} - describe('MCP login start route', () => { beforeEach(() => { vi.clearAllMocks() @@ -43,7 +33,7 @@ describe('MCP login start route', () => { it('starts a browser approval login and returns an absolute approval URL', async () => { const { POST } = await import('./route') - const response = await POST(createStartRequest()) + const response = await POST() expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ @@ -53,26 +43,20 @@ describe('MCP login start route', () => { intervalSeconds: 2, authorizeUrl: 'https://studio.example.test/mcp/authorize?code=login-code', }) - expect(mockStartMcpDeviceLogin).toHaveBeenCalledWith('public:studio.example.test:203.0.113.10') + expect(mockStartMcpDeviceLogin).toHaveBeenCalledWith('public:studio.example.test') }) - it('does not share pending-login quota across requesters on the same host', async () => { + it('uses the deployment host as the public start quota scope', async () => { const { POST } = await import('./route') - await POST(createStartRequest('203.0.113.10')) - await POST(createStartRequest('203.0.113.11')) + await POST() + await POST() - expect(mockStartMcpDeviceLogin).toHaveBeenNthCalledWith( - 1, - 'public:studio.example.test:203.0.113.10' - ) - expect(mockStartMcpDeviceLogin).toHaveBeenNthCalledWith( - 2, - 'public:studio.example.test:203.0.113.11' - ) + expect(mockStartMcpDeviceLogin).toHaveBeenNthCalledWith(1, 'public:studio.example.test') + expect(mockStartMcpDeviceLogin).toHaveBeenNthCalledWith(2, 'public:studio.example.test') }) - it('returns 429 when too many approval logins are active for the requester', async () => { + it('returns 429 when too many approval logins are active for the deployment', async () => { const { POST } = await import('./route') mockStartMcpDeviceLogin.mockRejectedValueOnce( new MockMcpDeviceLoginRateLimitError( @@ -80,12 +64,12 @@ describe('MCP login start route', () => { ) ) - const response = await POST(createStartRequest()) + const response = await POST() expect(response.status).toBe(429) await expect(response.json()).resolves.toEqual({ error: 'Too many active MCP login attempts. Please wait for existing attempts to expire.', }) - expect(mockStartMcpDeviceLogin).toHaveBeenCalledWith('public:studio.example.test:203.0.113.10') + expect(mockStartMcpDeviceLogin).toHaveBeenCalledWith('public:studio.example.test') }) }) diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.ts index dad1879d7..d747c4c44 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.ts @@ -1,22 +1,13 @@ -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { McpDeviceLoginRateLimitError, startMcpDeviceLogin } from '@/lib/mcp/auth' import { getBaseUrl } from '@/lib/urls/utils' export const dynamic = 'force-dynamic' -function getRequesterKey(request: NextRequest, baseUrl: string) { - const requester = - request.headers.get('x-forwarded-for')?.split(',', 1)[0]?.trim() || - request.headers.get('x-real-ip')?.trim() || - 'unknown' - - return `public:${new URL(baseUrl).host}:${requester}` -} - -export async function POST(request: NextRequest) { +export async function POST() { try { const baseUrl = getBaseUrl() - const login = await startMcpDeviceLogin(getRequesterKey(request, baseUrl)) + const login = await startMcpDeviceLogin(`public:${new URL(baseUrl).host}`) const authorizeUrl = new URL('/mcp/authorize', baseUrl) authorizeUrl.searchParams.set('code', login.code) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index ac0127885..0e06ce670 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -10,7 +10,7 @@ const DEVICE_LOGIN_PREFIX = 'mcp:' const DEVICE_LOGIN_LOCK_NAMESPACE = 47_102 const POLL_INTERVAL_SECONDS = 2 const DELIVERY_RETRY_LIMIT = 5 -const MAX_PENDING_DEVICE_LOGINS_PER_REQUESTER = 20 +const MAX_ACTIVE_MCP_DEVICE_LOGIN_STARTS_PER_DEPLOYMENT = 200 type PendingDeviceLogin = { status: 'pending' @@ -146,18 +146,18 @@ async function updateDeviceLoginState( } export async function startMcpDeviceLogin( - requesterKey: string + deploymentKey: string ): Promise { const code = randomBytes(32).toString('base64url') const verificationKey = randomBytes(32).toString('base64url') const now = new Date() const expiresAt = new Date(now.getTime() + DEVICE_LOGIN_TTL_MS) - const requesterHash = hashValue(requesterKey) - const requesterIdentifierPrefix = `${DEVICE_LOGIN_PREFIX}${requesterHash}:` + const deploymentHash = hashValue(deploymentKey) + const deploymentIdentifierPrefix = `${DEVICE_LOGIN_PREFIX}${deploymentHash}:` await db.transaction(async (tx) => { await tx.execute( - sql`select pg_advisory_xact_lock(${DEVICE_LOGIN_LOCK_NAMESPACE}, hashtext(${requesterHash}))` + sql`select pg_advisory_xact_lock(${DEVICE_LOGIN_LOCK_NAMESPACE}, hashtext(${deploymentHash}))` ) await tx @@ -174,18 +174,18 @@ export async function startMcpDeviceLogin( .from(verification) .where( and( - like(verification.identifier, `${requesterIdentifierPrefix}%`), + like(verification.identifier, `${deploymentIdentifierPrefix}%`), gt(verification.expiresAt, now) ) ) - if ((activeLogins?.count ?? 0) >= MAX_PENDING_DEVICE_LOGINS_PER_REQUESTER) { + if ((activeLogins?.count ?? 0) >= MAX_ACTIVE_MCP_DEVICE_LOGIN_STARTS_PER_DEPLOYMENT) { throw new McpDeviceLoginRateLimitError() } await tx.insert(verification).values({ id: nanoid(), - identifier: `${requesterIdentifierPrefix}${hashValue(code)}`, + identifier: `${deploymentIdentifierPrefix}${hashValue(code)}`, value: JSON.stringify({ status: 'pending', createdAt: now.toISOString(), From 74bd54e86df01440c33feff9fed5c16e80caef8f Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 18:10:29 -0600 Subject: [PATCH 133/284] feat(auth): sign MCP device login codes Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/auth/mcp/start/route.test.ts | 33 +- .../app/api/auth/mcp/start/route.ts | 25 +- apps/tradinggoose/lib/mcp/auth.ts | 283 +++++++++++------- 3 files changed, 187 insertions(+), 154 deletions(-) diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts index faa2a6eb5..022425eec 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts @@ -4,13 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { MockMcpDeviceLoginRateLimitError, mockStartMcpDeviceLogin } = vi.hoisted(() => ({ - MockMcpDeviceLoginRateLimitError: class extends Error {}, +const { mockStartMcpDeviceLogin } = vi.hoisted(() => ({ mockStartMcpDeviceLogin: vi.fn(), })) vi.mock('@/lib/mcp/auth', () => ({ - McpDeviceLoginRateLimitError: MockMcpDeviceLoginRateLimitError, startMcpDeviceLogin: (...args: unknown[]) => mockStartMcpDeviceLogin(...args), })) @@ -43,33 +41,6 @@ describe('MCP login start route', () => { intervalSeconds: 2, authorizeUrl: 'https://studio.example.test/mcp/authorize?code=login-code', }) - expect(mockStartMcpDeviceLogin).toHaveBeenCalledWith('public:studio.example.test') - }) - - it('uses the deployment host as the public start quota scope', async () => { - const { POST } = await import('./route') - - await POST() - await POST() - - expect(mockStartMcpDeviceLogin).toHaveBeenNthCalledWith(1, 'public:studio.example.test') - expect(mockStartMcpDeviceLogin).toHaveBeenNthCalledWith(2, 'public:studio.example.test') - }) - - it('returns 429 when too many approval logins are active for the deployment', async () => { - const { POST } = await import('./route') - mockStartMcpDeviceLogin.mockRejectedValueOnce( - new MockMcpDeviceLoginRateLimitError( - 'Too many active MCP login attempts. Please wait for existing attempts to expire.' - ) - ) - - const response = await POST() - - expect(response.status).toBe(429) - await expect(response.json()).resolves.toEqual({ - error: 'Too many active MCP login attempts. Please wait for existing attempts to expire.', - }) - expect(mockStartMcpDeviceLogin).toHaveBeenCalledWith('public:studio.example.test') + expect(mockStartMcpDeviceLogin).toHaveBeenCalledWith() }) }) diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.ts index d747c4c44..565a12da4 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.ts @@ -1,24 +1,17 @@ import { NextResponse } from 'next/server' -import { McpDeviceLoginRateLimitError, startMcpDeviceLogin } from '@/lib/mcp/auth' +import { startMcpDeviceLogin } from '@/lib/mcp/auth' import { getBaseUrl } from '@/lib/urls/utils' export const dynamic = 'force-dynamic' export async function POST() { - try { - const baseUrl = getBaseUrl() - const login = await startMcpDeviceLogin(`public:${new URL(baseUrl).host}`) - const authorizeUrl = new URL('/mcp/authorize', baseUrl) - authorizeUrl.searchParams.set('code', login.code) + const baseUrl = getBaseUrl() + const login = await startMcpDeviceLogin() + const authorizeUrl = new URL('/mcp/authorize', baseUrl) + authorizeUrl.searchParams.set('code', login.code) - return NextResponse.json({ - ...login, - authorizeUrl: authorizeUrl.toString(), - }) - } catch (error) { - if (error instanceof McpDeviceLoginRateLimitError) { - return NextResponse.json({ error: error.message }, { status: 429 }) - } - throw error - } + return NextResponse.json({ + ...login, + authorizeUrl: authorizeUrl.toString(), + }) } diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 0e06ce670..3a4c0c27b 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -1,16 +1,14 @@ -import { createHash, randomBytes } from 'crypto' +import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' import { db } from '@tradinggoose/db' import { apiKey, verification } from '@tradinggoose/db/schema' -import { and, count, eq, gt, like, lte, sql } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import { createApiKeyMaterial } from '@/lib/api-key/service' +import { env } from '@/lib/env' const DEVICE_LOGIN_TTL_MS = 10 * 60 * 1000 const DEVICE_LOGIN_PREFIX = 'mcp:' -const DEVICE_LOGIN_LOCK_NAMESPACE = 47_102 const POLL_INTERVAL_SECONDS = 2 -const DELIVERY_RETRY_LIMIT = 5 -const MAX_ACTIVE_MCP_DEVICE_LOGIN_STARTS_PER_DEPLOYMENT = 200 type PendingDeviceLogin = { status: 'pending' @@ -26,6 +24,7 @@ type ApprovedDeviceLogin = { verificationKeyHash: string approvedAt: string userId: string + deliveredAt?: string } type DeviceLoginState = PendingDeviceLogin | ApprovedDeviceLogin @@ -59,17 +58,89 @@ export type McpDeviceLoginStartResult = { intervalSeconds: number } -export class McpDeviceLoginRateLimitError extends Error { - constructor() { - super('Too many active MCP login attempts. Please wait for existing attempts to expire.') - this.name = 'McpDeviceLoginRateLimitError' - } -} - function hashValue(value: string) { return createHash('sha256').update(value).digest('hex') } +function signDeviceLoginCode(unsignedCode: string): string { + return createHmac('sha256', env.INTERNAL_API_SECRET).update(unsignedCode).digest('base64url') +} + +function buildDeviceLoginIdentifier(code: string): string { + return `${DEVICE_LOGIN_PREFIX}${hashValue(code)}` +} + +function createDeviceLoginCode({ + expiresAt, + now, + verificationKey, +}: { + expiresAt: Date + now: Date + verificationKey: string +}): string { + const unsignedCode = [ + randomBytes(32).toString('base64url'), + String(now.getTime()), + String(expiresAt.getTime()), + hashValue(verificationKey), + ].join('.') + return `${unsignedCode}.${signDeviceLoginCode(unsignedCode)}` +} + +function readDeviceLoginCode(code: string): { + state: PendingDeviceLogin + expiresAt: Date + identifier: string +} | null { + try { + const [nonce, issuedAtRaw, expiresAtRaw, verificationKeyHash, encodedSignature, extra] = + code.split('.') + if ( + !nonce || + !issuedAtRaw || + !expiresAtRaw || + !verificationKeyHash || + !encodedSignature || + extra + ) { + return null + } + + const unsignedCode = `${nonce}.${issuedAtRaw}.${expiresAtRaw}.${verificationKeyHash}` + const expectedSignature = signDeviceLoginCode(unsignedCode) + const signatureMatches = + expectedSignature.length === encodedSignature.length && + timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(encodedSignature)) + + if (!signatureMatches) { + return null + } + + const issuedAt = Number(issuedAtRaw) + const expiresAt = Number(expiresAtRaw) + if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt)) { + return null + } + + if (expiresAt <= Date.now()) { + return null + } + + return { + expiresAt: new Date(expiresAt), + identifier: buildDeviceLoginIdentifier(code), + state: { + status: 'pending', + createdAt: new Date(issuedAt).toISOString(), + verificationKeyHash, + }, + } + } catch { + return null + } +} + function deviceLoginMatches(login: DeviceLogin, state = login.state) { return and(eq(verification.id, login.id), eq(verification.value, JSON.stringify(state))) } @@ -91,7 +162,8 @@ function parseDeviceLoginState(value: string): DeviceLoginState | null { typeof parsed.createdAt === 'string' && typeof parsed.verificationKeyHash === 'string' && typeof parsed.approvedAt === 'string' && - typeof parsed.userId === 'string' + typeof parsed.userId === 'string' && + (parsed.deliveredAt === undefined || typeof parsed.deliveredAt === 'string') ) { return parsed as ApprovedDeviceLogin } @@ -109,7 +181,7 @@ async function readDeviceLogin(code: string) { expiresAt: verification.expiresAt, }) .from(verification) - .where(like(verification.identifier, `${DEVICE_LOGIN_PREFIX}%:${hashValue(code)}`)) + .where(eq(verification.identifier, buildDeviceLoginIdentifier(code))) .limit(1) if (!row) { @@ -145,60 +217,13 @@ async function updateDeviceLoginState( return Boolean(updated) } -export async function startMcpDeviceLogin( - deploymentKey: string -): Promise { - const code = randomBytes(32).toString('base64url') +export async function startMcpDeviceLogin(): Promise { const verificationKey = randomBytes(32).toString('base64url') const now = new Date() const expiresAt = new Date(now.getTime() + DEVICE_LOGIN_TTL_MS) - const deploymentHash = hashValue(deploymentKey) - const deploymentIdentifierPrefix = `${DEVICE_LOGIN_PREFIX}${deploymentHash}:` - - await db.transaction(async (tx) => { - await tx.execute( - sql`select pg_advisory_xact_lock(${DEVICE_LOGIN_LOCK_NAMESPACE}, hashtext(${deploymentHash}))` - ) - - await tx - .delete(verification) - .where( - and( - like(verification.identifier, `${DEVICE_LOGIN_PREFIX}%`), - lte(verification.expiresAt, now) - ) - ) - - const [activeLogins] = await tx - .select({ count: count() }) - .from(verification) - .where( - and( - like(verification.identifier, `${deploymentIdentifierPrefix}%`), - gt(verification.expiresAt, now) - ) - ) - - if ((activeLogins?.count ?? 0) >= MAX_ACTIVE_MCP_DEVICE_LOGIN_STARTS_PER_DEPLOYMENT) { - throw new McpDeviceLoginRateLimitError() - } - - await tx.insert(verification).values({ - id: nanoid(), - identifier: `${deploymentIdentifierPrefix}${hashValue(code)}`, - value: JSON.stringify({ - status: 'pending', - createdAt: now.toISOString(), - verificationKeyHash: hashValue(verificationKey), - } satisfies PendingDeviceLogin), - expiresAt, - createdAt: now, - updatedAt: now, - }) - }) return { - code, + code: createDeviceLoginCode({ expiresAt, now, verificationKey }), verificationKey, expiresAt: expiresAt.toISOString(), intervalSeconds: POLL_INTERVAL_SECONDS, @@ -214,10 +239,36 @@ export async function createMcpDeviceLoginApprovalChallenge({ }): Promise { const login = await readDeviceLogin(code) if (!login) { - return { status: 'expired' } + const codeState = readDeviceLoginCode(code) + if (!codeState) { + return { status: 'expired' } + } + + const approvalToken = randomBytes(32).toString('base64url') + await db.insert(verification).values({ + id: nanoid(), + identifier: codeState.identifier, + value: JSON.stringify({ + ...codeState.state, + approvalUserId: userId, + approvalTokenHash: hashValue(approvalToken), + } satisfies PendingDeviceLogin), + expiresAt: codeState.expiresAt, + createdAt: new Date(codeState.state.createdAt), + updatedAt: new Date(), + }) + + return { + status: 'pending', + expiresAt: codeState.expiresAt.toISOString(), + approvalToken, + } } if (login.state.status === 'approved') { + if (login.state.deliveredAt) { + return { status: 'expired' } + } return { status: 'approved', expiresAt: login.expiresAt.toISOString(), @@ -246,65 +297,80 @@ export async function pollMcpDeviceLogin( code: string, verificationKey: string ): Promise { - let lastExpiresAt: string | null = null - for (let deliveryAttempt = 0; deliveryAttempt < DELIVERY_RETRY_LIMIT; deliveryAttempt += 1) { - const login = await readDeviceLogin(code) - if (!login) { + const login = await readDeviceLogin(code) + if (!login) { + const codeState = readDeviceLoginCode(code) + if (!codeState) { return { status: 'expired' } } - lastExpiresAt = login.expiresAt.toISOString() - - if (login.state.verificationKeyHash !== hashValue(verificationKey)) { + if (codeState.state.verificationKeyHash !== hashValue(verificationKey)) { return { status: 'invalid' } } + return { + status: 'pending', + intervalSeconds: POLL_INTERVAL_SECONDS, + expiresAt: codeState.expiresAt.toISOString(), + } + } - if (login.state.status !== 'approved') { - return { - status: 'pending', - intervalSeconds: POLL_INTERVAL_SECONDS, - expiresAt: login.expiresAt.toISOString(), - } + if (login.state.verificationKeyHash !== hashValue(verificationKey)) { + return { status: 'invalid' } + } + + if (login.state.status === 'approved' && login.state.deliveredAt) { + return { status: 'expired' } + } + + if (login.state.status !== 'approved') { + return { + status: 'pending', + intervalSeconds: POLL_INTERVAL_SECONDS, + expiresAt: login.expiresAt.toISOString(), } + } - const approvedState = login.state - const now = new Date() - const apiKeyId = nanoid() - const { key, storedKey } = await createApiKeyMaterial() - const delivered = await db.transaction(async (tx) => { - const [deleted] = await tx - .delete(verification) - .where(deviceLoginMatches(login)) - .returning({ id: verification.id }) - if (!deleted) { - return false - } - await tx.insert(apiKey).values({ - id: apiKeyId, - userId: approvedState.userId, - workspaceId: null, - name: `TradingGoose MCP Access ${now.toISOString()}`, - key: storedKey, - type: 'personal', - createdAt: now, + const approvedState = login.state + const now = new Date() + const { key, storedKey } = await createApiKeyMaterial() + const delivered = await db.transaction(async (tx) => { + const [updated] = await tx + .update(verification) + .set({ + value: JSON.stringify({ + ...approvedState, + deliveredAt: now.toISOString(), + } satisfies ApprovedDeviceLogin), updatedAt: now, }) - return true - }) - if (!delivered) { - continue + .where(deviceLoginMatches(login)) + .returning({ id: verification.id }) + if (!updated) { + return false } - + await tx.insert(apiKey).values({ + id: nanoid(), + userId: approvedState.userId, + workspaceId: null, + name: `TradingGoose MCP Access ${now.toISOString()}`, + key: storedKey, + type: 'personal', + createdAt: now, + updatedAt: now, + }) + return true + }) + if (!delivered) { return { - status: 'approved', - apiKey: key, + status: 'pending', + intervalSeconds: POLL_INTERVAL_SECONDS, expiresAt: login.expiresAt.toISOString(), } } return { - status: 'pending', - intervalSeconds: POLL_INTERVAL_SECONDS, - expiresAt: lastExpiresAt!, + status: 'approved', + apiKey: key, + expiresAt: login.expiresAt.toISOString(), } } @@ -323,6 +389,9 @@ export async function approveMcpDeviceLogin({ } if (login.state.status === 'approved') { + if (login.state.deliveredAt) { + return { status: 'invalid' } + } return { status: 'approved', expiresAt: login.expiresAt.toISOString(), From f668a296c0565da32ff451360f2e330821ca0bc4 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 18:10:45 -0600 Subject: [PATCH 134/284] refactor(workspace): split entity writes and clean up Yjs sessions Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/indicators/custom/route.ts | 59 +++++-- .../tradinggoose/app/api/mcp/servers/route.ts | 13 +- .../app/api/mcp/servers/schema.ts | 6 +- .../tradinggoose/app/api/skills/route.test.ts | 11 +- apps/tradinggoose/app/api/skills/route.ts | 41 ++++- .../app/api/tools/custom/route.test.ts | 12 +- .../app/api/tools/custom/route.ts | 66 ++++++-- .../app/api/workflows/[id]/route.test.ts | 25 ++- .../app/api/workflows/[id]/route.ts | 2 + .../api/workflows/[id]/state/route.test.ts | 7 - .../app/api/workflows/[id]/state/route.ts | 20 --- .../tools/server/entities/custom-tool.ts | 13 +- .../tools/server/entities/indicator.ts | 12 +- .../copilot/tools/server/entities/skill.ts | 13 +- .../lib/custom-tools/operations.ts | 106 ++++++------ apps/tradinggoose/lib/custom-tools/schema.ts | 2 +- .../lib/indicators/custom/operations.ts | 103 ++++++------ apps/tradinggoose/lib/knowledge/service.ts | 2 + apps/tradinggoose/lib/skills/operations.ts | 119 +++++++------ .../lib/workflows/custom-tools-persistence.ts | 157 ------------------ .../lib/yjs/server/snapshot-bridge.ts | 8 + .../tradinggoose/socket-server/routes/http.ts | 42 ++++- .../socket-server/yjs/persistence.ts | 13 ++ .../socket-server/yjs/upstream-utils.ts | 19 ++- 24 files changed, 457 insertions(+), 414 deletions(-) delete mode 100644 apps/tradinggoose/lib/workflows/custom-tools-persistence.ts diff --git a/apps/tradinggoose/app/api/indicators/custom/route.ts b/apps/tradinggoose/app/api/indicators/custom/route.ts index bc7a9350b..5423ea48f 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -3,9 +3,10 @@ import { pineIndicators, workflow } from '@tradinggoose/db/schema' import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { upsertIndicators } from '@/lib/indicators/custom/operations' +import { createIndicators, saveIndicator } from '@/lib/indicators/custom/operations' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' +import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { authenticateIndicatorRequest, checkWorkspacePermission } from '../utils' const logger = createLogger('IndicatorsAPI') @@ -144,12 +145,39 @@ export async function POST(request: NextRequest) { return permissionCheck.response } - const resultIndicators = await upsertIndicators({ - indicators, - workspaceId, - userId: auth.userId, - requestId, - }) + const indicatorsToCreate = indicators.filter((indicator) => !indicator.id) + const indicatorsToSave = indicators.filter((indicator) => indicator.id) + if (indicatorsToCreate.length > 0 && indicatorsToSave.length > 0) { + return NextResponse.json( + { error: 'Create and save indicators in separate requests' }, + { status: 400 } + ) + } + if (indicatorsToSave.length > 1) { + return NextResponse.json( + { error: 'Save one existing indicator per request' }, + { status: 400 } + ) + } + + const resultIndicators = + indicatorsToSave.length === 1 + ? await saveIndicator({ + indicator: { + id: indicatorsToSave[0].id!, + name: indicatorsToSave[0].name, + pineCode: indicatorsToSave[0].pineCode, + inputMeta: indicatorsToSave[0].inputMeta, + }, + workspaceId, + requestId, + }) + : await createIndicators({ + indicators: indicatorsToCreate, + workspaceId, + userId: auth.userId, + requestId, + }) return NextResponse.json({ success: true, data: resultIndicators }) } catch (validationError) { @@ -170,6 +198,9 @@ export async function POST(request: NextRequest) { { status: 400 } ) } + if (validationError instanceof Error && validationError.message.includes('was not found')) { + return NextResponse.json({ error: validationError.message }, { status: 404 }) + } throw validationError } } catch (error) { @@ -219,16 +250,22 @@ export async function DELETE(request: NextRequest) { return permissionCheck.response } - const [deletedIndicator] = await db - .delete(pineIndicators) + const [existingIndicator] = await db + .select({ id: pineIndicators.id }) + .from(pineIndicators) .where(and(eq(pineIndicators.id, indicatorId), eq(pineIndicators.workspaceId, workspaceId))) - .returning({ id: pineIndicators.id }) + .limit(1) - if (!deletedIndicator) { + if (!existingIndicator) { logger.warn(`[${requestId}] Indicator not found: ${indicatorId}`) return NextResponse.json({ error: 'Indicator not found' }, { status: 404 }) } + await deleteYjsSessionInSocketServer(indicatorId) + await db + .delete(pineIndicators) + .where(and(eq(pineIndicators.id, indicatorId), eq(pineIndicators.workspaceId, workspaceId))) + logger.info(`[${requestId}] Deleted indicator ${indicatorId}`) return NextResponse.json({ success: true }, { status: 200 }) } catch (error) { diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 78bea5ea1..c463eb783 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -8,6 +8,7 @@ import { mcpService } from '@/lib/mcp/service' import type { McpTransport } from '@/lib/mcp/types' import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { CreateMcpServerSchema } from './schema' const logger = createLogger('McpServersAPI') @@ -86,7 +87,7 @@ export const POST = withMcpAuth('write')( body.url = urlValidation.normalizedUrl } - const serverId = body.id || crypto.randomUUID() + const serverId = crypto.randomUUID() const [server] = await db .insert(mcpServers) @@ -159,9 +160,10 @@ export const DELETE = withMcpAuth('write')( logger.info(`[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}`) const [server] = await db - .delete(mcpServers) + .select({ id: mcpServers.id }) + .from(mcpServers) .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - .returning({ id: mcpServers.id }) + .limit(1) if (!server) { return createMcpErrorResponse( @@ -171,6 +173,11 @@ export const DELETE = withMcpAuth('write')( ) } + await deleteYjsSessionInSocketServer(serverId) + await db + .delete(mcpServers) + .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) + mcpService.clearCache(workspaceId) logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) diff --git a/apps/tradinggoose/app/api/mcp/servers/schema.ts b/apps/tradinggoose/app/api/mcp/servers/schema.ts index 9da0e2a8b..2d6360d90 100644 --- a/apps/tradinggoose/app/api/mcp/servers/schema.ts +++ b/apps/tradinggoose/app/api/mcp/servers/schema.ts @@ -20,11 +20,9 @@ const McpServerBaseSchema = z.object({ /** * Schema for creating a new MCP server. - * `name` and `transport` are required; `id` is optional (auto-generated if omitted). + * `name` and `transport` are required. IDs are generated by the server. */ -export const CreateMcpServerSchema = McpServerBaseSchema.extend({ - id: z.string().optional(), -}) +export const CreateMcpServerSchema = McpServerBaseSchema /** * Schema for updating an existing MCP server. diff --git a/apps/tradinggoose/app/api/skills/route.test.ts b/apps/tradinggoose/app/api/skills/route.test.ts index c327d6ed2..bfcf5fa99 100644 --- a/apps/tradinggoose/app/api/skills/route.test.ts +++ b/apps/tradinggoose/app/api/skills/route.test.ts @@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mockCheckHybridAuth = vi.fn() const mockGetUserEntityPermissions = vi.fn() -const mockUpsertSkills = vi.fn() +const mockCreateSkills = vi.fn() +const mockSaveSkill = vi.fn() const mockListSkills = vi.fn() const mockDeleteSkill = vi.fn() @@ -19,7 +20,8 @@ vi.mock('@/lib/permissions/utils', () => ({ })) vi.mock('@/lib/skills/operations', () => ({ - upsertSkills: mockUpsertSkills, + createSkills: mockCreateSkills, + saveSkill: mockSaveSkill, listSkills: mockListSkills, deleteSkill: mockDeleteSkill, })) @@ -45,7 +47,8 @@ describe('Skills API Routes', () => { vi.resetAllMocks() mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-123' }) mockGetUserEntityPermissions.mockResolvedValue('admin') - mockUpsertSkills.mockResolvedValue([]) + mockCreateSkills.mockResolvedValue([]) + mockSaveSkill.mockResolvedValue([]) mockListSkills.mockResolvedValue([]) mockDeleteSkill.mockResolvedValue(true) }) @@ -99,7 +102,7 @@ describe('Skills API Routes', () => { }) it('POST should accept human-readable skill names', async () => { - mockUpsertSkills.mockResolvedValue([ + mockCreateSkills.mockResolvedValue([ { id: 'skill-1', name: 'Market Research', diff --git a/apps/tradinggoose/app/api/skills/route.ts b/apps/tradinggoose/app/api/skills/route.ts index 6d216850e..c343a76f0 100644 --- a/apps/tradinggoose/app/api/skills/route.ts +++ b/apps/tradinggoose/app/api/skills/route.ts @@ -8,7 +8,7 @@ import { SKILL_DESCRIPTION_MAX_LENGTH, SKILL_NAME_MAX_LENGTH, } from '@/lib/skills/import-export' -import { deleteSkill, listSkills, upsertSkills } from '@/lib/skills/operations' +import { createSkills, deleteSkill, listSkills, saveSkill } from '@/lib/skills/operations' import { generateRequestId } from '@/lib/utils' const logger = createLogger('SkillsAPI') @@ -95,12 +95,36 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) } - const resultSkills = await upsertSkills({ - skills, - workspaceId, - userId: authResult.userId, - requestId, - }) + const skillsToCreate = skills.filter((skill) => !skill.id) + const skillsToSave = skills.filter((skill) => skill.id) + if (skillsToCreate.length > 0 && skillsToSave.length > 0) { + return NextResponse.json( + { error: 'Create and save skills in separate requests' }, + { status: 400 } + ) + } + if (skillsToSave.length > 1) { + return NextResponse.json({ error: 'Save one existing skill per request' }, { status: 400 }) + } + + const resultSkills = + skillsToSave.length === 1 + ? await saveSkill({ + skill: { + id: skillsToSave[0].id!, + name: skillsToSave[0].name, + description: skillsToSave[0].description, + content: skillsToSave[0].content, + }, + workspaceId, + requestId, + }) + : await createSkills({ + skills: skillsToCreate, + workspaceId, + userId: authResult.userId, + requestId, + }) return NextResponse.json({ success: true, data: resultSkills }) } catch (validationError) { @@ -122,6 +146,9 @@ export async function POST(request: NextRequest) { if (validationError instanceof Error && validationError.message.includes('already exists')) { return NextResponse.json({ error: validationError.message }, { status: 409 }) } + if (validationError instanceof Error && validationError.message.includes('was not found')) { + return NextResponse.json({ error: validationError.message }, { status: 404 }) + } throw validationError } diff --git a/apps/tradinggoose/app/api/tools/custom/route.test.ts b/apps/tradinggoose/app/api/tools/custom/route.test.ts index d21b1c2b5..b1ef59753 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.test.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.test.ts @@ -6,7 +6,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mockCheckHybridAuth = vi.fn() const mockGetUserEntityPermissions = vi.fn() -const mockUpsertCustomTools = vi.fn() +const mockCreateCustomTools = vi.fn() +const mockSaveCustomTool = vi.fn() +const mockListCustomTools = vi.fn() vi.mock('@/lib/auth/hybrid', () => ({ checkHybridAuth: mockCheckHybridAuth, @@ -17,7 +19,9 @@ vi.mock('@/lib/permissions/utils', () => ({ })) vi.mock('@/lib/custom-tools/operations', () => ({ - upsertCustomTools: mockUpsertCustomTools, + createCustomTools: mockCreateCustomTools, + saveCustomTool: mockSaveCustomTool, + listCustomTools: mockListCustomTools, })) vi.mock('@tradinggoose/db', () => ({ @@ -45,7 +49,9 @@ describe('Custom Tools API Routes', () => { vi.resetAllMocks() mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-123' }) mockGetUserEntityPermissions.mockResolvedValue('admin') - mockUpsertCustomTools.mockResolvedValue([]) + mockCreateCustomTools.mockResolvedValue([]) + mockSaveCustomTool.mockResolvedValue([]) + mockListCustomTools.mockResolvedValue([]) }) afterEach(() => { diff --git a/apps/tradinggoose/app/api/tools/custom/route.ts b/apps/tradinggoose/app/api/tools/custom/route.ts index 835d54063..113561996 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.ts @@ -4,11 +4,12 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' -import { listCustomTools, upsertCustomTools } from '@/lib/custom-tools/operations' -import { CustomToolUpsertRequestSchema } from '@/lib/custom-tools/schema' +import { createCustomTools, listCustomTools, saveCustomTool } from '@/lib/custom-tools/operations' +import { CustomToolWriteRequestSchema } from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' +import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('CustomToolsAPI') @@ -84,7 +85,7 @@ export async function POST(req: NextRequest) { try { // Validate the request body - const { tools, workspaceId } = CustomToolUpsertRequestSchema.parse(body) + const { tools, workspaceId } = CustomToolWriteRequestSchema.parse(body) const permission = await getUserEntityPermissions(authResult.userId, 'workspace', workspaceId) if (!permission) { @@ -101,12 +102,39 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) } - const resultTools = await upsertCustomTools({ - tools, - workspaceId, - userId: authResult.userId, - requestId, - }) + const toolsToCreate = tools.filter((tool) => !tool.id) + const toolsToSave = tools.filter((tool) => tool.id) + if (toolsToCreate.length > 0 && toolsToSave.length > 0) { + return NextResponse.json( + { error: 'Create and save custom tools in separate requests' }, + { status: 400 } + ) + } + if (toolsToSave.length > 1) { + return NextResponse.json( + { error: 'Save one existing custom tool per request' }, + { status: 400 } + ) + } + + const resultTools = + toolsToSave.length === 1 + ? await saveCustomTool({ + tool: { + id: toolsToSave[0].id!, + title: toolsToSave[0].title, + schema: toolsToSave[0].schema, + code: toolsToSave[0].code, + }, + workspaceId, + requestId, + }) + : await createCustomTools({ + tools: toolsToCreate, + workspaceId, + userId: authResult.userId, + requestId, + }) return NextResponse.json({ success: true, data: resultTools }) } catch (validationError) { @@ -127,6 +155,12 @@ export async function POST(req: NextRequest) { { status: 400 } ) } + if (validationError instanceof Error && validationError.message.includes('already exists')) { + return NextResponse.json({ error: validationError.message }, { status: 409 }) + } + if (validationError instanceof Error && validationError.message.includes('was not found')) { + return NextResponse.json({ error: validationError.message }, { status: 404 }) + } throw validationError } } catch (error) { @@ -173,16 +207,22 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) } - const deletedTool = await db - .delete(customTools) + const [existingTool] = await db + .select({ id: customTools.id }) + .from(customTools) .where(and(eq(customTools.id, toolId), eq(customTools.workspaceId, workspaceId))) - .returning({ id: customTools.id }) + .limit(1) - if (deletedTool.length === 0) { + if (!existingTool) { logger.warn(`[${requestId}] Tool not found: ${toolId}`) return NextResponse.json({ error: 'Tool not found' }, { status: 404 }) } + await deleteYjsSessionInSocketServer(toolId) + await db + .delete(customTools) + .where(and(eq(customTools.id, toolId), eq(customTools.workspaceId, workspaceId))) + logger.info(`[${requestId}] Deleted tool: ${toolId}`) return NextResponse.json({ success: true }) } catch (error) { diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index da9e0e48c..3908996cb 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -20,6 +20,7 @@ describe('Workflow By ID API Route', () => { const mockReadWorkflowAccessContext = vi.fn() const mockLoadWorkflowState = vi.fn() const mockApplyWorkflowEntityName = vi.fn() + const mockDeleteYjsSession = vi.fn() beforeEach(() => { vi.resetModules() @@ -67,16 +68,21 @@ describe('Workflow By ID API Route', () => { mockReadWorkflowAccessContext.mockReset() mockLoadWorkflowState.mockReset() mockApplyWorkflowEntityName.mockReset() + mockDeleteYjsSession.mockReset() mockLoadWorkflowState.mockResolvedValue(null) mockApplyWorkflowEntityName.mockResolvedValue({ id: 'workflow-123', name: 'Updated Workflow', workspaceId: null, }) + mockDeleteYjsSession.mockResolvedValue(undefined) vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ applyWorkflowEntityName: mockApplyWorkflowEntityName, })) + vi.doMock('@/lib/yjs/server/snapshot-bridge', () => ({ + deleteYjsSessionInSocketServer: mockDeleteYjsSession, + })) vi.doMock('@/lib/workflows/utils', () => ({ readWorkflowById: mockReadWorkflowById, @@ -364,7 +370,7 @@ describe('Workflow By ID API Route', () => { }) describe('DELETE /api/workflows/[id]', () => { - it('should delete the workflow row without socket cleanup', async () => { + it('should terminate the Yjs session before deleting the workflow row', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -372,6 +378,9 @@ describe('Workflow By ID API Route', () => { workspaceId: null, } const events: string[] = [] + mockDeleteYjsSession.mockImplementation(async () => { + events.push('yjs-delete') + }) vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ @@ -410,10 +419,10 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(200) const data = await response.json() expect(data.success).toBe(true) - expect(events).toEqual(['db-delete']) + expect(events).toEqual(['yjs-delete', 'db-delete']) }) - it('should not clean up the Yjs session if workflow row deletion fails', async () => { + it('should return 500 if workflow row deletion fails after session termination', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -457,6 +466,7 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(500) const data = await response.json() expect(data.error).toBe('Internal server error') + expect(mockDeleteYjsSession).toHaveBeenCalledWith('workflow-123') expect(deleteWhereMock).toHaveBeenCalledOnce() }) @@ -505,7 +515,7 @@ describe('Workflow By ID API Route', () => { expect(data.success).toBe(true) }) - it('should continue deleting the workflow row when socket/Yjs cleanup fails', async () => { + it('should not delete the workflow row when Yjs session termination fails', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -513,6 +523,7 @@ describe('Workflow By ID API Route', () => { workspaceId: null, } const deleteWhereMock = vi.fn().mockResolvedValue([{ id: 'workflow-123' }]) + mockDeleteYjsSession.mockRejectedValueOnce(new Error('socket offline')) vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ @@ -545,10 +556,10 @@ describe('Workflow By ID API Route', () => { const { DELETE } = await import('@/app/api/workflows/[id]/route') const response = await DELETE(req, { params }) - expect(response.status).toBe(200) + expect(response.status).toBe(500) const data = await response.json() - expect(data.success).toBe(true) - expect(deleteWhereMock).toHaveBeenCalledOnce() + expect(data.error).toBe('Internal server error') + expect(deleteWhereMock).not.toHaveBeenCalled() }) it('should deny deletion for non-admin users', async () => { diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index 90ef5261c..8486db553 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -12,6 +12,7 @@ import { generateRequestId } from '@/lib/utils' import { loadWorkflowState } from '@/lib/workflows/db-helpers' import { readWorkflowAccessContext, readWorkflowById } from '@/lib/workflows/utils' import { applyWorkflowEntityName } from '@/lib/yjs/server/apply-workflow-state' +import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' const logger = createLogger('WorkflowByIdAPI') @@ -284,6 +285,7 @@ export async function DELETE( } } + await deleteYjsSessionInSocketServer(workflowId) await db.delete(workflow).where(eq(workflow.id, workflowId)) const elapsed = Date.now() - startTime diff --git a/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts index 4829c4bbe..95101248c 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts @@ -95,13 +95,6 @@ describe('Workflow State API Route', () => { ), })) - vi.doMock('@/lib/workflows/custom-tools-persistence', () => ({ - extractAndPersistCustomTools: vi.fn().mockResolvedValue({ - saved: 0, - errors: [], - }), - })) - vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ applyWorkflowState: applyWorkflowStateMock, })) diff --git a/apps/tradinggoose/app/api/workflows/[id]/state/route.ts b/apps/tradinggoose/app/api/workflows/[id]/state/route.ts index f4b626d21..6ba5a2aab 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/state/route.ts @@ -2,7 +2,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { extractAndPersistCustomTools } from '@/lib/workflows/custom-tools-persistence' import { ensureUniqueBlockIds, ensureUniqueEdgeIds, @@ -188,25 +187,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ workflowData.name ) - // Extract and persist custom tools to database - try { - const { saved, errors } = await extractAndPersistCustomTools( - persistedWorkflowState, - workflowData.workspaceId ?? null, - userId - ) - - if (saved > 0) { - logger.info(`[${requestId}] Persisted ${saved} custom tool(s) to database`, { workflowId }) - } - - if (errors.length > 0) { - logger.warn(`[${requestId}] Some custom tools failed to persist`, { errors, workflowId }) - } - } catch (error) { - logger.error(`[${requestId}] Failed to persist custom tools`, { error, workflowId }) - } - const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully saved workflow ${workflowId} state in ${elapsed}ms`) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts index 136d25e42..707517a53 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts @@ -1,7 +1,6 @@ -import { nanoid } from 'nanoid' import { ENTITY_KIND_CUSTOM_TOOL } from '@/lib/copilot/review-sessions/types' import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' -import { listCustomTools, upsertCustomTools } from '@/lib/custom-tools/operations' +import { createCustomTools, listCustomTools } from '@/lib/custom-tools/operations' import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { @@ -59,26 +58,24 @@ async function createCustomToolEntity( context: Parameters[0] ): Promise { const { userId, workspaceId } = await verifyWorkspaceContext(context, 'write') - const entityId = nanoid() - const rows = await upsertCustomTools({ + const rows = await createCustomTools({ userId, workspaceId, tools: [ { - id: entityId, title: String(fields.title ?? ''), schema: parseCustomToolSchemaText(fields.schemaText), code: String(fields.codeText ?? ''), }, ], }) - const row = rows.find((candidate) => candidate.id === entityId) + const row = rows[0] if (!row) { - throw new Error('Created custom tool was not returned from canonical upsert') + throw new Error('Created custom tool was not returned') } return { - entityId, + entityId: row.id, fields: savedEntityRowToFields(ENTITY_KIND_CUSTOM_TOOL, row), } } diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts index 076c60a40..bb87cd2d1 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts @@ -3,7 +3,7 @@ import { pineIndicators } from '@tradinggoose/db/schema' import { desc, eq } from 'drizzle-orm' import { ENTITY_KIND_INDICATOR } from '@/lib/copilot/review-sessions/types' import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' -import { upsertIndicators } from '@/lib/indicators/custom/operations' +import { createIndicators } from '@/lib/indicators/custom/operations' import { DEFAULT_INDICATOR_RUNTIME_ENTRIES, DEFAULT_INDICATOR_RUNTIME_MAP, @@ -71,13 +71,11 @@ async function createIndicatorEntity( context: Parameters[0] ): Promise { const { userId, workspaceId } = await verifyWorkspaceContext(context, 'write') - const entityId = crypto.randomUUID() - const rows = await upsertIndicators({ + const rows = await createIndicators({ userId, workspaceId, indicators: [ { - id: entityId, name: String(fields.name ?? ''), color: String(fields.color ?? ''), pineCode: String(fields.pineCode ?? ''), @@ -90,13 +88,13 @@ async function createIndicatorEntity( }, ], }) - const row = rows.find((candidate) => candidate.id === entityId) + const row = rows[0] if (!row) { - throw new Error('Created indicator was not returned from canonical upsert') + throw new Error('Created indicator was not returned') } return { - entityId, + entityId: row.id, fields: savedEntityRowToFields(ENTITY_KIND_INDICATOR, row), } } diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts index 335a88afd..42e7ea028 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts @@ -1,7 +1,6 @@ -import { nanoid } from 'nanoid' import { ENTITY_KIND_SKILL } from '@/lib/copilot/review-sessions/types' import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' -import { listSkills, upsertSkills } from '@/lib/skills/operations' +import { createSkills, listSkills } from '@/lib/skills/operations' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { buildDocumentEnvelope, @@ -30,26 +29,24 @@ async function createSkillEntity( context: Parameters[0] ): Promise { const { userId, workspaceId } = await verifyWorkspaceContext(context, 'write') - const entityId = nanoid() - const rows = await upsertSkills({ + const rows = await createSkills({ userId, workspaceId, skills: [ { - id: entityId, name: String(fields.name ?? ''), description: String(fields.description ?? ''), content: String(fields.content ?? ''), }, ], }) - const row = rows.find((candidate) => candidate.id === entityId) + const row = rows[0] if (!row) { - throw new Error('Created skill was not returned from canonical upsert') + throw new Error('Created skill was not returned') } return { - entityId, + entityId: row.id, fields: savedEntityRowToFields(ENTITY_KIND_SKILL, row), } } diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index dc755c8b9..f166a6429 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -12,9 +12,8 @@ import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-st const logger = createLogger('CustomToolsOperations') -interface UpsertCustomToolsParams { +interface CreateCustomToolsParams { tools: Array<{ - id?: string title: string schema: Record code: string @@ -24,6 +23,17 @@ interface UpsertCustomToolsParams { requestId?: string } +interface SaveCustomToolParams { + tool: { + id: string + title: string + schema: Record + code: string + } + workspaceId: string + requestId?: string +} + interface ImportCustomToolsParams { tools: CustomToolTransferRecord[] workspaceId: string @@ -39,21 +49,17 @@ export async function listCustomTools(params: { workspaceId: string }) { .orderBy(desc(customTools.createdAt)) } -/** - * Create or update custom tools scoped to a workspace. - */ -export async function upsertCustomTools({ +export async function createCustomTools({ tools, workspaceId, userId, requestId = generateRequestId(), -}: UpsertCustomToolsParams) { - const updates: Array<{ - id: string - fields: Record - }> = [] +}: CreateCustomToolsParams) { + if (tools.length === 0) { + return [] + } - await db.transaction(async (tx) => { + return await db.transaction(async (tx) => { const existingTools = await tx .select({ id: customTools.id, @@ -62,43 +68,20 @@ export async function upsertCustomTools({ .from(customTools) .where(eq(customTools.workspaceId, workspaceId)) - const existingById = new Map( - existingTools.map((tool) => [tool.id, { id: tool.id, title: tool.title }]) - ) const plannedTitles = new Map(existingTools.map((tool) => [tool.title, tool.id])) + const nowTime = new Date() + const insertValues = [] for (const tool of tools) { - const nowTime = new Date() - const existingTool = tool.id ? existingById.get(tool.id) : null const conflictingToolId = plannedTitles.get(tool.title) - if (conflictingToolId && conflictingToolId !== tool.id) { + if (conflictingToolId) { throw new Error(`A tool with the title "${tool.title}" already exists in this workspace`) } - if (existingTool && tool.id) { - if (existingTool.title !== tool.title) { - plannedTitles.delete(existingTool.title) - plannedTitles.set(tool.title, tool.id) - existingTool.title = tool.title - } - - updates.push({ - id: tool.id, - fields: { - title: tool.title, - schemaText: JSON.stringify(tool.schema, null, 2), - codeText: tool.code, - }, - }) - logger.info(`[${requestId}] Updated custom tool ${tool.id}`) - continue - } - - const toolId = tool.id || nanoid() + const toolId = nanoid() plannedTitles.set(tool.title, toolId) - existingById.set(toolId, { id: toolId, title: tool.title }) - const newTool = { + insertValues.push({ id: toolId, workspaceId, userId, @@ -107,19 +90,44 @@ export async function upsertCustomTools({ code: tool.code, createdAt: nowTime, updatedAt: nowTime, - } - await tx.insert(customTools).values(newTool) - - logger.info(`[${requestId}] Created custom tool ${tool.title}`) + }) } + + const createdTools = await tx.insert(customTools).values(insertValues).returning() + logger.info(`[${requestId}] Created ${createdTools.length} custom tool(s)`) + return createdTools }) +} - await Promise.all( - updates.map(({ id, fields }) => - applySavedEntityPersistedState('custom_tool', id, workspaceId, fields) - ) +export async function saveCustomTool({ + tool, + workspaceId, + requestId = generateRequestId(), +}: SaveCustomToolParams) { + const existingTools = await db + .select({ + id: customTools.id, + title: customTools.title, + }) + .from(customTools) + .where(eq(customTools.workspaceId, workspaceId)) + const existingTool = existingTools.find((candidate) => candidate.id === tool.id) + if (!existingTool) { + throw new Error(`Custom tool ${tool.id} was not found`) + } + const conflictingTool = existingTools.find( + (candidate) => candidate.title === tool.title && candidate.id !== tool.id ) - + if (conflictingTool) { + throw new Error(`A tool with the title "${tool.title}" already exists in this workspace`) + } + + await applySavedEntityPersistedState('custom_tool', tool.id, workspaceId, { + title: tool.title, + schemaText: JSON.stringify(tool.schema, null, 2), + codeText: tool.code, + }) + logger.info(`[${requestId}] Saved custom tool ${tool.id}`) return listCustomTools({ workspaceId }) } diff --git a/apps/tradinggoose/lib/custom-tools/schema.ts b/apps/tradinggoose/lib/custom-tools/schema.ts index 2e8be0383..9ffb97d99 100644 --- a/apps/tradinggoose/lib/custom-tools/schema.ts +++ b/apps/tradinggoose/lib/custom-tools/schema.ts @@ -71,7 +71,7 @@ export const CustomToolTransferSchema = z }) .strict() -export const CustomToolUpsertRequestSchema = z.object({ +export const CustomToolWriteRequestSchema = z.object({ workspaceId: z .string({ required_error: 'workspaceId is required' }) .min(1, 'workspaceId is required'), diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index 3f6bfff19..d4285ca88 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -26,9 +26,8 @@ export async function listCustomIndicatorRuntimeEntries(workspaceId: string) { })) } -interface UpsertIndicatorsParams { +interface CreateIndicatorsParams { indicators: Array<{ - id?: string name: string color?: string pineCode: string @@ -39,6 +38,17 @@ interface UpsertIndicatorsParams { requestId?: string } +interface SaveIndicatorParams { + indicator: { + id: string + name: string + pineCode: string + inputMeta?: Record + } + workspaceId: string + requestId?: string +} + interface ImportIndicatorsParams { indicators: IndicatorTransferRecord[] workspaceId: string @@ -46,49 +56,23 @@ interface ImportIndicatorsParams { requestId?: string } -export async function upsertIndicators({ +export async function createIndicators({ indicators, workspaceId, userId, requestId = generateRequestId(), -}: UpsertIndicatorsParams) { - const updates: Array<{ - id: string - fields: Record - }> = [] +}: CreateIndicatorsParams) { + if (indicators.length === 0) { + return [] + } - await db.transaction(async (tx) => { - for (const indicator of indicators) { - const nowTime = new Date() - - if (indicator.id) { - const existing = await tx - .select() - .from(pineIndicators) - .where( - and(eq(pineIndicators.id, indicator.id), eq(pineIndicators.workspaceId, workspaceId)) - ) - .limit(1) - - if (existing.length > 0) { - const existingColor = existing[0]?.color - - updates.push({ - id: indicator.id, - fields: { - name: indicator.name, - color: existingColor ?? getStableVibrantColor(indicator.id), - pineCode: indicator.pineCode, - inputMeta: indicator.inputMeta ?? null, - }, - }) - logger.info(`[${requestId}] Updated Indicator ${indicator.id}`) - continue - } - } + return await db.transaction(async (tx) => { + const nowTime = new Date() + const insertValues = [] - const indicatorId = indicator.id ?? crypto.randomUUID() - const newIndicator = { + for (const indicator of indicators) { + const indicatorId = crypto.randomUUID() + insertValues.push({ id: indicatorId, workspaceId, userId, @@ -98,19 +82,40 @@ export async function upsertIndicators({ inputMeta: indicator.inputMeta ?? null, createdAt: nowTime, updatedAt: nowTime, - } - await tx.insert(pineIndicators).values(newIndicator) - - logger.info(`[${requestId}] Created Indicator ${indicator.name}`) + }) } - }) - await Promise.all( - updates.map(({ id, fields }) => - applySavedEntityPersistedState('indicator', id, workspaceId, fields) - ) - ) + const createdIndicators = await tx.insert(pineIndicators).values(insertValues).returning() + logger.info(`[${requestId}] Created ${createdIndicators.length} indicator(s)`) + return createdIndicators + }) +} +export async function saveIndicator({ + indicator, + workspaceId, + requestId = generateRequestId(), +}: SaveIndicatorParams) { + const [existing] = await db + .select({ + id: pineIndicators.id, + color: pineIndicators.color, + }) + .from(pineIndicators) + .where(and(eq(pineIndicators.id, indicator.id), eq(pineIndicators.workspaceId, workspaceId))) + .limit(1) + + if (!existing) { + throw new Error(`Indicator ${indicator.id} was not found`) + } + + await applySavedEntityPersistedState('indicator', indicator.id, workspaceId, { + name: indicator.name, + color: existing.color ?? getStableVibrantColor(indicator.id), + pineCode: indicator.pineCode, + inputMeta: indicator.inputMeta ?? null, + }) + logger.info(`[${requestId}] Saved Indicator ${indicator.id}`) return db .select() .from(pineIndicators) diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index c9c19c09a..327439148 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -22,6 +22,7 @@ import type { import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/permissions/utils' import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' +import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('KnowledgeBaseService') @@ -429,6 +430,7 @@ export async function deleteKnowledgeBase( ): Promise { const now = new Date() + await deleteYjsSessionInSocketServer(knowledgeBaseId) await db .update(knowledgeBase) .set({ diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index 4f72f57b4..0731a565a 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -10,12 +10,12 @@ import { } from '@/lib/skills/import-export' import { generateRequestId } from '@/lib/utils' import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' +import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('SkillsOperations') -interface UpsertSkillsParams { +interface CreateSkillsParams { skills: Array<{ - id?: string name: string description: string content: string @@ -25,6 +25,17 @@ interface UpsertSkillsParams { requestId?: string } +interface SaveSkillParams { + skill: { + id: string + name: string + description: string + content: string + } + workspaceId: string + requestId?: string +} + interface ImportSkillsParams { skills: SkillTransferRecord[] workspaceId: string @@ -44,31 +55,36 @@ export async function deleteSkill(params: { skillId: string workspaceId: string }): Promise { - const deletedSkill = await db - .delete(skill) + const [existingSkill] = await db + .select({ id: skill.id }) + .from(skill) .where(and(eq(skill.id, params.skillId), eq(skill.workspaceId, params.workspaceId))) - .returning({ id: skill.id }) + .limit(1) - if (deletedSkill.length === 0) { + if (!existingSkill) { return false } + await deleteYjsSessionInSocketServer(params.skillId) + await db + .delete(skill) + .where(and(eq(skill.id, params.skillId), eq(skill.workspaceId, params.workspaceId))) + logger.info(`Deleted skill ${params.skillId}`) return true } -export async function upsertSkills({ +export async function createSkills({ skills, workspaceId, userId, requestId = generateRequestId(), -}: UpsertSkillsParams) { - const updates: Array<{ - id: string - fields: Record - }> = [] +}: CreateSkillsParams) { + if (skills.length === 0) { + return [] + } - await db.transaction(async (tx) => { + return await db.transaction(async (tx) => { const existingSkills = await tx .select({ id: skill.id, @@ -77,50 +93,24 @@ export async function upsertSkills({ .from(skill) .where(eq(skill.workspaceId, workspaceId)) - const existingById = new Map( - existingSkills.map((currentSkill) => [ - currentSkill.id, - { id: currentSkill.id, name: currentSkill.name }, - ]) - ) const plannedNames = new Map( existingSkills.map((currentSkill) => [currentSkill.name, currentSkill.id]) ) + const nowTime = new Date() + const insertValues = [] for (const currentSkill of skills) { - const nowTime = new Date() - const existingSkill = currentSkill.id ? existingById.get(currentSkill.id) : null const conflictingSkillId = plannedNames.get(currentSkill.name) - if (conflictingSkillId && conflictingSkillId !== currentSkill.id) { + if (conflictingSkillId) { throw new Error( `A skill with the name "${currentSkill.name}" already exists in this workspace` ) } - if (existingSkill && currentSkill.id) { - if (existingSkill.name !== currentSkill.name) { - plannedNames.delete(existingSkill.name) - plannedNames.set(currentSkill.name, currentSkill.id) - existingSkill.name = currentSkill.name - } - - updates.push({ - id: currentSkill.id, - fields: { - name: currentSkill.name, - description: currentSkill.description, - content: currentSkill.content, - }, - }) - logger.info(`[${requestId}] Updated skill ${currentSkill.id}`) - continue - } - - const skillId = currentSkill.id || nanoid() + const skillId = nanoid() plannedNames.set(currentSkill.name, skillId) - existingById.set(skillId, { id: skillId, name: currentSkill.name }) - const newSkill = { + insertValues.push({ id: skillId, workspaceId, userId, @@ -129,19 +119,44 @@ export async function upsertSkills({ content: currentSkill.content, createdAt: nowTime, updatedAt: nowTime, - } - await tx.insert(skill).values(newSkill) - - logger.info(`[${requestId}] Created skill "${currentSkill.name}"`) + }) } + + const createdSkills = await tx.insert(skill).values(insertValues).returning() + logger.info(`[${requestId}] Created ${createdSkills.length} skill(s)`) + return createdSkills }) +} - await Promise.all( - updates.map(({ id, fields }) => - applySavedEntityPersistedState('skill', id, workspaceId, fields) - ) +export async function saveSkill({ + skill: currentSkill, + workspaceId, + requestId = generateRequestId(), +}: SaveSkillParams) { + const existingSkills = await db + .select({ + id: skill.id, + name: skill.name, + }) + .from(skill) + .where(eq(skill.workspaceId, workspaceId)) + const existingSkill = existingSkills.find((candidate) => candidate.id === currentSkill.id) + if (!existingSkill) { + throw new Error(`Skill ${currentSkill.id} was not found`) + } + const conflictingSkill = existingSkills.find( + (candidate) => candidate.name === currentSkill.name && candidate.id !== currentSkill.id ) + if (conflictingSkill) { + throw new Error(`A skill with the name "${currentSkill.name}" already exists in this workspace`) + } + await applySavedEntityPersistedState('skill', currentSkill.id, workspaceId, { + name: currentSkill.name, + description: currentSkill.description, + content: currentSkill.content, + }) + logger.info(`[${requestId}] Saved skill ${currentSkill.id}`) return listSkills({ workspaceId }) } diff --git a/apps/tradinggoose/lib/workflows/custom-tools-persistence.ts b/apps/tradinggoose/lib/workflows/custom-tools-persistence.ts deleted file mode 100644 index b3229b0d1..000000000 --- a/apps/tradinggoose/lib/workflows/custom-tools-persistence.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { createLogger } from '@/lib/logs/console/logger' -import { getCustomToolEntityIdFromRuntimeId } from '@/lib/custom-tools/schema' -import { upsertCustomTools } from '@/lib/custom-tools/operations' - -const logger = createLogger('CustomToolsPersistence') - -interface CustomTool { - id?: string - type: 'custom-tool' - title: string - toolId: string - schema: { - function: { - description: string - parameters: Record - } - } - code: string - usageControl?: string -} - -/** - * Extract all custom tools from agent blocks in the workflow state - */ -export function extractCustomToolsFromWorkflowState(workflowState: any): CustomTool[] { - const customToolsMap = new Map() - - if (!workflowState?.blocks) { - return [] - } - - for (const [blockId, block] of Object.entries(workflowState.blocks)) { - try { - const blockData = block as any - - // Only process agent blocks - if (!blockData || blockData.type !== 'agent') { - continue - } - - const subBlocks = blockData.subBlocks || {} - const toolsSubBlock = subBlocks.tools - - if (!toolsSubBlock?.value) { - continue - } - - let tools = toolsSubBlock.value - - // Parse if it's a string - if (typeof tools === 'string') { - try { - tools = JSON.parse(tools) - } catch (error) { - logger.warn(`Failed to parse tools in block ${blockId}`, { error }) - continue - } - } - - if (!Array.isArray(tools)) { - continue - } - - // Extract custom tools - for (const tool of tools) { - if ( - tool && - typeof tool === 'object' && - tool.type === 'custom-tool' && - tool.title && - tool.toolId && - tool.schema?.function && - tool.code - ) { - const toolKey = tool.toolId - - // Deduplicate by toolKey (if same tool appears in multiple blocks) - if (!customToolsMap.has(toolKey)) { - customToolsMap.set(toolKey, tool as CustomTool) - } - } - } - } catch (error) { - logger.error(`Error extracting custom tools from block ${blockId}`, { error }) - } - } - - return Array.from(customToolsMap.values()) -} - -/** - * Persist custom tools to the database - * Creates new tools or updates existing ones - */ -export async function persistCustomToolsToDatabase( - customToolsList: CustomTool[], - workspaceId: string | null, - userId: string -): Promise<{ saved: number; errors: string[] }> { - if (!customToolsList || customToolsList.length === 0) { - return { saved: 0, errors: [] } - } - - if (!workspaceId) { - logger.debug('Skipping custom tools persistence - no workspaceId provided') - return { saved: 0, errors: [] } - } - - const errors: string[] = [] - try { - await upsertCustomTools({ - tools: customToolsList.map((tool) => ({ - id: normalizeToolId(tool), - title: tool.title, - schema: tool.schema, - code: tool.code, - })), - workspaceId, - userId, - }) - - logger.info(`Persisted ${customToolsList.length} custom tool(s)`, { workspaceId }) - return { saved: customToolsList.length, errors } - } catch (error) { - const errorMsg = `Failed to persist custom tools: ${error instanceof Error ? error.message : String(error)}` - logger.error(errorMsg, { error }) - errors.push(errorMsg) - return { saved: 0, errors } - } -} - -function normalizeToolId(tool: CustomTool): string { - return getCustomToolEntityIdFromRuntimeId(tool.toolId) -} - -/** - * Extract and persist custom tools from workflow state in one operation - */ -export async function extractAndPersistCustomTools( - workflowState: any, - workspaceId: string | null, - userId: string -): Promise<{ saved: number; errors: string[] }> { - const customToolsList = extractCustomToolsFromWorkflowState(workflowState) - - if (customToolsList.length === 0) { - logger.debug('No custom tools found in workflow state') - return { saved: 0, errors: [] } - } - - logger.info(`Found ${customToolsList.length} custom tool(s) to persist`, { - tools: customToolsList.map((t) => t.title), - workspaceId, - }) - - return await persistCustomToolsToDatabase(customToolsList, workspaceId, userId) -} diff --git a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts index 6dc130405..499bf516f 100644 --- a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts +++ b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts @@ -135,3 +135,11 @@ export async function applyYjsUpdateInSocketServer( { updateBase64 } ) } + +export async function deleteYjsSessionInSocketServer(sessionId: string): Promise { + await fetchFromSocketServer( + new URL(`/internal/yjs/sessions/${encodeURIComponent(sessionId)}`, getSocketServerUrl()), + { method: 'DELETE' }, + 10000 + ) +} diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index 6423d3658..d2de50fea 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -20,11 +20,13 @@ import { } from '@/lib/yjs/workflow-session' import { getMonitorRuntimeLockHealth } from '@/socket-server/monitor-runtime-lock' import { + deleteState, getLastTouchedAt, getState, storeState, } from '@/socket-server/yjs/persistence' import { + discardDocument, flushDocumentPersistence, getDocument, getExistingDocument, @@ -50,8 +52,8 @@ const INTERNAL_SECRET_HEADER = 'x-internal-secret' const INTERNAL_YJS_WORKFLOW_APPLY_PATH = /^\/internal\/yjs\/workflows\/([^/]+)\/apply-state$/ const INTERNAL_YJS_ENTITY_APPLY_PATH = /^\/internal\/yjs\/entities\/([^/]+)\/apply-state$/ const INTERNAL_YJS_SNAPSHOT_PATH = /^\/internal\/yjs\/sessions\/([^/]+)\/snapshot$/ -const INTERNAL_YJS_SESSION_APPLY_UPDATE_PATH = - /^\/internal\/yjs\/sessions\/([^/]+)\/apply-update$/ +const INTERNAL_YJS_SESSION_DELETE_PATH = /^\/internal\/yjs\/sessions\/([^/]+)$/ +const INTERNAL_YJS_SESSION_APPLY_UPDATE_PATH = /^\/internal\/yjs\/sessions\/([^/]+)\/apply-update$/ type ApplyWorkflowStateRequest = { workflowState?: WorkflowSnapshot @@ -247,7 +249,10 @@ async function getInitializedSessionDocument(sessionId: string): Promise async function getBootstrappedApplyDocument( descriptor: ReturnType ): Promise { - if (!(await getExistingDocument(descriptor.yjsSessionId)) && !(await getState(descriptor.yjsSessionId))) { + if ( + !(await getExistingDocument(descriptor.yjsSessionId)) && + !(await getState(descriptor.yjsSessionId)) + ) { if (!descriptor.entityId) { throw new InvalidInternalYjsRequestError('Saved Yjs session required') } @@ -418,6 +423,26 @@ async function handleInternalYjsSnapshotRequest( } } +async function handleInternalYjsSessionDeleteRequest( + res: ServerResponse, + logger: Logger, + sessionId: string +): Promise { + try { + if (await getExistingDocument(sessionId)) { + setPersistence(sessionId, { getState, storeState: async () => {} }) + discardDocument(sessionId) + } + await deleteState(sessionId) + sendJson(res, 200, { success: true }) + } catch (error) { + logger.error('Error deleting Yjs session', { error, sessionId }) + sendJson(res, 500, { + error: error instanceof Error ? error.message : 'Failed to delete Yjs session', + }) + } +} + function matchInternalRoute( pathname: string, pattern: RegExp, @@ -468,6 +493,17 @@ async function handleInternalYjsRequest( return true } + const deleteSessionId = matchInternalRoute( + parsedUrl.pathname, + INTERNAL_YJS_SESSION_DELETE_PATH, + 'DELETE', + req.method + ) + if (deleteSessionId) { + await handleInternalYjsSessionDeleteRequest(res, logger, deleteSessionId) + return true + } + const applyUpdateId = matchInternalRoute( parsedUrl.pathname, INTERNAL_YJS_SESSION_APPLY_UPDATE_PATH, diff --git a/apps/tradinggoose/socket-server/yjs/persistence.ts b/apps/tradinggoose/socket-server/yjs/persistence.ts index c15f73fa2..4520ae635 100644 --- a/apps/tradinggoose/socket-server/yjs/persistence.ts +++ b/apps/tradinggoose/socket-server/yjs/persistence.ts @@ -137,6 +137,19 @@ export async function storeState(sessionId: string, state: Uint8Array): Promise< evictOldestLocalEntries() } +export async function deleteState(sessionId: string): Promise { + if (getRedisStorageMode() === 'redis') { + const redis = getRedisClient() + if (!redis) { + return + } + await redis.del(stateKey(sessionId), updatedAtKey(sessionId)) + return + } + + localStore.delete(sessionId) +} + export async function getLastTouchedAt(sessionId: string): Promise { const mode = getRedisStorageMode() diff --git a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts index 9a9009e44..5db759e25 100644 --- a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts +++ b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts @@ -3,7 +3,7 @@ * * Uses the app's single Yjs runtime and exposes only the helpers this repo * needs: `getDocument`, `getExistingDocument`, `peekDocument`, - * `setupWSConnection`, `setPersistence`, `removeDocument`, + * `setupWSConnection`, `setPersistence`, `removeDocument`, `discardDocument`, * and `cleanupAllDocuments`. */ @@ -377,6 +377,23 @@ export function removeDocument(docId: string): void { }) } +export function discardDocument(docId: string): void { + const doc = docs.get(docId) + if (!doc) { + return + } + + const conns = Array.from(doc.conns.keys()) + cleanupDocument(doc) + conns.forEach((conn) => { + try { + conn.close() + } catch { + // ignore + } + }) +} + export function cleanupAllDocuments(): void { for (const docId of Array.from(docs.keys())) { removeDocument(docId) From f422fdd70aa40fd38f7d866b874ca28442ec096a Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 18:37:27 -0600 Subject: [PATCH 135/284] feat(workflows): load workflow state through the Yjs snapshot bridge Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/workflows/db-helpers.test.ts | 35 ++++++++++--------- apps/tradinggoose/lib/workflows/db-helpers.ts | 33 +++++++++-------- 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index bc15585ac..91f2f9359 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -110,19 +110,21 @@ vi.doMock('@/lib/logs/console/logger', () => ({ })) const mockReconcilePublishedChatsForDeploymentTx = vi.fn() -const mockReadBootstrappedReviewTargetSnapshot = vi.fn() -class MockReviewTargetBootstrapError extends Error { +const mockGetYjsSnapshot = vi.fn() +class MockSocketServerBridgeError extends Error { + body = '' + constructor( public status: number, message: string ) { super(message) - this.name = 'ReviewTargetBootstrapError' + this.name = 'SocketServerBridgeError' } } -vi.doMock('@/lib/yjs/server/bootstrap-review-target', () => ({ - readBootstrappedReviewTargetSnapshot: mockReadBootstrappedReviewTargetSnapshot, - ReviewTargetBootstrapError: MockReviewTargetBootstrapError, +vi.doMock('@/lib/yjs/server/snapshot-bridge', () => ({ + getYjsSnapshot: mockGetYjsSnapshot, + SocketServerBridgeError: MockSocketServerBridgeError, })) vi.doMock('@/lib/chat/published-deployment', () => ({ @@ -336,9 +338,7 @@ describe('Database Helpers', () => { beforeEach(() => { vi.clearAllMocks() - mockReadBootstrappedReviewTargetSnapshot.mockRejectedValue( - new MockReviewTargetBootstrapError(404, 'Not found') - ) + mockGetYjsSnapshot.mockRejectedValue(new MockSocketServerBridgeError(404, 'Not found')) mockReconcilePublishedChatsForDeploymentTx.mockResolvedValue(undefined) mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ @@ -925,7 +925,7 @@ describe('Database Helpers', () => { }) expect(result.success).toBe(true) - expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalled() + expect(mockGetYjsSnapshot).toHaveBeenCalled() expect(result.currentState).toMatchObject({ blocks: expect.objectContaining({ 'block-1': expect.objectContaining({ id: 'block-1' }), @@ -964,7 +964,7 @@ describe('Database Helpers', () => { }) describe('loadWorkflowStateFromYjs', () => { - it('should decode the workflow state from the bootstrapped Yjs snapshot', async () => { + it('should decode the workflow state from an existing Yjs snapshot', async () => { const yjsState = { blocks: { 'block-yjs': { @@ -991,17 +991,18 @@ describe('Database Helpers', () => { }, } - mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( + mockGetYjsSnapshot.mockResolvedValue( buildWorkflowSnapshotResponseFromState(yjsState, yjsVariables) ) const result = await dbHelpers.loadWorkflowStateFromYjs(mockWorkflowId) - expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalledWith( + expect(mockGetYjsSnapshot).toHaveBeenCalledWith( + mockWorkflowId, expect.objectContaining({ + sessionId: mockWorkflowId, entityKind: 'workflow', entityId: mockWorkflowId, - yjsSessionId: mockWorkflowId, }) ) expect(result).toMatchObject({ @@ -1028,7 +1029,7 @@ describe('Database Helpers', () => { 'var-yjs': { id: 'var-yjs', value: 'latest' }, } - mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( + mockGetYjsSnapshot.mockResolvedValue( buildWorkflowSnapshotResponseFromState(yjsState, yjsVariables) ) mockDb.select.mockReturnValue({ @@ -1067,7 +1068,7 @@ describe('Database Helpers', () => { } let normalizedQueryCount = 0 - mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( + mockGetYjsSnapshot.mockResolvedValue( buildWorkflowSnapshotResponseFromState(yjsState, { 'var-yjs': { id: 'var-yjs', value: 'stale' }, }) @@ -1177,7 +1178,7 @@ describe('Database Helpers', () => { deployedAt: deployedAt.toISOString(), source: 'db', }) - expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalled() + expect(mockGetYjsSnapshot).toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index b4d20f5b8..b0f2da1c6 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -12,10 +12,15 @@ import { and, desc, eq, inArray, ne, sql } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' import * as Y from 'yjs' import { reconcilePublishedChatsForDeploymentTx } from '@/lib/chat/published-deployment' +import { + buildYjsTransportEnvelope, + serializeYjsTransportEnvelope, +} from '@/lib/copilot/review-sessions/identity' import { createLogger } from '@/lib/logs/console/logger' import { resolveStoredDateValue } from '@/lib/time-format' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' import { inferWorkflowDirectionFromState } from '@/lib/workflows/workflow-direction' +import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' import { extractPersistedStateFromDoc } from '@/lib/yjs/workflow-session' import type { Variable } from '@/stores/variables/types' import type { @@ -96,22 +101,22 @@ export type PersistedWorkflowState = { export async function loadWorkflowStateFromYjs( workflowId: string ): Promise { - const { readBootstrappedReviewTargetSnapshot, ReviewTargetBootstrapError } = await import( - '@/lib/yjs/server/bootstrap-review-target' - ) - - let snapshot: Awaited> + const descriptor = { + workspaceId: null, + entityKind: 'workflow' as const, + entityId: workflowId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: workflowId, + } + let snapshot: Awaited> try { - snapshot = await readBootstrappedReviewTargetSnapshot({ - workspaceId: null, - entityKind: 'workflow', - entityId: workflowId, - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: workflowId, - }) + snapshot = await getYjsSnapshot( + workflowId, + serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor)) + ) } catch (error) { - if (error instanceof ReviewTargetBootstrapError && error.status === 404) { + if (error instanceof SocketServerBridgeError && error.status === 404) { return null } throw error From 8b9764c97809c9c2fb456d83f756b2c49e021582 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 18:37:39 -0600 Subject: [PATCH 136/284] fix(mcp): harden device login approval challenge handling Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/mcp/auth.ts | 55 ++++++++++++++++++------------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 3a4c0c27b..7a8a16a83 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -66,7 +66,7 @@ function signDeviceLoginCode(unsignedCode: string): string { return createHmac('sha256', env.INTERNAL_API_SECRET).update(unsignedCode).digest('base64url') } -function buildDeviceLoginIdentifier(code: string): string { +function buildDeviceLoginId(code: string): string { return `${DEVICE_LOGIN_PREFIX}${hashValue(code)}` } @@ -91,7 +91,7 @@ function createDeviceLoginCode({ function readDeviceLoginCode(code: string): { state: PendingDeviceLogin expiresAt: Date - identifier: string + id: string } | null { try { const [nonce, issuedAtRaw, expiresAtRaw, verificationKeyHash, encodedSignature, extra] = @@ -129,7 +129,7 @@ function readDeviceLoginCode(code: string): { return { expiresAt: new Date(expiresAt), - identifier: buildDeviceLoginIdentifier(code), + id: buildDeviceLoginId(code), state: { status: 'pending', createdAt: new Date(issuedAt).toISOString(), @@ -181,7 +181,7 @@ async function readDeviceLogin(code: string) { expiresAt: verification.expiresAt, }) .from(verification) - .where(eq(verification.identifier, buildDeviceLoginIdentifier(code))) + .where(eq(verification.id, buildDeviceLoginId(code))) .limit(1) if (!row) { @@ -237,7 +237,7 @@ export async function createMcpDeviceLoginApprovalChallenge({ code: string userId: string }): Promise { - const login = await readDeviceLogin(code) + let login = await readDeviceLogin(code) if (!login) { const codeState = readDeviceLoginCode(code) if (!codeState) { @@ -245,23 +245,35 @@ export async function createMcpDeviceLoginApprovalChallenge({ } const approvalToken = randomBytes(32).toString('base64url') - await db.insert(verification).values({ - id: nanoid(), - identifier: codeState.identifier, - value: JSON.stringify({ - ...codeState.state, - approvalUserId: userId, - approvalTokenHash: hashValue(approvalToken), - } satisfies PendingDeviceLogin), - expiresAt: codeState.expiresAt, - createdAt: new Date(codeState.state.createdAt), - updatedAt: new Date(), - }) + const state = { + ...codeState.state, + approvalUserId: userId, + approvalTokenHash: hashValue(approvalToken), + } satisfies PendingDeviceLogin + const [inserted] = await db + .insert(verification) + .values({ + id: codeState.id, + identifier: codeState.id, + value: JSON.stringify(state), + expiresAt: codeState.expiresAt, + createdAt: new Date(codeState.state.createdAt), + updatedAt: new Date(), + }) + .onConflictDoNothing({ target: verification.id }) + .returning({ id: verification.id }) - return { - status: 'pending', - expiresAt: codeState.expiresAt.toISOString(), - approvalToken, + if (inserted) { + return { + status: 'pending', + expiresAt: codeState.expiresAt.toISOString(), + approvalToken, + } + } + + login = await readDeviceLogin(code) + if (!login) { + return { status: 'expired' } } } @@ -373,7 +385,6 @@ export async function pollMcpDeviceLogin( expiresAt: login.expiresAt.toISOString(), } } - export async function approveMcpDeviceLogin({ approvalToken, code, From def05e8ea770b95a6d942470d0c2ece283da7635 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 18:37:47 -0600 Subject: [PATCH 137/284] feat(api): scope endpoint rate limits by endpoint type Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/api/rate-limit.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/tradinggoose/lib/api/rate-limit.ts b/apps/tradinggoose/lib/api/rate-limit.ts index 93ca720e5..af88fcfd9 100644 --- a/apps/tradinggoose/lib/api/rate-limit.ts +++ b/apps/tradinggoose/lib/api/rate-limit.ts @@ -16,6 +16,19 @@ export interface RateLimitResult { failureKind?: 'auth' | 'dependency' } +export type ApiRateLimitEndpoint = 'api-endpoint' | 'copilot-mcp' | 'logs' | 'logs-detail' + +function getApiEndpointRateLimitScope(userId: string, endpoint: ApiRateLimitEndpoint) { + return endpoint === 'api-endpoint' + ? undefined + : { + scopeType: 'user' as const, + scopeId: `${userId}:${endpoint}`, + organizationId: null, + userId, + } +} + export async function createApiAuthFailureRateLimitResult(error: string): Promise { const limit = await isBillingEnabledForRuntime() .then((enabled) => (enabled ? 0 : Number.MAX_SAFE_INTEGER)) @@ -32,7 +45,7 @@ export async function createApiAuthFailureRateLimitResult(error: string): Promis export async function checkApiEndpointRateLimit( userId: string, - endpoint = 'api-endpoint' + endpoint: ApiRateLimitEndpoint = 'api-endpoint' ): Promise { try { const billingEnabled = await isBillingEnabledForRuntime() @@ -47,12 +60,14 @@ export async function checkApiEndpointRateLimit( } const subscription = await getPersonalEffectiveSubscription(userId) + const billingScope = getApiEndpointRateLimitScope(userId, endpoint) const result = await rateLimiter.checkRateLimitWithSubscription( userId, subscription, 'api-endpoint', - false + false, + billingScope ) if (!result.allowed) { @@ -67,7 +82,8 @@ export async function checkApiEndpointRateLimit( userId, subscription, 'api-endpoint', - false + false, + billingScope ) return { From 5821043aa7d5e528e7693482a5e33312d85ca137 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 18:37:55 -0600 Subject: [PATCH 138/284] fix(mcp): ignore malformed client configs when reading tokens Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/mcp/local-config-writer-script.test.ts | 11 +++++++++++ .../lib/mcp/local-config-writer-script.ts | 7 ++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts b/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts index 48579b687..f53ebf0ae 100644 --- a/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts +++ b/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts @@ -109,6 +109,17 @@ describe('MCP local config writer script', () => { expect(stdout.trim()).toBe('existing-token') }) + it('skips malformed JSON client configs while discovering existing tokens', () => { + const home = mkdtempSync(join(tmpdir(), 'tg-mcp-token-malformed-')) + mkdirSync(join(home, '.cursor'), { recursive: true }) + writeFileSync(join(home, '.cursor', 'mcp.json'), '{', 'utf8') + runWriter(home, ['claude', 'http://localhost:3000/api/copilot/mcp', 'valid-token']) + + const stdout = runWriterCapture(home, ['read-tokens']) + + expect(stdout.trim()).toBe('valid-token') + }) + it('writes JSON client configs with the TradingGoose server name', () => { const home = mkdtempSync(join(tmpdir(), 'tg-mcp-cursor-')) const configPath = join(home, '.cursor', 'mcp.json') diff --git a/apps/tradinggoose/lib/mcp/local-config-writer-script.ts b/apps/tradinggoose/lib/mcp/local-config-writer-script.ts index e709488f7..5ca462af6 100644 --- a/apps/tradinggoose/lib/mcp/local-config-writer-script.ts +++ b/apps/tradinggoose/lib/mcp/local-config-writer-script.ts @@ -131,7 +131,12 @@ function findTomlSection(text, sectionHeader) { } function readJsonToken(filePath, section) { - const config = readJson(filePath) + let config + try { + config = readJson(filePath) + } catch { + return null + } return bearerTokenFromHeader(config?.[section]?.[mcpServerName]?.headers?.Authorization) } From d785788e2fea2ad5832f5f667a12a8a85f8d78f6 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 19:39:19 -0600 Subject: [PATCH 139/284] fix(yjs): make socket cleanup non-blocking after data mutation Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/indicators/custom/route.ts | 2 +- .../tradinggoose/app/api/mcp/servers/route.ts | 2 +- .../app/api/tools/custom/route.ts | 2 +- .../app/api/workflows/[id]/route.test.ts | 56 ++----------------- .../app/api/workflows/[id]/route.ts | 2 +- apps/tradinggoose/lib/knowledge/service.ts | 2 +- apps/tradinggoose/lib/skills/operations.ts | 2 +- .../yjs/server/apply-workflow-state.test.ts | 6 +- .../lib/yjs/server/apply-workflow-state.ts | 4 +- 9 files changed, 18 insertions(+), 60 deletions(-) diff --git a/apps/tradinggoose/app/api/indicators/custom/route.ts b/apps/tradinggoose/app/api/indicators/custom/route.ts index 5423ea48f..de46dc0c3 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -261,10 +261,10 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Indicator not found' }, { status: 404 }) } - await deleteYjsSessionInSocketServer(indicatorId) await db .delete(pineIndicators) .where(and(eq(pineIndicators.id, indicatorId), eq(pineIndicators.workspaceId, workspaceId))) + await deleteYjsSessionInSocketServer(indicatorId).catch(() => undefined) logger.info(`[${requestId}] Deleted indicator ${indicatorId}`) return NextResponse.json({ success: true }, { status: 200 }) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index c463eb783..5a8b40e8c 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -173,10 +173,10 @@ export const DELETE = withMcpAuth('write')( ) } - await deleteYjsSessionInSocketServer(serverId) await db .delete(mcpServers) .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) + await deleteYjsSessionInSocketServer(serverId).catch(() => undefined) mcpService.clearCache(workspaceId) diff --git a/apps/tradinggoose/app/api/tools/custom/route.ts b/apps/tradinggoose/app/api/tools/custom/route.ts index 113561996..39b98d349 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.ts @@ -218,10 +218,10 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Tool not found' }, { status: 404 }) } - await deleteYjsSessionInSocketServer(toolId) await db .delete(customTools) .where(and(eq(customTools.id, toolId), eq(customTools.workspaceId, workspaceId))) + await deleteYjsSessionInSocketServer(toolId).catch(() => undefined) logger.info(`[${requestId}] Deleted tool: ${toolId}`) return NextResponse.json({ success: true }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index 3908996cb..deb0a29a2 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -370,7 +370,7 @@ describe('Workflow By ID API Route', () => { }) describe('DELETE /api/workflows/[id]', () => { - it('should terminate the Yjs session before deleting the workflow row', async () => { + it('should delete the workflow row before non-blocking Yjs cleanup', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -380,6 +380,7 @@ describe('Workflow By ID API Route', () => { const events: string[] = [] mockDeleteYjsSession.mockImplementation(async () => { events.push('yjs-delete') + throw new Error('socket offline') }) vi.doMock('@/lib/auth', () => ({ @@ -419,10 +420,10 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(200) const data = await response.json() expect(data.success).toBe(true) - expect(events).toEqual(['yjs-delete', 'db-delete']) + expect(events).toEqual(['db-delete', 'yjs-delete']) }) - it('should return 500 if workflow row deletion fails after session termination', async () => { + it('should return 500 if workflow row deletion fails before session cleanup', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -466,7 +467,7 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(500) const data = await response.json() expect(data.error).toBe('Internal server error') - expect(mockDeleteYjsSession).toHaveBeenCalledWith('workflow-123') + expect(mockDeleteYjsSession).not.toHaveBeenCalled() expect(deleteWhereMock).toHaveBeenCalledOnce() }) @@ -515,53 +516,6 @@ describe('Workflow By ID API Route', () => { expect(data.success).toBe(true) }) - it('should not delete the workflow row when Yjs session termination fails', async () => { - const mockWorkflow = { - id: 'workflow-123', - userId: 'user-123', - name: 'Test Workflow', - workspaceId: null, - } - const deleteWhereMock = vi.fn().mockResolvedValue([{ id: 'workflow-123' }]) - mockDeleteYjsSession.mockRejectedValueOnce(new Error('socket offline')) - - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-123' }, - }), - })) - - mockReadWorkflowById.mockResolvedValueOnce(mockWorkflow) - mockReadWorkflowAccessContext.mockResolvedValueOnce({ - workflow: mockWorkflow, - workspaceOwnerId: null, - workspacePermission: null, - isOwner: true, - isWorkspaceOwner: false, - }) - vi.doMock('@tradinggoose/db', () => ({ - db: { - delete: vi.fn().mockReturnValue({ - where: deleteWhereMock, - }), - }, - workflow: {}, - })) - - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'DELETE', - }) - const params = Promise.resolve({ id: 'workflow-123' }) - - const { DELETE } = await import('@/app/api/workflows/[id]/route') - const response = await DELETE(req, { params }) - - expect(response.status).toBe(500) - const data = await response.json() - expect(data.error).toBe('Internal server error') - expect(deleteWhereMock).not.toHaveBeenCalled() - }) - it('should deny deletion for non-admin users', async () => { const mockWorkflow = { id: 'workflow-123', diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index 8486db553..8f0936870 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -285,8 +285,8 @@ export async function DELETE( } } - await deleteYjsSessionInSocketServer(workflowId) await db.delete(workflow).where(eq(workflow.id, workflowId)) + await deleteYjsSessionInSocketServer(workflowId).catch(() => undefined) const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully deleted workflow ${workflowId} in ${elapsed}ms`) diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index 327439148..cefe8c8e5 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -430,7 +430,6 @@ export async function deleteKnowledgeBase( ): Promise { const now = new Date() - await deleteYjsSessionInSocketServer(knowledgeBaseId) await db .update(knowledgeBase) .set({ @@ -438,6 +437,7 @@ export async function deleteKnowledgeBase( updatedAt: now, }) .where(eq(knowledgeBase.id, knowledgeBaseId)) + await deleteYjsSessionInSocketServer(knowledgeBaseId).catch(() => undefined) logger.info(`[${requestId}] Soft deleted knowledge base: ${knowledgeBaseId}`) } diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index 0731a565a..cf55289e6 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -65,10 +65,10 @@ export async function deleteSkill(params: { return false } - await deleteYjsSessionInSocketServer(params.skillId) await db .delete(skill) .where(and(eq(skill.id, params.skillId), eq(skill.workspaceId, params.workspaceId))) + await deleteYjsSessionInSocketServer(params.skillId).catch(() => undefined) logger.info(`Deleted skill ${params.skillId}`) return true diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts index 6ce67cc41..3dde63479 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts @@ -95,7 +95,8 @@ describe('applyWorkflowState', () => { mockDbUpdate.mockReturnValue({ set: mockUpdateSet }) }) - it('renames workflow entity metadata without republishing workflow state', async () => { + it('renames workflow entity metadata before non-blocking Yjs name sync', async () => { + mockApplyWorkflowEntityNameInSocketServer.mockRejectedValueOnce(new Error('socket offline')) const { applyWorkflowEntityName } = await import('./apply-workflow-state') await applyWorkflowEntityName('workflow-1', 'Renamed Workflow', { @@ -113,6 +114,9 @@ describe('applyWorkflowState', () => { description: 'Updated description', }) ) + expect(mockDbUpdate.mock.invocationCallOrder[0]).toBeLessThan( + mockApplyWorkflowEntityNameInSocketServer.mock.invocationCallOrder[0] + ) }) it('persists the applied Yjs workflow state after publishing to Yjs', async () => { diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index 54bd25302..beb7df80e 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -152,8 +152,6 @@ export async function applyWorkflowEntityName( entityName: string, fields: Partial = {} ): Promise { - await applyWorkflowEntityNameInSocketServer(workflowId, entityName) - const [updatedWorkflow] = await db .update(workflow) .set({ ...fields, name: entityName, updatedAt: fields.updatedAt ?? new Date() }) @@ -164,5 +162,7 @@ export async function applyWorkflowEntityName( throw new Error('Workflow not found') } + await applyWorkflowEntityNameInSocketServer(workflowId, entityName).catch(() => undefined) + return updatedWorkflow } From a904006be19825c27a5ee0a3da8b472830b003b1 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 19:40:03 -0600 Subject: [PATCH 140/284] fix(mcp): preserve cancelled device login state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/mcp/auth.ts | 32 ++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 7a8a16a83..999dbcd62 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -27,7 +27,10 @@ type ApprovedDeviceLogin = { deliveredAt?: string } -type DeviceLoginState = PendingDeviceLogin | ApprovedDeviceLogin +type DeviceLoginState = + | PendingDeviceLogin + | ApprovedDeviceLogin + | { status: 'cancelled'; verificationKeyHash: string } type DeviceLogin = { id: string state: DeviceLoginState @@ -167,6 +170,9 @@ function parseDeviceLoginState(value: string): DeviceLoginState | null { ) { return parsed as ApprovedDeviceLogin } + if (parsed.status === 'cancelled' && typeof parsed.verificationKeyHash === 'string') { + return parsed as DeviceLoginState + } return null } catch { return null @@ -286,6 +292,9 @@ export async function createMcpDeviceLoginApprovalChallenge({ expiresAt: login.expiresAt.toISOString(), } } + if (login.state.status !== 'pending') { + return { status: 'expired' } + } const approvalToken = randomBytes(32).toString('base64url') const challengedState = { @@ -333,13 +342,16 @@ export async function pollMcpDeviceLogin( return { status: 'expired' } } - if (login.state.status !== 'approved') { + if (login.state.status === 'pending') { return { status: 'pending', intervalSeconds: POLL_INTERVAL_SECONDS, expiresAt: login.expiresAt.toISOString(), } } + if (login.state.status !== 'approved') { + return { status: 'expired' } + } const approvedState = login.state const now = new Date() @@ -408,6 +420,9 @@ export async function approveMcpDeviceLogin({ expiresAt: login.expiresAt.toISOString(), } } + if (login.state.status !== 'pending') { + return { status: 'invalid' } + } if ( login.state.approvalUserId !== userId || @@ -461,12 +476,19 @@ export async function cancelMcpDeviceLogin({ return { status: 'invalid' } } - const [deleted] = await db - .delete(verification) + const [updated] = await db + .update(verification) + .set({ + value: JSON.stringify({ + status: 'cancelled', + verificationKeyHash: login.state.verificationKeyHash, + }), + updatedAt: new Date(), + }) .where(deviceLoginMatches(login)) .returning({ id: verification.id }) - if (!deleted) { + if (!updated) { return { status: 'invalid' } } From c640cc924b0afc9434c29915871f69751345f34d Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 19:40:18 -0600 Subject: [PATCH 141/284] fix(copilot): run accepted review before consuming token row Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/copilot/tools/server/review-acceptance.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts index 20e927d50..ce0a08f29 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts @@ -156,6 +156,12 @@ export async function acceptServerManagedToolReview( ) const identifier = `${REVIEW_TOKEN_PREFIX}${reviewToken}` + const result = await routeExecution(toolName, payload, { + ...executionContext, + accessLevel: 'full', + acceptedReviewBaseStateHash: staged.baseStateHash, + }) + const [deletedRow] = await db .delete(verification) .where(and(eq(verification.identifier, identifier), eq(verification.value, row.value))) @@ -164,9 +170,5 @@ export async function acceptServerManagedToolReview( throw new Error('Server tool review token is invalid or expired') } - return routeExecution(toolName, payload, { - ...executionContext, - accessLevel: 'full', - acceptedReviewBaseStateHash: staged.baseStateHash, - }) + return result } From b890d71b88aa587726d123987bc10876f0c77986 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 20:18:55 -0600 Subject: [PATCH 142/284] fix(copilot): claim server tool review tokens before execution Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../copilot/tools/server/review-acceptance.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts index ce0a08f29..af6788458 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts @@ -156,15 +156,36 @@ export async function acceptServerManagedToolReview( ) const identifier = `${REVIEW_TOKEN_PREFIX}${reviewToken}` + const claimValue = `claimed:${nanoid()}` + const [claimedRow] = await db + .update(verification) + .set({ value: claimValue }) + .where(and(eq(verification.identifier, identifier), eq(verification.value, row.value))) + .returning({ id: verification.id }) + if (!claimedRow) { + throw new Error('Server tool review token is invalid or expired') + } + const result = await routeExecution(toolName, payload, { ...executionContext, accessLevel: 'full', acceptedReviewBaseStateHash: staged.baseStateHash, + }).catch(async (error) => { + await db + .update(verification) + .set({ value: row.value }) + .where(and(eq(verification.identifier, identifier), eq(verification.value, claimValue))) + .catch((restoreError) => { + if (error instanceof Error && error.cause === undefined) { + error.cause = restoreError + } + }) + throw error }) const [deletedRow] = await db .delete(verification) - .where(and(eq(verification.identifier, identifier), eq(verification.value, row.value))) + .where(and(eq(verification.identifier, identifier), eq(verification.value, claimValue))) .returning({ id: verification.id }) if (!deletedRow) { throw new Error('Server tool review token is invalid or expired') From ae3b2388913c765454f8f80f25847c0d5bf3a5a4 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 20:19:13 -0600 Subject: [PATCH 143/284] fix(mcp): derive approval tokens from device login code Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/mcp/auth.ts | 51 +++++++++++-------------------- 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 999dbcd62..59e6b80fa 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -14,8 +14,6 @@ type PendingDeviceLogin = { status: 'pending' createdAt: string verificationKeyHash: string - approvalUserId?: string - approvalTokenHash?: string } type ApprovedDeviceLogin = { @@ -73,6 +71,18 @@ function buildDeviceLoginId(code: string): string { return `${DEVICE_LOGIN_PREFIX}${hashValue(code)}` } +function createDeviceLoginApprovalToken(code: string, userId: string): string { + return signDeviceLoginCode(`mcp-approval.${buildDeviceLoginId(code)}.${userId}`) +} + +function approvalTokenMatches(code: string, userId: string, approvalToken: string): boolean { + const expectedToken = createDeviceLoginApprovalToken(code, userId) + return ( + expectedToken.length === approvalToken.length && + timingSafeEqual(Buffer.from(expectedToken), Buffer.from(approvalToken)) + ) +} + function createDeviceLoginCode({ expiresAt, now, @@ -154,9 +164,7 @@ function parseDeviceLoginState(value: string): DeviceLoginState | null { if ( parsed.status === 'pending' && typeof parsed.createdAt === 'string' && - typeof parsed.verificationKeyHash === 'string' && - (parsed.approvalUserId === undefined || typeof parsed.approvalUserId === 'string') && - (parsed.approvalTokenHash === undefined || typeof parsed.approvalTokenHash === 'string') + typeof parsed.verificationKeyHash === 'string' ) { return parsed as PendingDeviceLogin } @@ -250,18 +258,12 @@ export async function createMcpDeviceLoginApprovalChallenge({ return { status: 'expired' } } - const approvalToken = randomBytes(32).toString('base64url') - const state = { - ...codeState.state, - approvalUserId: userId, - approvalTokenHash: hashValue(approvalToken), - } satisfies PendingDeviceLogin const [inserted] = await db .insert(verification) .values({ id: codeState.id, identifier: codeState.id, - value: JSON.stringify(state), + value: JSON.stringify(codeState.state), expiresAt: codeState.expiresAt, createdAt: new Date(codeState.state.createdAt), updatedAt: new Date(), @@ -273,7 +275,7 @@ export async function createMcpDeviceLoginApprovalChallenge({ return { status: 'pending', expiresAt: codeState.expiresAt.toISOString(), - approvalToken, + approvalToken: createDeviceLoginApprovalToken(code, userId), } } @@ -296,21 +298,10 @@ export async function createMcpDeviceLoginApprovalChallenge({ return { status: 'expired' } } - const approvalToken = randomBytes(32).toString('base64url') - const challengedState = { - ...login.state, - approvalUserId: userId, - approvalTokenHash: hashValue(approvalToken), - } satisfies PendingDeviceLogin - - if (!(await updateDeviceLoginState(login, challengedState))) { - return { status: 'invalid' } - } - return { status: 'pending', expiresAt: login.expiresAt.toISOString(), - approvalToken, + approvalToken: createDeviceLoginApprovalToken(code, userId), } } @@ -424,10 +415,7 @@ export async function approveMcpDeviceLogin({ return { status: 'invalid' } } - if ( - login.state.approvalUserId !== userId || - login.state.approvalTokenHash !== hashValue(approvalToken) - ) { + if (!approvalTokenMatches(code, userId, approvalToken)) { return { status: 'invalid' } } @@ -469,10 +457,7 @@ export async function cancelMcpDeviceLogin({ return { status: 'invalid' } } - if ( - login.state.approvalUserId !== userId || - login.state.approvalTokenHash !== hashValue(approvalToken) - ) { + if (!approvalTokenMatches(code, userId, approvalToken)) { return { status: 'invalid' } } From c0c9a463084a4f38f136736343f8cc160ba54a60 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 20:19:25 -0600 Subject: [PATCH 144/284] fix(yjs): persist deployment status from workflow state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/yjs/server/apply-workflow-state.test.ts | 4 ++++ apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts index 3dde63479..27ec395a7 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts @@ -153,6 +153,8 @@ describe('applyWorkflowState', () => { edges: [], loops: {}, parallels: {}, + isDeployed: true, + deployedAt: '2026-06-19T12:00:00.000Z', }, { apiKey: { id: 'apiKey', value: 'from-yjs' } } ), @@ -222,6 +224,8 @@ describe('applyWorkflowState', () => { expect(mockUpdateSet).toHaveBeenCalledWith( expect.objectContaining({ variables: { apiKey: { id: 'apiKey', value: 'from-yjs' } }, + isDeployed: true, + deployedAt: new Date('2026-06-19T12:00:00.000Z'), }) ) }) diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index beb7df80e..fcdbb44ab 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -113,6 +113,7 @@ export async function applyWorkflowState( try { const appliedState = await readAppliedYjsWorkflowState(workflowId) + const { deployedAt, isDeployed } = appliedState.workflowState const saveResult = await saveWorkflowToNormalizedTables( workflowId, appliedState.workflowState, @@ -123,6 +124,12 @@ export async function applyWorkflowState( lastSynced: syncedAt, updatedAt: syncedAt, variables: appliedState.variables, + ...(isDeployed === undefined + ? {} + : { + isDeployed, + deployedAt: isDeployed ? (deployedAt ? new Date(deployedAt) : syncedAt) : null, + }), }) .where(eq(workflow.id, workflowId)) .returning({ id: workflow.id }) From 12aee05cf845729546d048b984d48040403f0a6a Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 20:50:13 -0600 Subject: [PATCH 145/284] fix(mcp): rate-limit device login starts and scope approvals Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/auth/mcp/start/route.test.ts | 27 ++- .../app/api/auth/mcp/start/route.ts | 15 +- apps/tradinggoose/lib/mcp/auth.ts | 162 +++++++----------- 3 files changed, 95 insertions(+), 109 deletions(-) diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts index 022425eec..0276d4b2f 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ +import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockStartMcpDeviceLogin } = vi.hoisted(() => ({ @@ -12,6 +13,16 @@ vi.mock('@/lib/mcp/auth', () => ({ startMcpDeviceLogin: (...args: unknown[]) => mockStartMcpDeviceLogin(...args), })) +function createRequest() { + return new NextRequest('https://studio.example.test/api/auth/mcp/start', { + method: 'POST', + headers: { + 'user-agent': 'test-client', + 'x-forwarded-for': '203.0.113.10', + }, + }) +} + describe('MCP login start route', () => { beforeEach(() => { vi.clearAllMocks() @@ -31,7 +42,7 @@ describe('MCP login start route', () => { it('starts a browser approval login and returns an absolute approval URL', async () => { const { POST } = await import('./route') - const response = await POST() + const response = await POST(createRequest()) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ @@ -41,6 +52,18 @@ describe('MCP login start route', () => { intervalSeconds: 2, authorizeUrl: 'https://studio.example.test/mcp/authorize?code=login-code', }) - expect(mockStartMcpDeviceLogin).toHaveBeenCalledWith() + expect(mockStartMcpDeviceLogin).toHaveBeenCalledWith( + 'https://studio.example.test:203.0.113.10:test-client' + ) + }) + + it('rate-limits browser approval login starts before issuing a code', async () => { + mockStartMcpDeviceLogin.mockResolvedValueOnce(null) + const { POST } = await import('./route') + + const response = await POST(createRequest()) + + expect(response.status).toBe(429) + expect(mockStartMcpDeviceLogin).toHaveBeenCalledOnce() }) }) diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.ts index 565a12da4..3ff160de0 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.ts @@ -1,12 +1,21 @@ -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' import { startMcpDeviceLogin } from '@/lib/mcp/auth' import { getBaseUrl } from '@/lib/urls/utils' export const dynamic = 'force-dynamic' -export async function POST() { +export async function POST(request: NextRequest) { const baseUrl = getBaseUrl() - const login = await startMcpDeviceLogin() + const requester = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || + request.headers.get('x-real-ip') || + 'unknown' + const login = await startMcpDeviceLogin( + `${baseUrl}:${requester}:${request.headers.get('user-agent') ?? ''}` + ) + if (!login) { + return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 }) + } const authorizeUrl = new URL('/mcp/authorize', baseUrl) authorizeUrl.searchParams.set('code', login.code) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 59e6b80fa..1ef65f30a 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -1,14 +1,16 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' import { db } from '@tradinggoose/db' import { apiKey, verification } from '@tradinggoose/db/schema' -import { and, eq } from 'drizzle-orm' +import { and, eq, gt } from 'drizzle-orm' import { nanoid } from 'nanoid' import { createApiKeyMaterial } from '@/lib/api-key/service' import { env } from '@/lib/env' +import { getBaseUrl } from '@/lib/urls/utils' const DEVICE_LOGIN_TTL_MS = 10 * 60 * 1000 const DEVICE_LOGIN_PREFIX = 'mcp:' const POLL_INTERVAL_SECONDS = 2 +const MAX_PENDING_DEVICE_LOGINS_PER_REQUESTER = 20 type PendingDeviceLogin = { status: 'pending' @@ -67,8 +69,16 @@ function signDeviceLoginCode(unsignedCode: string): string { return createHmac('sha256', env.INTERNAL_API_SECRET).update(unsignedCode).digest('base64url') } +function getDeviceLoginDeploymentScope(): string { + return hashValue(getBaseUrl()) +} + function buildDeviceLoginId(code: string): string { - return `${DEVICE_LOGIN_PREFIX}${hashValue(code)}` + return `${DEVICE_LOGIN_PREFIX}${hashValue(`${getDeviceLoginDeploymentScope()}:${code}`)}` +} + +function buildDeviceLoginRequesterIdentifier(requesterKey: string): string { + return `${DEVICE_LOGIN_PREFIX}${getDeviceLoginDeploymentScope()}:${hashValue(requesterKey)}` } function createDeviceLoginApprovalToken(code: string, userId: string): string { @@ -83,7 +93,7 @@ function approvalTokenMatches(code: string, userId: string, approvalToken: strin ) } -function createDeviceLoginCode({ +function createDeviceLogin({ expiresAt, now, verificationKey, @@ -91,66 +101,24 @@ function createDeviceLoginCode({ expiresAt: Date now: Date verificationKey: string -}): string { +}) { + const verificationKeyHash = hashValue(verificationKey) const unsignedCode = [ randomBytes(32).toString('base64url'), String(now.getTime()), String(expiresAt.getTime()), - hashValue(verificationKey), + getDeviceLoginDeploymentScope(), + verificationKeyHash, ].join('.') - return `${unsignedCode}.${signDeviceLoginCode(unsignedCode)}` -} - -function readDeviceLoginCode(code: string): { - state: PendingDeviceLogin - expiresAt: Date - id: string -} | null { - try { - const [nonce, issuedAtRaw, expiresAtRaw, verificationKeyHash, encodedSignature, extra] = - code.split('.') - if ( - !nonce || - !issuedAtRaw || - !expiresAtRaw || - !verificationKeyHash || - !encodedSignature || - extra - ) { - return null - } - - const unsignedCode = `${nonce}.${issuedAtRaw}.${expiresAtRaw}.${verificationKeyHash}` - const expectedSignature = signDeviceLoginCode(unsignedCode) - const signatureMatches = - expectedSignature.length === encodedSignature.length && - timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(encodedSignature)) - - if (!signatureMatches) { - return null - } - - const issuedAt = Number(issuedAtRaw) - const expiresAt = Number(expiresAtRaw) - if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt)) { - return null - } - - if (expiresAt <= Date.now()) { - return null - } - - return { - expiresAt: new Date(expiresAt), - id: buildDeviceLoginId(code), - state: { - status: 'pending', - createdAt: new Date(issuedAt).toISOString(), - verificationKeyHash, - }, - } - } catch { - return null + const code = `${unsignedCode}.${signDeviceLoginCode(unsignedCode)}` + return { + code, + id: buildDeviceLoginId(code), + state: { + status: 'pending', + createdAt: now.toISOString(), + verificationKeyHash, + } satisfies PendingDeviceLogin, } } @@ -231,13 +199,36 @@ async function updateDeviceLoginState( return Boolean(updated) } -export async function startMcpDeviceLogin(): Promise { +export async function startMcpDeviceLogin( + requesterKey: string +): Promise { const verificationKey = randomBytes(32).toString('base64url') const now = new Date() const expiresAt = new Date(now.getTime() + DEVICE_LOGIN_TTL_MS) + const identifier = buildDeviceLoginRequesterIdentifier(requesterKey) + const activeLogins = await db + .select({ id: verification.id }) + .from(verification) + .where(and(eq(verification.identifier, identifier), gt(verification.expiresAt, now))) + .limit(MAX_PENDING_DEVICE_LOGINS_PER_REQUESTER) + + if (activeLogins.length >= MAX_PENDING_DEVICE_LOGINS_PER_REQUESTER) { + return null + } + + const login = createDeviceLogin({ expiresAt, now, verificationKey }) + + await db.insert(verification).values({ + id: login.id, + identifier, + value: JSON.stringify(login.state), + expiresAt, + createdAt: now, + updatedAt: now, + }) return { - code: createDeviceLoginCode({ expiresAt, now, verificationKey }), + code: login.code, verificationKey, expiresAt: expiresAt.toISOString(), intervalSeconds: POLL_INTERVAL_SECONDS, @@ -251,41 +242,15 @@ export async function createMcpDeviceLoginApprovalChallenge({ code: string userId: string }): Promise { - let login = await readDeviceLogin(code) + const login = await readDeviceLogin(code) if (!login) { - const codeState = readDeviceLoginCode(code) - if (!codeState) { - return { status: 'expired' } - } - - const [inserted] = await db - .insert(verification) - .values({ - id: codeState.id, - identifier: codeState.id, - value: JSON.stringify(codeState.state), - expiresAt: codeState.expiresAt, - createdAt: new Date(codeState.state.createdAt), - updatedAt: new Date(), - }) - .onConflictDoNothing({ target: verification.id }) - .returning({ id: verification.id }) - - if (inserted) { - return { - status: 'pending', - expiresAt: codeState.expiresAt.toISOString(), - approvalToken: createDeviceLoginApprovalToken(code, userId), - } - } - - login = await readDeviceLogin(code) - if (!login) { - return { status: 'expired' } - } + return { status: 'expired' } } if (login.state.status === 'approved') { + if (login.state.userId !== userId) { + return { status: 'invalid' } + } if (login.state.deliveredAt) { return { status: 'expired' } } @@ -311,18 +276,7 @@ export async function pollMcpDeviceLogin( ): Promise { const login = await readDeviceLogin(code) if (!login) { - const codeState = readDeviceLoginCode(code) - if (!codeState) { - return { status: 'expired' } - } - if (codeState.state.verificationKeyHash !== hashValue(verificationKey)) { - return { status: 'invalid' } - } - return { - status: 'pending', - intervalSeconds: POLL_INTERVAL_SECONDS, - expiresAt: codeState.expiresAt.toISOString(), - } + return { status: 'expired' } } if (login.state.verificationKeyHash !== hashValue(verificationKey)) { @@ -403,7 +357,7 @@ export async function approveMcpDeviceLogin({ } if (login.state.status === 'approved') { - if (login.state.deliveredAt) { + if (login.state.userId !== userId || login.state.deliveredAt) { return { status: 'invalid' } } return { From 69d9cf3673dff1949534f48f541d0297d0c653f5 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 21:23:17 -0600 Subject: [PATCH 146/284] feat(api-key): encrypt stored API keys Update API key creation, display, and authentication to work with encrypted storage and the new plain-key format. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/users/me/api-keys/route.ts | 41 ++- .../app/api/workflows/middleware.ts | 2 +- .../app/api/workspaces/[id]/api-keys/route.ts | 22 +- apps/tradinggoose/lib/api-key/service.test.ts | 88 ------- apps/tradinggoose/lib/api-key/service.ts | 240 +++++++++++------- 5 files changed, 198 insertions(+), 195 deletions(-) delete mode 100644 apps/tradinggoose/lib/api-key/service.test.ts diff --git a/apps/tradinggoose/app/api/users/me/api-keys/route.ts b/apps/tradinggoose/app/api/users/me/api-keys/route.ts index 01a5dace6..19943e175 100644 --- a/apps/tradinggoose/app/api/users/me/api-keys/route.ts +++ b/apps/tradinggoose/app/api/users/me/api-keys/route.ts @@ -1,8 +1,9 @@ import { db } from '@tradinggoose/db' import { apiKey } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' +import { nanoid } from 'nanoid' import { type NextRequest, NextResponse } from 'next/server' -import { createPersonalApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/service' +import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/service' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' @@ -31,10 +32,12 @@ export async function GET(request: NextRequest) { .where(and(eq(apiKey.userId, userId), eq(apiKey.type, 'personal'))) .orderBy(apiKey.createdAt) - const maskedKeys = keys.map(({ key, ...apiKey }) => ({ - ...apiKey, - displayKey: getApiKeyDisplayFormat(key), - })) + const maskedKeys = await Promise.all( + keys.map(async ({ key, ...apiKey }) => ({ + ...apiKey, + displayKey: await getApiKeyDisplayFormat(key), + })) + ) return NextResponse.json({ keys: maskedKeys }) } catch (error) { @@ -79,10 +82,34 @@ export async function POST(request: NextRequest) { ) } - const newKey = await createPersonalApiKey({ userId, name }) + const { key: plainKey, encryptedKey } = await createApiKey(true) + if (!encryptedKey) { + throw new Error('Failed to encrypt API key for storage') + } + + const [newKey] = await db + .insert(apiKey) + .values({ + id: nanoid(), + userId, + workspaceId: null, + name, + key: encryptedKey, + type: 'personal', + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning({ + id: apiKey.id, + name: apiKey.name, + createdAt: apiKey.createdAt, + }) return NextResponse.json({ - key: newKey, + key: { + ...newKey, + key: plainKey, + }, }) } catch (error) { logger.error('Failed to create API key', { error }) diff --git a/apps/tradinggoose/app/api/workflows/middleware.ts b/apps/tradinggoose/app/api/workflows/middleware.ts index 587e8c4cc..0ad8b66d0 100644 --- a/apps/tradinggoose/app/api/workflows/middleware.ts +++ b/apps/tradinggoose/app/api/workflows/middleware.ts @@ -67,7 +67,7 @@ export async function validateWorkflowAccess( // If a pinned key exists, only accept that specific key if (workflow.pinnedApiKey?.key) { - const isValidPinnedKey = storedApiKeyMatches(apiKeyHeader, workflow.pinnedApiKey.key) + const isValidPinnedKey = await storedApiKeyMatches(apiKeyHeader, workflow.pinnedApiKey.key) if (!isValidPinnedKey) { return { error: { diff --git a/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts b/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts index 9bc81bd4a..e370d8e07 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts @@ -4,7 +4,7 @@ import { and, eq, inArray } from 'drizzle-orm' import { nanoid } from 'nanoid' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { createApiKeyMaterial, getApiKeyDisplayFormat } from '@/lib/api-key/service' +import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/service' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' @@ -57,10 +57,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .where(and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.type, 'workspace'))) .orderBy(apiKey.createdAt) - const formattedWorkspaceKeys = workspaceKeys.map(({ key, ...apiKey }) => ({ - ...apiKey, - displayKey: getApiKeyDisplayFormat(key), - })) + const formattedWorkspaceKeys = await Promise.all( + workspaceKeys.map(async ({ key, ...apiKey }) => ({ + ...apiKey, + displayKey: await getApiKeyDisplayFormat(key), + })) + ) return NextResponse.json({ keys: formattedWorkspaceKeys, @@ -116,18 +118,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ) } - const keyId = nanoid() - const { key: plainKey, storedKey } = await createApiKeyMaterial() + const { key: plainKey, encryptedKey } = await createApiKey(true) + if (!encryptedKey) { + throw new Error('Failed to encrypt API key for storage') + } const [newKey] = await db .insert(apiKey) .values({ - id: keyId, + id: nanoid(), workspaceId, userId: userId, createdBy: userId, name, - key: storedKey, + key: encryptedKey, type: 'workspace', createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/tradinggoose/lib/api-key/service.test.ts b/apps/tradinggoose/lib/api-key/service.test.ts deleted file mode 100644 index 4e6959fc1..000000000 --- a/apps/tradinggoose/lib/api-key/service.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { fromMock, limitMock, selectMock, whereMock } = vi.hoisted(() => ({ - fromMock: vi.fn(), - limitMock: vi.fn(), - selectMock: vi.fn(), - whereMock: vi.fn(), -})) - -vi.mock('@tradinggoose/db', () => ({ - db: { - select: selectMock, - }, -})) - -import { - authenticateApiKeyFromHeader, - generateApiKey, - getApiKeyDisplayFormat, - getStoredApiKey, -} from '@/lib/api-key/service' - -describe('authenticateApiKeyFromHeader', () => { - beforeEach(() => { - vi.resetAllMocks() - selectMock.mockReturnValue({ from: fromMock }) - fromMock.mockReturnValue({ where: whereMock }) - whereMock.mockReturnValue({ limit: limitMock }) - }) - - it('rejects malformed keys before querying stored keys', async () => { - await expect( - authenticateApiKeyFromHeader('sk-tradinggoose-malformed', { keyTypes: ['personal'] }) - ).resolves.toEqual({ success: false, error: 'Invalid API key' }) - - expect(selectMock).not.toHaveBeenCalled() - }) - - it('authenticates personal API keys by stored-key lookup', async () => { - const token = `sk-tradinggoose-${'a'.repeat(32)}` - limitMock.mockResolvedValue([ - { - id: 'key-1', - userId: 'user-1', - workspaceId: null, - type: 'personal', - expiresAt: null, - }, - ]) - - await expect(authenticateApiKeyFromHeader(token, { keyTypes: ['personal'] })).resolves.toEqual({ - success: true, - userId: 'user-1', - keyId: 'key-1', - keyType: 'personal', - workspaceId: undefined, - }) - - expect(whereMock).toHaveBeenCalledOnce() - expect(limitMock).toHaveBeenCalledWith(1) - }) - - it('rejects unknown well-formed keys after one exact lookup', async () => { - const token = `sk-tradinggoose-${'b'.repeat(32)}` - limitMock.mockResolvedValue([]) - - await expect(authenticateApiKeyFromHeader(token, { keyTypes: ['personal'] })).resolves.toEqual({ - success: false, - error: 'Invalid API key', - }) - - expect(whereMock).toHaveBeenCalledOnce() - expect(limitMock).toHaveBeenCalledWith(1) - }) - - it('stores API keys as non-reversible display plus digest values', () => { - const key = generateApiKey() - const storedKey = getStoredApiKey(key) - - expect(key).toMatch(/^sk-tradinggoose-[A-Za-z0-9_-]{32}$/) - expect(storedKey).toContain(':sha256:') - expect(storedKey).not.toContain(key) - expect(getApiKeyDisplayFormat(storedKey)).toMatch(/^sk-tradinggoose-\.\.\.[A-Za-z0-9_-]{4}$/) - }) -}) diff --git a/apps/tradinggoose/lib/api-key/service.ts b/apps/tradinggoose/lib/api-key/service.ts index f147b1ab1..595aeae96 100644 --- a/apps/tradinggoose/lib/api-key/service.ts +++ b/apps/tradinggoose/lib/api-key/service.ts @@ -1,13 +1,12 @@ -import { createHash } from 'crypto' +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto' import { db } from '@tradinggoose/db' import { apiKey as apiKeyTable } from '@tradinggoose/db/schema' -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq, inArray, type SQL } from 'drizzle-orm' import { nanoid } from 'nanoid' +import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('ApiKeyService') -const API_KEY_PATTERN = /^sk-tradinggoose-[A-Za-z0-9_-]{32}$/ -const API_KEY_STORAGE_SEPARATOR = ':sha256:' export interface ApiKeyAuthOptions { userId?: string @@ -24,71 +23,23 @@ export interface ApiKeyAuthResult { error?: string } -export interface CreatePersonalApiKeyInput { - userId: string - name: string - createdAt?: Date -} - -export interface CreatedPersonalApiKey { - id: string - name: string - createdAt: Date - key: string -} - -export async function createApiKeyMaterial(): Promise<{ +export async function createApiKey(useStorage = true): Promise<{ key: string - storedKey: string + encryptedKey?: string }> { try { - const key = generateApiKey() - return { key, storedKey: getStoredApiKey(key) } - } catch (error) { - logger.error('API key creation error:', { error }) - throw new Error('Failed to create API key') - } -} + const plainKey = env.API_ENCRYPTION_KEY ? generateEncryptedApiKey() : generateApiKey() -export async function createPersonalApiKey({ - userId, - name, - createdAt = new Date(), -}: CreatePersonalApiKeyInput): Promise { - const trimmedName = name.trim() - if (!trimmedName) { - throw new Error('API key name is required') - } - - const keyId = nanoid() - const { key: plainKey, storedKey } = await createApiKeyMaterial() - - const [newKey] = await db - .insert(apiKeyTable) - .values({ - id: keyId, - userId, - workspaceId: null, - name: trimmedName, - key: storedKey, - type: 'personal', - createdAt, - updatedAt: createdAt, - }) - .returning({ - id: apiKeyTable.id, - name: apiKeyTable.name, - createdAt: apiKeyTable.createdAt, - }) + if (useStorage) { + const encryptedKey = await encryptApiKeyForStorage(plainKey) + return { key: plainKey, encryptedKey } + } - if (!newKey) { + return { key: plainKey } + } catch (error) { + logger.error('API key creation error:', { error }) throw new Error('Failed to create API key') } - - return { - ...newKey, - key: plainKey, - } } /** @@ -102,12 +53,9 @@ export async function authenticateApiKeyFromHeader( if (!apiKey) { return { success: false, error: 'API key required' } } - if (!API_KEY_PATTERN.test(apiKey)) { - return { success: false, error: 'Invalid API key' } - } try { - const conditions = [eq(apiKeyTable.key, getStoredApiKey(apiKey))] + const conditions: SQL[] = [] if (options.userId) { conditions.push(eq(apiKeyTable.userId, options.userId)) @@ -123,29 +71,39 @@ export async function authenticateApiKeyFromHeader( conditions.push(inArray(apiKeyTable.type, options.keyTypes)) } - const [storedKey] = await db + const query = db .select({ id: apiKeyTable.id, userId: apiKeyTable.userId, workspaceId: apiKeyTable.workspaceId, type: apiKeyTable.type, + key: apiKeyTable.key, expiresAt: apiKeyTable.expiresAt, }) .from(apiKeyTable) - .where(and(...conditions)) - .limit(1) - if (!storedKey || (storedKey.expiresAt && storedKey.expiresAt < new Date())) { - return { success: false, error: 'Invalid API key' } - } + const keyRecords = conditions.length ? await query.where(and(...conditions)) : await query - return { - success: true, - userId: storedKey.userId, - keyId: storedKey.id, - keyType: storedKey.type as 'personal' | 'workspace', - workspaceId: storedKey.workspaceId || undefined, + for (const storedKey of keyRecords) { + if (storedKey.expiresAt && storedKey.expiresAt < new Date()) { + continue + } + + const isValid = await authenticateApiKey(apiKey, storedKey.key) + if (!isValid) { + continue + } + + return { + success: true, + userId: storedKey.userId, + keyId: storedKey.id, + keyType: storedKey.type as 'personal' | 'workspace', + workspaceId: storedKey.workspaceId || undefined, + } } + + return { success: false, error: 'Invalid API key' } } catch (error) { logger.error('API key authentication error:', error) return { success: false, error: 'Authentication failed' } @@ -184,29 +142,131 @@ export async function getApiKeyOwnerUserId( } } +function getApiEncryptionKey(): Buffer | null { + const key = env.API_ENCRYPTION_KEY + if (!key) { + return null + } + if (key.length !== 64) { + throw new Error('API_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)') + } + return Buffer.from(key, 'hex') +} + +export async function encryptApiKey(apiKey: string): Promise<{ encrypted: string; iv: string }> { + const key = getApiEncryptionKey() + if (!key) { + return { encrypted: apiKey, iv: '' } + } + + const iv = randomBytes(16) + const cipher = createCipheriv('aes-256-gcm', key, iv) + let encrypted = cipher.update(apiKey, 'utf8', 'hex') + encrypted += cipher.final('hex') + + const authTag = cipher.getAuthTag() + return { + encrypted: `${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`, + iv: iv.toString('hex'), + } +} + +export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted: string }> { + if (!isEncryptedKey(encryptedValue)) { + return { decrypted: encryptedValue } + } + + const key = getApiEncryptionKey() + if (!key) { + return { decrypted: encryptedValue } + } + + const [ivHex, encrypted, authTagHex] = encryptedValue.split(':') + if (!ivHex || !encrypted || !authTagHex) { + throw new Error('Invalid encrypted API key format. Expected "iv:encrypted:authTag"') + } + + try { + const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivHex, 'hex')) + decipher.setAuthTag(Buffer.from(authTagHex, 'hex')) + + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + return { decrypted } + } catch (error: unknown) { + logger.error('API key decryption error:', { + error: error instanceof Error ? error.message : 'Unknown error', + }) + throw error + } +} + +export function isEncryptedKey(storedKey: string): boolean { + return storedKey.includes(':') && storedKey.split(':').length === 3 +} + +export async function authenticateApiKey(inputKey: string, storedKey: string): Promise { + try { + if (isEncryptedKey(storedKey)) { + const { decrypted } = await decryptApiKey(storedKey) + return inputKey === decrypted + } + + return inputKey === storedKey + } catch (error) { + logger.error('API key authentication error:', { error }) + return false + } +} + +export async function encryptApiKeyForStorage(apiKey: string): Promise { + try { + const { encrypted } = await encryptApiKey(apiKey) + return encrypted + } catch (error) { + logger.error('API key encryption error:', { error }) + throw new Error('Failed to encrypt API key') + } +} + export function generateApiKey(): string { + return `tradinggoose_${nanoid(32)}` +} + +export function generateEncryptedApiKey(): string { return `sk-tradinggoose-${nanoid(32)}` } -export function isApiKeyFormat(apiKey: string): boolean { +export function isEncryptedApiKeyFormat(apiKey: string): boolean { return apiKey.startsWith('sk-tradinggoose-') } -export function formatApiKeyForDisplay(apiKey: string): string { - const last4 = apiKey.slice(-4) - return isApiKeyFormat(apiKey) ? `sk-tradinggoose-...${last4}` : `...${last4}` +export function isPlainApiKeyFormat(apiKey: string): boolean { + return apiKey.startsWith('tradinggoose_') && !apiKey.startsWith('sk-tradinggoose-') } -export function getStoredApiKey(apiKey: string): string { - const digest = createHash('sha256').update(apiKey).digest('hex') - return `${formatApiKeyForDisplay(apiKey)}${API_KEY_STORAGE_SEPARATOR}${digest}` +export function formatApiKeyForDisplay(apiKey: string): string { + const last4 = apiKey.slice(-4) + if (isEncryptedApiKeyFormat(apiKey)) { + return `sk-tradinggoose-...${last4}` + } + if (isPlainApiKeyFormat(apiKey)) { + return `tradinggoose_...${last4}` + } + return `...${last4}` } -export function storedApiKeyMatches(apiKey: string, storedApiKey: string): boolean { - return getStoredApiKey(apiKey) === storedApiKey +export async function storedApiKeyMatches(apiKey: string, storedApiKey: string): Promise { + return authenticateApiKey(apiKey, storedApiKey) } -export function getApiKeyDisplayFormat(storedApiKey: string): string { - const [display, digest] = storedApiKey.split(API_KEY_STORAGE_SEPARATOR) - return display && digest ? display : '****' +export async function getApiKeyDisplayFormat(storedApiKey: string): Promise { + try { + const { decrypted } = await decryptApiKey(storedApiKey) + return formatApiKeyForDisplay(decrypted) + } catch (error) { + logger.error('Failed to format API key for display:', { error }) + return '****' + } } From 6d29b463ea4447751a2e90dd7d8d9d29b4906204 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 21:24:39 -0600 Subject: [PATCH 147/284] feat(mcp): persist device login state Simplify the MCP start endpoint and move device-login persistence into the approval flow while keeping key issuance aligned with the new API-key helper. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/auth/mcp/start/route.test.ts | 27 +--- .../app/api/auth/mcp/start/route.ts | 15 +-- apps/tradinggoose/lib/mcp/auth.ts | 118 +++++++++++++----- 3 files changed, 90 insertions(+), 70 deletions(-) diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts index 0276d4b2f..022425eec 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node */ -import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockStartMcpDeviceLogin } = vi.hoisted(() => ({ @@ -13,16 +12,6 @@ vi.mock('@/lib/mcp/auth', () => ({ startMcpDeviceLogin: (...args: unknown[]) => mockStartMcpDeviceLogin(...args), })) -function createRequest() { - return new NextRequest('https://studio.example.test/api/auth/mcp/start', { - method: 'POST', - headers: { - 'user-agent': 'test-client', - 'x-forwarded-for': '203.0.113.10', - }, - }) -} - describe('MCP login start route', () => { beforeEach(() => { vi.clearAllMocks() @@ -42,7 +31,7 @@ describe('MCP login start route', () => { it('starts a browser approval login and returns an absolute approval URL', async () => { const { POST } = await import('./route') - const response = await POST(createRequest()) + const response = await POST() expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ @@ -52,18 +41,6 @@ describe('MCP login start route', () => { intervalSeconds: 2, authorizeUrl: 'https://studio.example.test/mcp/authorize?code=login-code', }) - expect(mockStartMcpDeviceLogin).toHaveBeenCalledWith( - 'https://studio.example.test:203.0.113.10:test-client' - ) - }) - - it('rate-limits browser approval login starts before issuing a code', async () => { - mockStartMcpDeviceLogin.mockResolvedValueOnce(null) - const { POST } = await import('./route') - - const response = await POST(createRequest()) - - expect(response.status).toBe(429) - expect(mockStartMcpDeviceLogin).toHaveBeenCalledOnce() + expect(mockStartMcpDeviceLogin).toHaveBeenCalledWith() }) }) diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.ts index 3ff160de0..565a12da4 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.ts @@ -1,21 +1,12 @@ -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { startMcpDeviceLogin } from '@/lib/mcp/auth' import { getBaseUrl } from '@/lib/urls/utils' export const dynamic = 'force-dynamic' -export async function POST(request: NextRequest) { +export async function POST() { const baseUrl = getBaseUrl() - const requester = - request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || - request.headers.get('x-real-ip') || - 'unknown' - const login = await startMcpDeviceLogin( - `${baseUrl}:${requester}:${request.headers.get('user-agent') ?? ''}` - ) - if (!login) { - return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 }) - } + const login = await startMcpDeviceLogin() const authorizeUrl = new URL('/mcp/authorize', baseUrl) authorizeUrl.searchParams.set('code', login.code) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 1ef65f30a..3c7adcf84 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -1,16 +1,15 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' import { db } from '@tradinggoose/db' import { apiKey, verification } from '@tradinggoose/db/schema' -import { and, eq, gt } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' -import { createApiKeyMaterial } from '@/lib/api-key/service' +import { createApiKey } from '@/lib/api-key/service' import { env } from '@/lib/env' import { getBaseUrl } from '@/lib/urls/utils' const DEVICE_LOGIN_TTL_MS = 10 * 60 * 1000 const DEVICE_LOGIN_PREFIX = 'mcp:' const POLL_INTERVAL_SECONDS = 2 -const MAX_PENDING_DEVICE_LOGINS_PER_REQUESTER = 20 type PendingDeviceLogin = { status: 'pending' @@ -77,10 +76,6 @@ function buildDeviceLoginId(code: string): string { return `${DEVICE_LOGIN_PREFIX}${hashValue(`${getDeviceLoginDeploymentScope()}:${code}`)}` } -function buildDeviceLoginRequesterIdentifier(requesterKey: string): string { - return `${DEVICE_LOGIN_PREFIX}${getDeviceLoginDeploymentScope()}:${hashValue(requesterKey)}` -} - function createDeviceLoginApprovalToken(code: string, userId: string): string { return signDeviceLoginCode(`mcp-approval.${buildDeviceLoginId(code)}.${userId}`) } @@ -93,6 +88,14 @@ function approvalTokenMatches(code: string, userId: string, approvalToken: strin ) } +function signatureMatches(unsignedCode: string, signature: string): boolean { + const expectedSignature = signDeviceLoginCode(unsignedCode) + return ( + expectedSignature.length === signature.length && + timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(signature)) + ) +} + function createDeviceLogin({ expiresAt, now, @@ -122,6 +125,54 @@ function createDeviceLogin({ } } +function parseDeviceLoginCode(code: string): DeviceLogin | null { + const parts = code.split('.') + if (parts.length !== 6) { + return null + } + + const signature = parts.at(-1) + if (!signature) { + return null + } + + const unsignedCode = parts.slice(0, -1).join('.') + if (!signatureMatches(unsignedCode, signature)) { + return null + } + + const [, createdAtValue, expiresAtValue, deploymentScope, verificationKeyHash] = parts + if ( + deploymentScope !== getDeviceLoginDeploymentScope() || + !verificationKeyHash || + !createdAtValue || + !expiresAtValue + ) { + return null + } + + const createdAtTime = Number(createdAtValue) + const expiresAtTime = Number(expiresAtValue) + if (!Number.isFinite(createdAtTime) || !Number.isFinite(expiresAtTime)) { + return null + } + + const expiresAt = new Date(expiresAtTime) + if (expiresAt <= new Date()) { + return null + } + + return { + id: buildDeviceLoginId(code), + state: { + status: 'pending', + createdAt: new Date(createdAtTime).toISOString(), + verificationKeyHash, + }, + expiresAt, + } +} + function deviceLoginMatches(login: DeviceLogin, state = login.state) { return and(eq(verification.id, login.id), eq(verification.value, JSON.stringify(state))) } @@ -167,7 +218,7 @@ async function readDeviceLogin(code: string) { .limit(1) if (!row) { - return null + return parseDeviceLoginCode(code) } const state = parseDeviceLoginState(row.value) @@ -183,6 +234,24 @@ async function readDeviceLogin(code: string) { } } +async function persistPendingDeviceLogin(login: DeviceLogin) { + if (login.state.status !== 'pending') { + return + } + + await db + .insert(verification) + .values({ + id: login.id, + identifier: login.id, + value: JSON.stringify(login.state), + expiresAt: login.expiresAt, + createdAt: new Date(login.state.createdAt), + updatedAt: new Date(), + }) + .onConflictDoNothing({ target: verification.id }) +} + async function updateDeviceLoginState( login: DeviceLogin, nextState: DeviceLoginState @@ -199,34 +268,12 @@ async function updateDeviceLoginState( return Boolean(updated) } -export async function startMcpDeviceLogin( - requesterKey: string -): Promise { +export async function startMcpDeviceLogin(): Promise { const verificationKey = randomBytes(32).toString('base64url') const now = new Date() const expiresAt = new Date(now.getTime() + DEVICE_LOGIN_TTL_MS) - const identifier = buildDeviceLoginRequesterIdentifier(requesterKey) - const activeLogins = await db - .select({ id: verification.id }) - .from(verification) - .where(and(eq(verification.identifier, identifier), gt(verification.expiresAt, now))) - .limit(MAX_PENDING_DEVICE_LOGINS_PER_REQUESTER) - - if (activeLogins.length >= MAX_PENDING_DEVICE_LOGINS_PER_REQUESTER) { - return null - } - const login = createDeviceLogin({ expiresAt, now, verificationKey }) - await db.insert(verification).values({ - id: login.id, - identifier, - value: JSON.stringify(login.state), - expiresAt, - createdAt: now, - updatedAt: now, - }) - return { code: login.code, verificationKey, @@ -263,6 +310,8 @@ export async function createMcpDeviceLoginApprovalChallenge({ return { status: 'expired' } } + await persistPendingDeviceLogin(login) + return { status: 'pending', expiresAt: login.expiresAt.toISOString(), @@ -300,7 +349,10 @@ export async function pollMcpDeviceLogin( const approvedState = login.state const now = new Date() - const { key, storedKey } = await createApiKeyMaterial() + const { key, encryptedKey } = await createApiKey(true) + if (!encryptedKey) { + throw new Error('Failed to encrypt API key for storage') + } const delivered = await db.transaction(async (tx) => { const [updated] = await tx .update(verification) @@ -321,7 +373,7 @@ export async function pollMcpDeviceLogin( userId: approvedState.userId, workspaceId: null, name: `TradingGoose MCP Access ${now.toISOString()}`, - key: storedKey, + key: encryptedKey, type: 'personal', createdAt: now, updatedAt: now, From 8afd1d1d9b362082e9db5cefadd07e189da0acff Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 21:51:49 -0600 Subject: [PATCH 148/284] fix(yjs): remove DB refresh fallback after persistence failures Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/mcp/servers/[id]/route.ts | 9 +- .../lib/yjs/server/apply-entity-state.test.ts | 27 +----- .../lib/yjs/server/apply-entity-state.ts | 23 ++--- .../yjs/server/apply-workflow-state.test.ts | 41 +-------- .../lib/yjs/server/apply-workflow-state.ts | 86 +++++++------------ 5 files changed, 49 insertions(+), 137 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts index d5c99c821..0b872fa1d 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts @@ -8,7 +8,10 @@ import { mcpService } from '@/lib/mcp/service' import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' -import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' +import { + applySavedEntityPersistedState, + SavedEntityPersistenceError, +} from '@/lib/yjs/server/apply-entity-state' import { UpdateMcpServerSchema } from '../schema' const logger = createLogger('McpServerAPI') @@ -104,6 +107,10 @@ export const PATCH = withMcpAuth('write')( return createMcpSuccessResponse({ server: nextServer }) } catch (error) { logger.error(`[${requestId}] Error updating MCP server:`, error) + if (error instanceof SavedEntityPersistenceError) { + return createMcpErrorResponse(error, error.message, error.status) + } + return createMcpErrorResponse( error instanceof Error ? error : new Error('Failed to update MCP server'), 'Failed to update MCP server', diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts index c9834ac24..ac17c7d74 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts @@ -10,7 +10,6 @@ const { mockApplyEntityStateInSocketServer, mockDbUpdate, mockGetYjsSnapshot, - mockReadSavedEntityFieldsFromDb, mockUpdateReturning, mockUpdateSet, mockUpdateWhere, @@ -19,7 +18,6 @@ const { mockApplyEntityStateInSocketServer: vi.fn(), mockDbUpdate: vi.fn(), mockGetYjsSnapshot: vi.fn(), - mockReadSavedEntityFieldsFromDb: vi.fn(), mockUpdateReturning: vi.fn(), mockUpdateSet: vi.fn(), mockUpdateWhere: vi.fn(), @@ -56,15 +54,7 @@ vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ getYjsSnapshot: mockGetYjsSnapshot, })) -vi.mock('@/lib/yjs/server/entity-loaders', () => ({ - readSavedEntityFieldsFromDb: mockReadSavedEntityFieldsFromDb, -})) - -function buildSkillSnapshotBase64(fields: { - name: string - description: string - content: string -}) { +function buildSkillSnapshotBase64(fields: { name: string; description: string; content: string }) { const doc = new Y.Doc() try { const map = doc.getMap('fields') @@ -97,11 +87,6 @@ describe('applySavedEntityPersistedState', () => { touchedAt: Date.now(), } }) - mockReadSavedEntityFieldsFromDb.mockResolvedValue({ - name: 'DB Skill', - description: 'DB description', - content: 'Use the saved database state.', - }) mockUpdateReturning.mockResolvedValue([{ id: 'skill-1' }]) mockUpdateWhere.mockReturnValue({ returning: mockUpdateReturning }) mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }) @@ -145,7 +130,7 @@ describe('applySavedEntityPersistedState', () => { expect(events).toEqual(['yjs', 'snapshot', 'db']) }) - it('refreshes the saved-entity Yjs session from DB when materialization fails', async () => { + it('leaves saved-entity Yjs unchanged when materialization fails', async () => { const { persistSavedEntityYjsState } = await import('./apply-entity-state') mockUpdateReturning.mockResolvedValueOnce([]) @@ -155,11 +140,7 @@ describe('applySavedEntityPersistedState', () => { status: 404, }) - expect(mockApplyEntityStateInSocketServer).toHaveBeenCalledWith('skill-1', 'skill', { - name: 'DB Skill', - description: 'DB description', - content: 'Use the saved database state.', - }) - expect(events).toEqual(['snapshot', 'db', 'yjs']) + expect(mockApplyEntityStateInSocketServer).not.toHaveBeenCalled() + expect(events).toEqual(['snapshot', 'db']) }) }) diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index 0525042a3..fd19bc05d 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -16,7 +16,6 @@ import { import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' import { getEntityFields } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' -import { readSavedEntityFieldsFromDb } from '@/lib/yjs/server/entity-loaders' import { applyEntityStateInSocketServer, getYjsSnapshot } from '@/lib/yjs/server/snapshot-bridge' export class SavedEntityPersistenceError extends Error { @@ -187,21 +186,9 @@ export async function persistSavedEntityYjsState( entityId: string, workspaceId: string ): Promise { - try { - const yjsFields = normalizeSavedEntityFields( - entityKind, - await readAppliedYjsEntityFields(entityKind, entityId, workspaceId) - ) - await persistSavedEntityState(entityKind, entityId, yjsFields) - } catch (error) { - await applyEntityStateInSocketServer( - entityId, - entityKind, - normalizeSavedEntityFields( - entityKind, - await readSavedEntityFieldsFromDb(entityKind, entityId, workspaceId) - ) - ) - throw error - } + const yjsFields = normalizeSavedEntityFields( + entityKind, + await readAppliedYjsEntityFields(entityKind, entityId, workspaceId) + ) + await persistSavedEntityState(entityKind, entityId, yjsFields) } diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts index 27ec395a7..1636e4cf9 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts @@ -13,7 +13,6 @@ const { mockEnsureUniqueBlockIds, mockEnsureUniqueEdgeIds, mockGetYjsSnapshot, - mockLoadWorkflowStateFromSavedTables, mockSaveWorkflowToNormalizedTables, mockUpdateReturning, mockUpdateSet, @@ -26,7 +25,6 @@ const { mockEnsureUniqueBlockIds: vi.fn(), mockEnsureUniqueEdgeIds: vi.fn(), mockGetYjsSnapshot: vi.fn(), - mockLoadWorkflowStateFromSavedTables: vi.fn(), mockSaveWorkflowToNormalizedTables: vi.fn(), mockUpdateReturning: vi.fn(), mockUpdateSet: vi.fn(), @@ -50,7 +48,6 @@ vi.mock('drizzle-orm', () => ({ vi.mock('@/lib/workflows/db-helpers', () => ({ ensureUniqueBlockIds: mockEnsureUniqueBlockIds, ensureUniqueEdgeIds: mockEnsureUniqueEdgeIds, - loadWorkflowStateFromSavedTables: mockLoadWorkflowStateFromSavedTables, saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, })) @@ -244,21 +241,11 @@ describe('applyWorkflowState', () => { expect(mockDbUpdate).not.toHaveBeenCalled() }) - it('refreshes workflow Yjs from DB when persistence fails after Yjs apply', async () => { + it('leaves workflow Yjs unchanged when materialization fails after Yjs apply', async () => { mockSaveWorkflowToNormalizedTables.mockResolvedValueOnce({ success: false, error: 'db failed', }) - mockLoadWorkflowStateFromSavedTables.mockResolvedValueOnce({ - name: 'Canonical Workflow', - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - variables: { apiKey: { id: 'apiKey', value: 'from-db' } }, - lastSaved: Date.parse('2026-06-23T00:00:00.000Z'), - isDeployed: false, - }) const { applyWorkflowState } = await import('./apply-workflow-state') @@ -266,32 +253,8 @@ describe('applyWorkflowState', () => { 'db failed' ) - expect(mockApplyWorkflowStateInSocketServer).toHaveBeenCalledTimes(2) - expect(mockApplyWorkflowStateInSocketServer).toHaveBeenNthCalledWith( - 2, - 'workflow-1', - expect.objectContaining({ blocks: {} }), - { apiKey: { id: 'apiKey', value: 'from-db' } }, - 'Canonical Workflow' - ) + expect(mockApplyWorkflowStateInSocketServer).toHaveBeenCalledOnce() expect(mockGetYjsSnapshot).toHaveBeenCalledOnce() expect(mockDbUpdate).not.toHaveBeenCalled() }) - - it('preserves the save error when workflow refresh fails', async () => { - mockSaveWorkflowToNormalizedTables.mockResolvedValueOnce({ - success: false, - error: 'db failed', - }) - mockLoadWorkflowStateFromSavedTables.mockResolvedValueOnce(null) - - const { applyWorkflowState } = await import('./apply-workflow-state') - - await expect(applyWorkflowState('workflow-1', emptyWorkflowState, {})).rejects.toMatchObject({ - message: 'db failed', - cause: expect.objectContaining({ - message: 'Workflow workflow-1 canonical DB state is missing', - }), - }) - }) }) diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index fcdbb44ab..ef414ee67 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -8,7 +8,6 @@ import { import { ensureUniqueBlockIds, ensureUniqueEdgeIds, - loadWorkflowStateFromSavedTables, saveWorkflowToNormalizedTables, } from '@/lib/workflows/db-helpers' import { @@ -66,24 +65,6 @@ async function readAppliedYjsWorkflowState(workflowId: string): Promise<{ } } -async function refreshWorkflowYjsFromSavedTables(workflowId: string): Promise { - const savedState = await loadWorkflowStateFromSavedTables(workflowId) - if (!savedState) { - throw new Error(`Workflow ${workflowId} canonical DB state is missing`) - } - - const { variables, name, lastSaved, ...savedWorkflowState } = savedState - await applyWorkflowStateInSocketServer( - workflowId, - createWorkflowSnapshot({ - ...savedWorkflowState, - lastSaved: new Date(lastSaved).toISOString(), - }), - variables, - name ?? undefined - ) -} - export async function applyWorkflowState( workflowId: string, workflowState: WorkflowSnapshot, @@ -111,46 +92,39 @@ export async function applyWorkflowState( await applyWorkflowStateInSocketServer(workflowId, storedWorkflowState, variables, entityName) - try { - const appliedState = await readAppliedYjsWorkflowState(workflowId) - const { deployedAt, isDeployed } = appliedState.workflowState - const saveResult = await saveWorkflowToNormalizedTables( - workflowId, - appliedState.workflowState, - async (tx) => { - const [updatedWorkflow] = await tx - .update(workflow) - .set({ - lastSynced: syncedAt, - updatedAt: syncedAt, - variables: appliedState.variables, - ...(isDeployed === undefined - ? {} - : { - isDeployed, - deployedAt: isDeployed ? (deployedAt ? new Date(deployedAt) : syncedAt) : null, - }), - }) - .where(eq(workflow.id, workflowId)) - .returning({ id: workflow.id }) + const appliedState = await readAppliedYjsWorkflowState(workflowId) + const { deployedAt: appliedDeployedAt, isDeployed } = appliedState.workflowState + const saveResult = await saveWorkflowToNormalizedTables( + workflowId, + appliedState.workflowState, + async (tx) => { + const [updatedWorkflow] = await tx + .update(workflow) + .set({ + lastSynced: syncedAt, + updatedAt: syncedAt, + variables: appliedState.variables, + ...(isDeployed === undefined + ? {} + : { + isDeployed, + deployedAt: isDeployed + ? appliedDeployedAt + ? new Date(appliedDeployedAt) + : syncedAt + : null, + }), + }) + .where(eq(workflow.id, workflowId)) + .returning({ id: workflow.id }) - if (!updatedWorkflow) { - throw new Error('Workflow not found') - } - } - ) - if (!saveResult.success) { - throw new Error(saveResult.error || 'Failed to materialize workflow state') - } - } catch (error) { - try { - await refreshWorkflowYjsFromSavedTables(workflowId) - } catch (refreshError) { - if (error instanceof Error && error.cause === undefined) { - error.cause = refreshError + if (!updatedWorkflow) { + throw new Error('Workflow not found') } } - throw error + ) + if (!saveResult.success) { + throw new Error(saveResult.error || 'Failed to materialize workflow state') } } From 5156c085afddf7ac2d8a2878b3a4708f3366948f Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 21:59:39 -0600 Subject: [PATCH 149/284] feat(indicator): surface save failures in code panel Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../components/pine-indicator-code-panel.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx b/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx index bc294d1fb..cb58de24f 100644 --- a/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx @@ -177,6 +177,7 @@ export function IndicatorCodePanel({ | { state: 'warning'; message: string; warnings: string[] } | { state: 'error'; message: string } >({ state: 'idle' }) + const [saveError, setSaveError] = useState(null) const [showEnvVars, setShowEnvVars] = useState(false) const [envVarSearchTerm, setEnvVarSearchTerm] = useState('') @@ -216,6 +217,7 @@ export function IndicatorCodePanel({ useEffect(() => { setVerifyStatus({ state: 'idle' }) + setSaveError(null) }, [doc, indicatorId]) const updateCursorState = ( @@ -262,10 +264,13 @@ export function IndicatorCodePanel({ const currentPineCode = codeEditorHandleRef.current?.getEditor()?.getValue() ?? pineCode const disallowedMessage = validateNoDollarGlobals(currentPineCode) if (disallowedMessage) { + setSaveError(null) setVerifyStatus({ state: 'error', message: disallowedMessage }) return } + setSaveError(null) + try { if (currentPineCode !== pineCode) { setPineCode(currentPineCode) @@ -273,6 +278,7 @@ export function IndicatorCodePanel({ await save() } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Failed to save indicator.') console.error('Failed to update indicator', err) } }, [workspaceId, indicatorId, doc, pineCode, save, setPineCode]) @@ -458,6 +464,11 @@ export function IndicatorCodePanel({ )} )} + {saveError ? ( + + {saveError} + + ) : null}
From bcf07b65ba7e22508dd721abb2590771e675cf15 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 21:59:52 -0600 Subject: [PATCH 150/284] feat(workflow): surface auto-layout failures in control bar Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../components/control-bar/control-bar.tsx | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx index 1e7837bf4..e684d7eb0 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useMemo, useState } from 'react' -import { ChevronDown, LayoutDashboard, Play, RefreshCw, X } from 'lucide-react' +import { AlertTriangle, ChevronDown, LayoutDashboard, Play, RefreshCw, X } from 'lucide-react' import { Button, DropdownMenu, @@ -110,6 +110,7 @@ export function ControlBar({ // Local state const [, forceUpdate] = useState({}) const [isAutoLayouting, setIsAutoLayouting] = useState(false) + const [autoLayoutError, setAutoLayoutError] = useState(null) // Deployed state management const [deployedState, setDeployedState] = useState(null) @@ -349,6 +350,7 @@ export function ControlBar({ } setIsAutoLayouting(true) + setAutoLayoutError(null) try { // Use the shared auto layout utility for immediate frontend updates const { applyAutoLayoutAndUpdateStore } = await import( @@ -364,11 +366,11 @@ export function ControlBar({ logger.info('Auto layout completed successfully') } else { logger.error('Auto layout failed:', result.error) - // You could add a toast notification here if available + setAutoLayoutError(result.error ?? 'Auto layout failed') } } catch (error) { logger.error('Auto layout error:', error) - // You could add a toast notification here if available + setAutoLayoutError(error instanceof Error ? error.message : 'Auto layout failed') } finally { setIsAutoLayouting(false) } @@ -562,6 +564,19 @@ export function ControlBar({ return (
+ {autoLayoutError ? ( + + +
+ + Auto layout failed +
+
+ + {autoLayoutError} + +
+ ) : null} {showOptionalControls && } {showOptionalControls && renderAutoLayoutButton()} {renderDeployButton()} From c5f9514dd96a38f2c28def6b9433a45cfe9b13ab Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 23:09:27 -0600 Subject: [PATCH 151/284] feat(api-key): add optional API key encryption Add API_ENCRYPTION_KEY support across app config, local compose, and Helm values, plus API-key generation and auth handling updates. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- README.md | 5 ++- apps/tradinggoose/lib/api-key/service.ts | 39 +++++++++++++++++-- apps/tradinggoose/lib/env.ts | 1 + docker-compose.local.yml | 1 + helm/tradinggoose/README.md | 4 +- helm/tradinggoose/examples/values-aws.yaml | 4 ++ helm/tradinggoose/examples/values-azure.yaml | 4 ++ .../examples/values-development.yaml | 6 ++- .../examples/values-external-db.yaml | 5 +++ helm/tradinggoose/examples/values-gcp.yaml | 4 ++ .../examples/values-production.yaml | 4 ++ .../examples/values-whitelabeled.yaml | 4 ++ helm/tradinggoose/values.yaml | 4 ++ 13 files changed, 78 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 661c6ebf6..1ab1ee07a 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,9 @@ If you use Docker Compose, copy `apps/tradinggoose/.env.example.docker` to `apps/tradinggoose/.env` and set the required secrets before running the compose manifests. The `.env` must include `POSTGRES_*`, `NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_SOCKET_URL`, `BETTER_AUTH_SECRET`, -`ENCRYPTION_KEY`, and `INTERNAL_API_SECRET`. The `ENCRYPTION_KEY` value is -shared by both the app and realtime containers. +`ENCRYPTION_KEY`, `API_ENCRYPTION_KEY`, and `INTERNAL_API_SECRET`. The +`ENCRYPTION_KEY` value is shared by both the app and realtime containers, and +`API_ENCRYPTION_KEY` enables encrypted API-key storage in the app container. `NEXT_PUBLIC_SOCKET_URL` should point at `http://localhost:3002` for local Compose runs; production deployments must override it with a browser-reachable public URL. The prod and Ollama compose files also require `IMAGE_TAG` and diff --git a/apps/tradinggoose/lib/api-key/service.ts b/apps/tradinggoose/lib/api-key/service.ts index 595aeae96..1a911a390 100644 --- a/apps/tradinggoose/lib/api-key/service.ts +++ b/apps/tradinggoose/lib/api-key/service.ts @@ -28,7 +28,8 @@ export async function createApiKey(useStorage = true): Promise<{ encryptedKey?: string }> { try { - const plainKey = env.API_ENCRYPTION_KEY ? generateEncryptedApiKey() : generateApiKey() + const plainKey = + env.API_ENCRYPTION_KEY !== undefined ? generateEncryptedApiKey() : generateApiKey() if (useStorage) { const encryptedKey = await encryptApiKeyForStorage(plainKey) @@ -145,6 +146,9 @@ export async function getApiKeyOwnerUserId( function getApiEncryptionKey(): Buffer | null { const key = env.API_ENCRYPTION_KEY if (!key) { + logger.warn( + 'API_ENCRYPTION_KEY not set - API keys will be stored in plain text. Consider setting this for better security.' + ) return null } if (key.length !== 64) { @@ -208,9 +212,38 @@ export function isEncryptedKey(storedKey: string): boolean { export async function authenticateApiKey(inputKey: string, storedKey: string): Promise { try { + if (isEncryptedApiKeyFormat(inputKey)) { + if (!isEncryptedKey(storedKey)) { + return false + } + try { + const { decrypted } = await decryptApiKey(storedKey) + return inputKey === decrypted + } catch (decryptError) { + logger.error('Failed to decrypt stored API key:', { error: decryptError }) + return false + } + } + + if (isPlainApiKeyFormat(inputKey)) { + if (isEncryptedKey(storedKey)) { + try { + const { decrypted } = await decryptApiKey(storedKey) + return inputKey === decrypted + } catch (decryptError) { + logger.error('Failed to decrypt stored API key:', { error: decryptError }) + } + } + return inputKey === storedKey + } + if (isEncryptedKey(storedKey)) { - const { decrypted } = await decryptApiKey(storedKey) - return inputKey === decrypted + try { + const { decrypted } = await decryptApiKey(storedKey) + return inputKey === decrypted + } catch (decryptError) { + logger.error('Failed to decrypt stored API key:', { error: decryptError }) + } } return inputKey === storedKey diff --git a/apps/tradinggoose/lib/env.ts b/apps/tradinggoose/lib/env.ts index f30a3e644..19d71907b 100644 --- a/apps/tradinggoose/lib/env.ts +++ b/apps/tradinggoose/lib/env.ts @@ -68,6 +68,7 @@ function safeCreateEnv() { ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data + API_ENCRYPTION_KEY: z.string().min(32).optional(), // Dedicated key for encrypting API keys (optional for OSS) INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication // Database & Storage diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 3f958ddf2..81cf11965 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -25,6 +25,7 @@ services: - COPILOT_API_URL=${COPILOT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002} + - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} depends_on: redis: condition: service_healthy diff --git a/helm/tradinggoose/README.md b/helm/tradinggoose/README.md index 81b4306cb..c2e561344 100644 --- a/helm/tradinggoose/README.md +++ b/helm/tradinggoose/README.md @@ -641,6 +641,7 @@ For production deployments, make sure to: **Optional Security (Recommended for Production):** - `CRON_SECRET`: Authenticates scheduled job requests to API endpoints (required only if `cronjobs.enabled=true`) +- `API_ENCRYPTION_KEY`: Encrypts API keys at rest in database (must be exactly 64 hex characters). If not set, API keys are stored in plain text. Generate using: `openssl rand -hex 32` (outputs 64 hex chars representing 32 bytes) ### Example secure values: @@ -651,6 +652,7 @@ app: ENCRYPTION_KEY: "your-secure-encryption-key-here" INTERNAL_API_SECRET: "your-secure-internal-api-secret-here" CRON_SECRET: "your-secure-cron-secret-here" + API_ENCRYPTION_KEY: "your-64-char-hex-string-for-api-key-encryption" # Optional but recommended postgresql: auth: @@ -702,4 +704,4 @@ kubectl logs job/-migrations - Documentation: https://docs.tradinggoose.ai - GitHub Issues: https://github.com/TradingGoose/TradingGoose-Studio/issues -- Discord: https://discord.gg/wavf5JWhuT +- Discord: https://discord.gg/wavf5JWhuT \ No newline at end of file diff --git a/helm/tradinggoose/examples/values-aws.yaml b/helm/tradinggoose/examples/values-aws.yaml index df1d09298..f4472ea87 100644 --- a/helm/tradinggoose/examples/values-aws.yaml +++ b/helm/tradinggoose/examples/values-aws.yaml @@ -36,6 +36,10 @@ app: ENCRYPTION_KEY: "your-secure-production-encryption-key-here" INTERNAL_API_SECRET: "your-secure-production-internal-api-secret-here" CRON_SECRET: "your-secure-production-cron-secret-here" + + # Optional: API Key Encryption (RECOMMENDED for production) + # Generate 64-character hex string using: openssl rand -hex 32 + API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" # Optional but recommended NODE_ENV: "production" NEXT_TELEMETRY_DISABLED: "1" diff --git a/helm/tradinggoose/examples/values-azure.yaml b/helm/tradinggoose/examples/values-azure.yaml index d885ede90..bab801160 100644 --- a/helm/tradinggoose/examples/values-azure.yaml +++ b/helm/tradinggoose/examples/values-azure.yaml @@ -34,6 +34,10 @@ app: ENCRYPTION_KEY: "your-secure-production-encryption-key-here" INTERNAL_API_SECRET: "your-secure-production-internal-api-secret-here" CRON_SECRET: "your-secure-production-cron-secret-here" + + # Optional: API Key Encryption (RECOMMENDED for production) + # Generate 64-character hex string using: openssl rand -hex 32 + API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" # Optional but recommended NODE_ENV: "production" NEXT_TELEMETRY_DISABLED: "1" diff --git a/helm/tradinggoose/examples/values-development.yaml b/helm/tradinggoose/examples/values-development.yaml index a96ac4cce..7572dd7e2 100644 --- a/helm/tradinggoose/examples/values-development.yaml +++ b/helm/tradinggoose/examples/values-development.yaml @@ -32,6 +32,10 @@ app: INTERNAL_API_SECRET: "dev-32-char-internal-secret-not-secure" CRON_SECRET: "dev-32-char-cron-secret-not-for-prod" + # Optional: API Key Encryption (leave empty for dev, encrypts API keys at rest) + # For production, generate 64-char hex using: openssl rand -hex 32 + API_ENCRYPTION_KEY: "" # Optional - if not set, API keys stored in plain text + # Realtime service realtime: enabled: true @@ -111,4 +115,4 @@ podDisruptionBudget: # Network policies (disabled for development) networkPolicy: - enabled: false + enabled: false \ No newline at end of file diff --git a/helm/tradinggoose/examples/values-external-db.yaml b/helm/tradinggoose/examples/values-external-db.yaml index a94e61abf..a4a79d351 100644 --- a/helm/tradinggoose/examples/values-external-db.yaml +++ b/helm/tradinggoose/examples/values-external-db.yaml @@ -30,6 +30,10 @@ app: ENCRYPTION_KEY: "" # Set via --set flag or external secret manager INTERNAL_API_SECRET: "" # Set via --set flag or external secret manager CRON_SECRET: "" # Set via --set flag or external secret manager + + # Optional: API Key Encryption (RECOMMENDED for production) + # Generate 64-character hex string using: openssl rand -hex 32 + API_ENCRYPTION_KEY: "" # Optional but recommended - encrypts API keys at rest NODE_ENV: "production" NEXT_TELEMETRY_DISABLED: "1" @@ -154,4 +158,5 @@ networkPolicy: # --set app.env.ENCRYPTION_KEY="$(openssl rand -hex 32)" \ # --set app.env.INTERNAL_API_SECRET="$(openssl rand -hex 32)" \ # --set app.env.CRON_SECRET="$(openssl rand -hex 32)" \ +# --set app.env.API_ENCRYPTION_KEY="$(openssl rand -hex 32)" \ # --set realtime.env.BETTER_AUTH_SECRET="$(openssl rand -hex 32)" diff --git a/helm/tradinggoose/examples/values-gcp.yaml b/helm/tradinggoose/examples/values-gcp.yaml index 363f8e8d1..2746abea5 100644 --- a/helm/tradinggoose/examples/values-gcp.yaml +++ b/helm/tradinggoose/examples/values-gcp.yaml @@ -36,6 +36,10 @@ app: ENCRYPTION_KEY: "your-secure-production-encryption-key-here" INTERNAL_API_SECRET: "your-secure-production-internal-api-secret-here" CRON_SECRET: "your-secure-production-cron-secret-here" + + # Optional: API Key Encryption (RECOMMENDED for production) + # Generate 64-character hex string using: openssl rand -hex 32 + API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" # Optional but recommended NODE_ENV: "production" NEXT_TELEMETRY_DISABLED: "1" diff --git a/helm/tradinggoose/examples/values-production.yaml b/helm/tradinggoose/examples/values-production.yaml index a187d0e88..21319e423 100644 --- a/helm/tradinggoose/examples/values-production.yaml +++ b/helm/tradinggoose/examples/values-production.yaml @@ -31,6 +31,10 @@ app: ENCRYPTION_KEY: "your-production-encryption-key-here" INTERNAL_API_SECRET: "your-production-internal-api-secret-here" CRON_SECRET: "your-production-cron-secret-here" + + # Optional: API Key Encryption (RECOMMENDED for production) + # Generate 64-character hex string using: openssl rand -hex 32 + API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" # Optional but recommended # Email verification (set to true if you want to require email verification) EMAIL_VERIFICATION_ENABLED: "false" diff --git a/helm/tradinggoose/examples/values-whitelabeled.yaml b/helm/tradinggoose/examples/values-whitelabeled.yaml index 5360125b8..cc07a0dda 100644 --- a/helm/tradinggoose/examples/values-whitelabeled.yaml +++ b/helm/tradinggoose/examples/values-whitelabeled.yaml @@ -24,6 +24,10 @@ app: ENCRYPTION_KEY: "your-production-encryption-key-here" INTERNAL_API_SECRET: "your-production-internal-api-secret-here" CRON_SECRET: "your-production-cron-secret-here" + + # Optional: API Key Encryption (RECOMMENDED for production) + # Generate 64-character hex string using: openssl rand -hex 32 + API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" # Optional but recommended # UI Branding & Whitelabeling Configuration NEXT_PUBLIC_BRAND_NAME: "Acme AI Studio" diff --git a/helm/tradinggoose/values.yaml b/helm/tradinggoose/values.yaml index a5d4b2d80..b5432b470 100644 --- a/helm/tradinggoose/values.yaml +++ b/helm/tradinggoose/values.yaml @@ -67,6 +67,10 @@ app: # Optional: Scheduled Jobs Authentication # Generate using: openssl rand -hex 32 CRON_SECRET: "" # OPTIONAL - required only if cronjobs.enabled=true, authenticates scheduled job requests + + # Optional: API Key Encryption (RECOMMENDED for production) + # Generate 64-character hex string using: openssl rand -hex 32 (outputs 64 hex chars = 32 bytes) + API_ENCRYPTION_KEY: "" # OPTIONAL - encrypts API keys at rest, must be exactly 64 hex characters, if not set keys stored in plain text # Email & Communication EMAIL_VERIFICATION_ENABLED: "false" # Enable email verification for user registration and login (defaults to false) From 24e8d508d2e8ce7f775414f21a26a6021f2ff286 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 23:57:58 -0600 Subject: [PATCH 152/284] feat(auth): rate-limit MCP login starts Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/auth/mcp/start/route.test.ts | 24 ++++- .../app/api/auth/mcp/start/route.ts | 30 +++++-- apps/tradinggoose/lib/mcp/auth.ts | 88 ++++++++++++++----- 3 files changed, 109 insertions(+), 33 deletions(-) diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts index 022425eec..49c6df2d7 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts @@ -4,11 +4,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockStartMcpDeviceLogin } = vi.hoisted(() => ({ +const { MockMcpDeviceLoginRateLimitError, mockStartMcpDeviceLogin } = vi.hoisted(() => ({ + MockMcpDeviceLoginRateLimitError: class extends Error { + constructor(public resetAt: Date) { + super('Too many MCP login starts') + this.name = 'McpDeviceLoginRateLimitError' + } + }, mockStartMcpDeviceLogin: vi.fn(), })) vi.mock('@/lib/mcp/auth', () => ({ + McpDeviceLoginRateLimitError: MockMcpDeviceLoginRateLimitError, startMcpDeviceLogin: (...args: unknown[]) => mockStartMcpDeviceLogin(...args), })) @@ -43,4 +50,19 @@ describe('MCP login start route', () => { }) expect(mockStartMcpDeviceLogin).toHaveBeenCalledWith() }) + + it('returns 429 when MCP login starts are rate limited', async () => { + const resetAt = new Date(Date.now() + 30_000) + mockStartMcpDeviceLogin.mockRejectedValueOnce(new MockMcpDeviceLoginRateLimitError(resetAt)) + const { POST } = await import('./route') + + const response = await POST() + + expect(response.status).toBe(429) + expect(response.headers.get('Retry-After')).toBe('30') + await expect(response.json()).resolves.toEqual({ + error: 'Too many MCP login starts', + retryAfter: resetAt.getTime(), + }) + }) }) diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.ts index 565a12da4..e97fb3a84 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.ts @@ -1,17 +1,31 @@ import { NextResponse } from 'next/server' -import { startMcpDeviceLogin } from '@/lib/mcp/auth' +import { McpDeviceLoginRateLimitError, startMcpDeviceLogin } from '@/lib/mcp/auth' import { getBaseUrl } from '@/lib/urls/utils' export const dynamic = 'force-dynamic' export async function POST() { const baseUrl = getBaseUrl() - const login = await startMcpDeviceLogin() - const authorizeUrl = new URL('/mcp/authorize', baseUrl) - authorizeUrl.searchParams.set('code', login.code) + try { + const login = await startMcpDeviceLogin() + const authorizeUrl = new URL('/mcp/authorize', baseUrl) + authorizeUrl.searchParams.set('code', login.code) - return NextResponse.json({ - ...login, - authorizeUrl: authorizeUrl.toString(), - }) + return NextResponse.json({ + ...login, + authorizeUrl: authorizeUrl.toString(), + }) + } catch (error) { + if (error instanceof McpDeviceLoginRateLimitError) { + const retryAfter = Math.max( + 0, + Math.ceil((error.resetAt.getTime() - Date.now()) / 1000) + ).toString() + return NextResponse.json( + { error: error.message, retryAfter: error.resetAt.getTime() }, + { status: 429, headers: { 'Retry-After': retryAfter } } + ) + } + throw error + } } diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 3c7adcf84..dd9a67c81 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -1,7 +1,7 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' import { db } from '@tradinggoose/db' -import { apiKey, verification } from '@tradinggoose/db/schema' -import { and, eq } from 'drizzle-orm' +import { apiKey, userRateLimits, verification } from '@tradinggoose/db/schema' +import { and, eq, sql } from 'drizzle-orm' import { nanoid } from 'nanoid' import { createApiKey } from '@/lib/api-key/service' import { env } from '@/lib/env' @@ -9,6 +9,8 @@ import { getBaseUrl } from '@/lib/urls/utils' const DEVICE_LOGIN_TTL_MS = 10 * 60 * 1000 const DEVICE_LOGIN_PREFIX = 'mcp:' +const DEVICE_LOGIN_START_RATE_LIMIT = 60 +const DEVICE_LOGIN_START_WINDOW_MS = 60 * 1000 const POLL_INTERVAL_SECONDS = 2 type PendingDeviceLogin = { @@ -60,6 +62,13 @@ export type McpDeviceLoginStartResult = { intervalSeconds: number } +export class McpDeviceLoginRateLimitError extends Error { + constructor(public resetAt: Date) { + super('Too many MCP login starts') + this.name = 'McpDeviceLoginRateLimitError' + } +} + function hashValue(value: string) { return createHash('sha256').update(value).digest('hex') } @@ -207,6 +216,11 @@ function parseDeviceLoginState(value: string): DeviceLoginState | null { } async function readDeviceLogin(code: string) { + const parsedLogin = parseDeviceLoginCode(code) + if (!parsedLogin) { + return null + } + const [row] = await db .select({ id: verification.id, @@ -214,11 +228,11 @@ async function readDeviceLogin(code: string) { expiresAt: verification.expiresAt, }) .from(verification) - .where(eq(verification.id, buildDeviceLoginId(code))) + .where(eq(verification.id, parsedLogin.id)) .limit(1) if (!row) { - return parseDeviceLoginCode(code) + return null } const state = parseDeviceLoginState(row.value) @@ -234,24 +248,6 @@ async function readDeviceLogin(code: string) { } } -async function persistPendingDeviceLogin(login: DeviceLogin) { - if (login.state.status !== 'pending') { - return - } - - await db - .insert(verification) - .values({ - id: login.id, - identifier: login.id, - value: JSON.stringify(login.state), - expiresAt: login.expiresAt, - createdAt: new Date(login.state.createdAt), - updatedAt: new Date(), - }) - .onConflictDoNothing({ target: verification.id }) -} - async function updateDeviceLoginState( login: DeviceLogin, nextState: DeviceLoginState @@ -268,12 +264,58 @@ async function updateDeviceLoginState( return Boolean(updated) } +async function enforceDeviceLoginStartRateLimit(now: Date) { + const windowStart = new Date(now.getTime() - DEVICE_LOGIN_START_WINDOW_MS) + const [record] = await db + .insert(userRateLimits) + .values({ + referenceId: `${DEVICE_LOGIN_PREFIX}start:${getDeviceLoginDeploymentScope()}`, + syncApiRequests: 0, + asyncApiRequests: 0, + apiEndpointRequests: 1, + windowStart: now, + lastRequestAt: now, + isRateLimited: false, + rateLimitResetAt: null, + }) + .onConflictDoUpdate({ + target: userRateLimits.referenceId, + set: { + apiEndpointRequests: sql`CASE WHEN ${userRateLimits.windowStart} < ${windowStart.toISOString()} THEN 1 ELSE ${userRateLimits.apiEndpointRequests} + 1 END`, + windowStart: sql`CASE WHEN ${userRateLimits.windowStart} < ${windowStart.toISOString()} THEN ${now.toISOString()} ELSE ${userRateLimits.windowStart} END`, + lastRequestAt: now, + }, + }) + .returning({ + apiEndpointRequests: userRateLimits.apiEndpointRequests, + windowStart: userRateLimits.windowStart, + }) + + if (!record || record.apiEndpointRequests <= DEVICE_LOGIN_START_RATE_LIMIT) { + return + } + + throw new McpDeviceLoginRateLimitError( + new Date(new Date(record.windowStart).getTime() + DEVICE_LOGIN_START_WINDOW_MS) + ) +} + export async function startMcpDeviceLogin(): Promise { const verificationKey = randomBytes(32).toString('base64url') const now = new Date() const expiresAt = new Date(now.getTime() + DEVICE_LOGIN_TTL_MS) const login = createDeviceLogin({ expiresAt, now, verificationKey }) + await enforceDeviceLoginStartRateLimit(now) + await db.insert(verification).values({ + id: login.id, + identifier: login.id, + value: JSON.stringify(login.state), + expiresAt: login.expiresAt, + createdAt: now, + updatedAt: now, + }) + return { code: login.code, verificationKey, @@ -310,8 +352,6 @@ export async function createMcpDeviceLoginApprovalChallenge({ return { status: 'expired' } } - await persistPendingDeviceLogin(login) - return { status: 'pending', expiresAt: login.expiresAt.toISOString(), From e20d45fc310aad53a543582432cfee630a9fb797 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Wed, 24 Jun 2026 23:58:47 -0600 Subject: [PATCH 153/284] feat(yjs): bootstrap live sessions from saved snapshots Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/workflows/db-helpers.test.ts | 218 +++++------------- apps/tradinggoose/lib/workflows/db-helpers.ts | 64 ++--- .../yjs/server/apply-workflow-state.test.ts | 2 +- .../lib/yjs/server/bootstrap-review-target.ts | 97 ++------ apps/tradinggoose/socket-server/index.test.ts | 67 +++--- .../tradinggoose/socket-server/routes/http.ts | 90 +++----- .../socket-server/yjs/persistence.ts | 214 ----------------- .../socket-server/yjs/upstream-utils.test.ts | 135 ----------- .../socket-server/yjs/upstream-utils.ts | 129 +---------- .../socket-server/yjs/ws-handler.test.ts | 81 ++++--- .../socket-server/yjs/ws-handler.ts | 31 ++- 11 files changed, 221 insertions(+), 907 deletions(-) delete mode 100644 apps/tradinggoose/socket-server/yjs/persistence.ts delete mode 100644 apps/tradinggoose/socket-server/yjs/upstream-utils.test.ts diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index 91f2f9359..c69913425 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -111,6 +111,7 @@ vi.doMock('@/lib/logs/console/logger', () => ({ const mockReconcilePublishedChatsForDeploymentTx = vi.fn() const mockGetYjsSnapshot = vi.fn() +const mockReadBootstrappedReviewTargetSnapshot = vi.fn() class MockSocketServerBridgeError extends Error { body = '' @@ -127,6 +128,10 @@ vi.doMock('@/lib/yjs/server/snapshot-bridge', () => ({ SocketServerBridgeError: MockSocketServerBridgeError, })) +vi.doMock('@/lib/yjs/server/bootstrap-review-target', () => ({ + readBootstrappedReviewTargetSnapshot: mockReadBootstrappedReviewTargetSnapshot, +})) + vi.doMock('@/lib/chat/published-deployment', () => ({ reconcilePublishedChatsForDeploymentTx: mockReconcilePublishedChatsForDeploymentTx, })) @@ -868,31 +873,28 @@ describe('Database Helpers', () => { value: 'latest', }, } - const updatedAt = new Date('2026-04-06T00:00:00.000Z') - let selectCallCount = 0 - mockDb.select.mockImplementation(() => { - selectCallCount++ - if (selectCallCount === 1) { - return { - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ variables: savedVariables, updatedAt }]), - }), - }), - } - } - const rows = - selectCallCount === 2 - ? mockBlocksFromDb - : selectCallCount === 3 - ? mockEdgesFromDb - : mockSubflowsFromDb - return { - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(rows), - }), - } - }) + const currentState = { + blocks: { + 'block-1': { + id: 'block-1', + type: 'input_trigger', + name: 'Trigger Block', + position: { x: 100, y: 100 }, + subBlocks: {}, + outputs: {}, + enabled: true, + }, + }, + edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2' }], + loops: {}, + parallels: {}, + lastSaved: '2026-04-06T00:00:00.000Z', + isDeployed: false, + } + + mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( + buildWorkflowSnapshotResponseFromState(currentState, savedVariables) + ) const updateCalls: Array<{ table: unknown; data: Record }> = [] const insertCalls: Array<{ table: unknown; data: Record }> = [] const tx = { @@ -925,7 +927,7 @@ describe('Database Helpers', () => { }) expect(result.success).toBe(true) - expect(mockGetYjsSnapshot).toHaveBeenCalled() + expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalled() expect(result.currentState).toMatchObject({ blocks: expect.objectContaining({ 'block-1': expect.objectContaining({ id: 'block-1' }), @@ -1016,7 +1018,7 @@ describe('Database Helpers', () => { }) describe('loadWorkflowState', () => { - it('loads the current workflow state from Yjs before saved database tables', async () => { + it('loads saved workflow state through a bootstrapped Yjs session', async () => { const yjsState = { direction: 'LR' as const, blocks: {}, @@ -1029,156 +1031,50 @@ describe('Database Helpers', () => { 'var-yjs': { id: 'var-yjs', value: 'latest' }, } - mockGetYjsSnapshot.mockResolvedValue( + mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( buildWorkflowSnapshotResponseFromState(yjsState, yjsVariables) ) - mockDb.select.mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - isDeployed: false, - deployedAt: null, - lastSynced: new Date('2026-04-06T00:05:00.000Z'), - }, - ]), - }), - }), - }) const result = await dbHelpers.loadWorkflowState(mockWorkflowId) + expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalledWith({ + workspaceId: null, + entityKind: 'workflow', + entityId: mockWorkflowId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: mockWorkflowId, + }) expect(result).toMatchObject({ direction: 'LR', variables: yjsVariables, source: 'yjs', }) + expect(mockDb.select).not.toHaveBeenCalled() }) - it('ignores stale Yjs workflow state when saved normalized state is newer', async () => { - const variables = { 'var-db': { id: 'var-db', name: 'risk', value: 'saved' } } - const updatedAt = new Date('2026-04-06T00:20:00.000Z') - const yjsState = { - direction: 'LR' as const, - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - lastSaved: '2026-04-06T00:05:00.000Z', - } - let normalizedQueryCount = 0 - - mockGetYjsSnapshot.mockResolvedValue( - buildWorkflowSnapshotResponseFromState(yjsState, { - 'var-yjs': { id: 'var-yjs', value: 'stale' }, - }) - ) - mockDb.select.mockImplementation((selection?: Record) => { - const selectedFields = Object.keys(selection ?? {}) - if (selectedFields.includes('lastSynced') && !selectedFields.includes('variables')) { - return { - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - isDeployed: false, - deployedAt: null, - lastSynced: new Date('2026-04-06T00:10:00.000Z'), - }, - ]), - }), - }), - } - } - if (selectedFields.includes('variables')) { - return { - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([ - { - name: 'Saved Workflow', - variables, - updatedAt, - isDeployed: false, - deployedAt: null, - }, - ]), - }), - }), - } - } - - normalizedQueryCount += 1 - const rows = - normalizedQueryCount === 1 - ? mockBlocksFromDb - : normalizedQueryCount === 2 - ? mockEdgesFromDb - : mockSubflowsFromDb - return { - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(rows), - }), - } - }) - - const result = await dbHelpers.loadWorkflowState(mockWorkflowId) - - expect(result).toMatchObject({ - blocks: expect.objectContaining({ - 'block-1': expect.objectContaining({ id: 'block-1' }), - }), - variables, - source: 'db', - }) - }) - - it('loads saved normalized workflow state when no Yjs state exists', async () => { - const variables = { 'var-db': { id: 'var-db', name: 'risk', value: 'saved' } } - const updatedAt = new Date('2026-04-06T00:05:00.000Z') - const deployedAt = new Date('2026-04-06T00:10:00.000Z') - let callCount = 0 - mockDb.select.mockImplementation(() => { - callCount++ - if (callCount === 1) { - return { - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi - .fn() - .mockResolvedValue([{ variables, updatedAt, isDeployed: true, deployedAt }]), - }), - }), - } - } - const rows = - callCount === 2 - ? mockBlocksFromDb - : callCount === 3 - ? mockEdgesFromDb - : mockSubflowsFromDb - return { - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(rows), - }), - } + it('returns null when the bootstrapped Yjs session has no snapshot', async () => { + mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue({ + snapshotBase64: '', + descriptor: { + workspaceId: null, + entityKind: 'workflow', + entityId: mockWorkflowId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: mockWorkflowId, + }, + runtime: { + docState: 'expired', + replaySafe: false, + reseededFromCanonical: false, + }, }) const result = await dbHelpers.loadWorkflowState(mockWorkflowId) - expect(result).toMatchObject({ - blocks: expect.objectContaining({ - 'block-1': expect.objectContaining({ id: 'block-1' }), - }), - edges: expect.arrayContaining([expect.objectContaining({ id: 'edge-1' })]), - variables, - direction: 'LR', - lastSaved: updatedAt.getTime(), - isDeployed: true, - deployedAt: deployedAt.toISOString(), - source: 'db', - }) - expect(mockGetYjsSnapshot).toHaveBeenCalled() + expect(result).toBeNull() + expect(mockDb.select).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index b0f2da1c6..6cb31edc2 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -126,9 +126,13 @@ export async function loadWorkflowStateFromYjs( return null } + return decodeWorkflowSnapshot(snapshot.snapshotBase64) +} + +function decodeWorkflowSnapshot(snapshotBase64: string): PersistedWorkflowState | null { const doc = new Y.Doc() try { - Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) + Y.applyUpdate(doc, Buffer.from(snapshotBase64, 'base64')) return extractPersistedStateFromDoc(doc) } finally { doc.destroy() @@ -142,51 +146,23 @@ export type WorkflowStateWithSource = PersistedWorkflowState & { export async function loadWorkflowState( workflowId: string ): Promise { - try { - const liveState = await loadWorkflowStateFromYjs(workflowId) - if (liveState) { - const [workflowStateRow] = await db - .select({ - isDeployed: workflow.isDeployed, - deployedAt: workflow.deployedAt, - lastSynced: workflow.lastSynced, - }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - if (!workflowStateRow) { - return null - } - - const liveSavedAt = resolveStoredDateValue(liveState.lastSaved) - const savedLastSynced = resolveStoredDateValue(workflowStateRow.lastSynced) - if (!savedLastSynced || (liveSavedAt && liveSavedAt >= savedLastSynced)) { - return { - ...liveState, - isDeployed: workflowStateRow.isDeployed ?? false, - deployedAt: toISOStringOrUndefined(workflowStateRow.deployedAt), - source: 'yjs', - } - } - - logger.warn( - `Ignoring stale live workflow state ${workflowId}; using saved normalized state`, - { - workflowId, - liveLastSaved: liveSavedAt?.toISOString(), - savedLastSynced: savedLastSynced.toISOString(), - } - ) - } - } catch (error) { - logger.warn( - `Failed to load live workflow state ${workflowId}; using saved normalized workflow state`, - { error } - ) + const { readBootstrappedReviewTargetSnapshot } = await import( + '@/lib/yjs/server/bootstrap-review-target' + ) + const snapshot = await readBootstrappedReviewTargetSnapshot({ + workspaceId: null, + entityKind: 'workflow', + entityId: workflowId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: workflowId, + }) + if (!snapshot.snapshotBase64) { + return null } - const savedState = await loadWorkflowStateFromSavedTables(workflowId) - return savedState ? { ...savedState, source: 'db' } : null + const state = decodeWorkflowSnapshot(snapshot.snapshotBase64) + return state ? { ...state, source: 'yjs' } : null } export async function loadWorkflowStateFromSavedTables( diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts index 1636e4cf9..5ab16b2ee 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts @@ -227,7 +227,7 @@ describe('applyWorkflowState', () => { ) }) - it('does not commit workflow DB changes when Yjs persistence fails', async () => { + it('does not commit workflow DB changes when the Yjs socket apply fails', async () => { mockApplyWorkflowStateInSocketServer.mockRejectedValueOnce(new TypeError('fetch failed')) const { applyWorkflowState } = await import('./apply-workflow-state') diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index d10361aa5..37cc3fd12 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -16,10 +16,7 @@ import { readSavedEntityFieldsFromDb, resolveEntityWorkspaceId, } from '@/lib/yjs/server/entity-loaders' -import { - getYjsSnapshot, - SocketServerBridgeError, -} from '@/lib/yjs/server/snapshot-bridge' +import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' import { createWorkflowSnapshot, @@ -27,10 +24,6 @@ import { setVariables, setWorkflowState, } from '@/lib/yjs/workflow-session' -import { - getState as getPersistedYjsState, - storeState, -} from '@/socket-server/yjs/persistence' export class ReviewTargetBootstrapError extends Error { status: number @@ -66,28 +59,23 @@ export async function readBootstrappedReviewTargetSnapshot(descriptor: ReviewTar } } - const resolved = await bootstrapReviewTarget(descriptor) - if (!resolved.runtime) { - throw new ReviewTargetBootstrapError(500, 'Bootstrap runtime missing') - } - - if (resolved.runtime.docState === 'expired') { + if (!descriptor.entityId) { return { snapshotBase64: '', - descriptor: resolved.descriptor, - runtime: resolved.runtime, + descriptor, + runtime: { + docState: 'expired' as const, + replaySafe: false, + reseededFromCanonical: false, + }, } } - const state = await getPersistedYjsState(resolved.descriptor.yjsSessionId) - if (!state) { - throw new ReviewTargetBootstrapError(500, 'Snapshot not available after bootstrap') - } - + const bootstrapped = await createSavedReviewTargetBootstrapUpdate(descriptor) return { - snapshotBase64: Buffer.from(state).toString('base64'), - descriptor: resolved.descriptor, - runtime: resolved.runtime, + snapshotBase64: Buffer.from(bootstrapped.state).toString('base64'), + descriptor: bootstrapped.descriptor, + runtime: bootstrapped.runtime, } } @@ -117,37 +105,9 @@ export async function readBootstrappedSavedEntityFields( } } -async function getExistingYjsState(sessionId: string): Promise { - const [{ getExistingDocument }, { getState }] = await Promise.all([ - import('@/socket-server/yjs/upstream-utils'), - import('@/socket-server/yjs/persistence'), - ]) - - const liveDoc = await getExistingDocument(sessionId) - if (liveDoc) { - return Y.encodeStateAsUpdate(liveDoc) - } - - return getState(sessionId) -} - -async function resolveExistingReviewTarget( +export async function createSavedReviewTargetBootstrapUpdate( descriptor: ReviewTargetDescriptor -): Promise { - const existingState = await getExistingYjsState(descriptor.yjsSessionId) - if (!existingState) { - return null - } - - return { - descriptor, - runtime: getRuntimeStateFromUpdate(existingState), - } -} - -async function bootstrapSavedEntityFromDb( - descriptor: ReviewTargetDescriptor -): Promise { +): Promise { if (!descriptor.entityId) { throw new ReviewTargetBootstrapError(404, 'Saved entity id is required') } @@ -198,40 +158,13 @@ async function bootstrapSavedEntityFromDb( metadata.set('entityName', workflowName) } const state = Y.encodeStateAsUpdate(doc) - await storeState(descriptor.yjsSessionId, state) return { descriptor, runtime: getRuntimeStateFromUpdate(state), + state, } } finally { doc.destroy() } } - -/** - * Ensures a review target has an active Yjs document. If an active blob already - * exists it is reused. Saved entities start a Yjs editing session from the - * saved database state; unsaved drafts return the explicit expired state. - */ -export async function bootstrapReviewTarget( - descriptor: ReviewTargetDescriptor -): Promise { - const existing = await resolveExistingReviewTarget(descriptor) - if (existing) { - return existing - } - - if (descriptor.entityId) { - return bootstrapSavedEntityFromDb(descriptor) - } - - return { - descriptor, - runtime: { - docState: 'expired', - replaySafe: false, - reseededFromCanonical: false, - }, - } -} diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 8a0dd6f1c..748c39bc3 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -9,22 +9,13 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite import * as Y from 'yjs' import { createLogger } from '@/lib/logs/console/logger' import { getEntityFields } from '@/lib/yjs/entity-session' -import { - extractPersistedStateFromDoc, - setWorkflowState, -} from '@/lib/yjs/workflow-session' +import { extractPersistedStateFromDoc, setWorkflowState } from '@/lib/yjs/workflow-session' import { createSocketIOServer } from '@/socket-server/config/socket' import { createHttpHandler } from '@/socket-server/routes/http' -import { - cleanupPersistence, - getState, - storeState, -} from '@/socket-server/yjs/persistence' import { cleanupAllDocuments, getDocument, getExistingDocument, - setPersistence, } from '@/socket-server/yjs/upstream-utils' vi.mock(import('@/lib/env'), async (importOriginal) => { @@ -46,24 +37,20 @@ vi.mock('@/lib/redis', () => ({ })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ - bootstrapReviewTarget: vi.fn(async (descriptor) => ({ + createSavedReviewTargetBootstrapUpdate: vi.fn(async (descriptor) => ({ descriptor, runtime: { docState: 'active', replaySafe: false, - reseededFromCanonical: false, + reseededFromCanonical: true, }, + state: new Uint8Array([0, 0]), })), getRuntimeStateFromDoc: vi.fn(() => ({ docState: 'active', replaySafe: false, reseededFromCanonical: false, })), - getRuntimeStateFromUpdate: vi.fn(() => ({ - docState: 'active', - replaySafe: false, - reseededFromCanonical: false, - })), })) vi.mock('@/lib/auth', () => ({ @@ -198,7 +185,6 @@ describe('Socket Server Index Integration', () => { beforeEach(async () => { cleanupAllDocuments() - cleanupPersistence() // Create HTTP server httpServer = createServer() @@ -236,7 +222,6 @@ describe('Socket Server Index Integration', () => { afterEach(async () => { cleanupAllDocuments() - cleanupPersistence() // Properly close servers and wait for them to fully close if (io) { @@ -329,12 +314,12 @@ describe('Socket Server Index Integration', () => { expect(response.statusCode).toBe(200) expect(await getExistingDocument('workflow-1')).toBeTruthy() - const persisted = await getState('workflow-1') + const persisted = await getExistingDocument('workflow-1') expect(persisted).toBeTruthy() const doc = new Y.Doc() try { - Y.applyUpdate(doc, persisted!) + Y.applyUpdate(doc, Y.encodeStateAsUpdate(persisted!)) const state = extractPersistedStateFromDoc(doc) expect(state.blocks['block-1']).toEqual( expect.objectContaining({ @@ -367,12 +352,12 @@ describe('Socket Server Index Integration', () => { ) expect(renameResponse.statusCode).toBe(200) - const renamedPersisted = await getState('workflow-1') + const renamedPersisted = await getExistingDocument('workflow-1') expect(renamedPersisted).toBeTruthy() const renamedDoc = new Y.Doc() try { - Y.applyUpdate(renamedDoc, renamedPersisted!) + Y.applyUpdate(renamedDoc, Y.encodeStateAsUpdate(renamedPersisted!)) const renamedState = extractPersistedStateFromDoc(renamedDoc) expect(renamedState.blocks['block-1']).toEqual( expect.objectContaining({ @@ -410,12 +395,12 @@ describe('Socket Server Index Integration', () => { expect(response.statusCode).toBe(200) expect(await getExistingDocument('skill-1')).toBeTruthy() - const persisted = await getState('skill-1') + const persisted = await getExistingDocument('skill-1') expect(persisted).toBeTruthy() const doc = new Y.Doc() try { - Y.applyUpdate(doc, persisted!) + Y.applyUpdate(doc, Y.encodeStateAsUpdate(persisted!)) expect(getEntityFields(doc, 'skill')).toEqual({ name: 'Risk Skill', description: 'Position sizing rules', @@ -428,11 +413,8 @@ describe('Socket Server Index Integration', () => { }) it('should return the internal Yjs workflow snapshot through the generic session route', async () => { - const { getRuntimeStateFromDoc, getRuntimeStateFromUpdate } = await import( - '@/lib/yjs/server/bootstrap-review-target' - ) + const { getRuntimeStateFromDoc } = await import('@/lib/yjs/server/bootstrap-review-target') - setPersistence('workflow-state-update', { getState, storeState }) getDocument('workflow-state-update') const liveDoc = await getExistingDocument('workflow-state-update') @@ -484,7 +466,7 @@ describe('Socket Server Index Integration', () => { yjsSessionId: 'workflow-state-update', }, runtime: getRuntimeStateFromDoc(liveDoc!), - touchedAt: expect.any(Number), + touchedAt: null, }) const doc = new Y.Doc() @@ -502,10 +484,9 @@ describe('Socket Server Index Integration', () => { } expect(getRuntimeStateFromDoc).toHaveBeenCalled() - expect(getRuntimeStateFromUpdate).not.toHaveBeenCalled() }) - it('should return 404 from the internal Yjs snapshot route when no workflow state exists', async () => { + it('should bootstrap a saved workflow snapshot into a live Yjs document', async () => { const response = await sendHttpRequestWithOptions( PORT, '/internal/yjs/sessions/missing-workflow/snapshot?targetKind=workflow&sessionId=missing-workflow&workflowId=missing-workflow&entityKind=workflow&entityId=missing-workflow', @@ -517,13 +498,25 @@ describe('Socket Server Index Integration', () => { } ) - expect(response.statusCode).toBe(404) - expect(JSON.parse(response.body)).toEqual({ - error: 'Session not found', - sessionId: 'missing-workflow', - }) + expect(response.statusCode).toBe(200) + expect(await getExistingDocument('missing-workflow')).toBeTruthy() }) + it('should bootstrap a saved entity snapshot into a live Yjs document', async () => { + const response = await sendHttpRequestWithOptions( + PORT, + '/internal/yjs/sessions/skill-stale/snapshot?targetKind=entity&sessionId=skill-stale&workspaceId=workspace-1&entityKind=skill&entityId=skill-stale', + { + method: 'GET', + headers: { + 'x-internal-secret': INTERNAL_SECRET, + }, + } + ) + + expect(response.statusCode).toBe(200) + expect(await getExistingDocument('skill-stale')).toBeTruthy() + }) }) describe('Socket.IO Server Configuration', () => { diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index d2de50fea..c39b34ba8 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -8,9 +8,8 @@ import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' import { env } from '@/lib/env' import { seedEntitySession } from '@/lib/yjs/entity-session' import { - bootstrapReviewTarget, + createSavedReviewTargetBootstrapUpdate, getRuntimeStateFromDoc, - getRuntimeStateFromUpdate, } from '@/lib/yjs/server/bootstrap-review-target' import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' import { @@ -19,18 +18,10 @@ import { type WorkflowSnapshot, } from '@/lib/yjs/workflow-session' import { getMonitorRuntimeLockHealth } from '@/socket-server/monitor-runtime-lock' -import { - deleteState, - getLastTouchedAt, - getState, - storeState, -} from '@/socket-server/yjs/persistence' import { discardDocument, - flushDocumentPersistence, getDocument, getExistingDocument, - setPersistence, } from '@/socket-server/yjs/upstream-utils' interface Logger { @@ -239,9 +230,13 @@ function clearSessionReseededFromCanonical(doc: Y.Doc): void { }, YJS_ORIGINS.SYSTEM) } -async function getInitializedSessionDocument(sessionId: string): Promise { - setPersistence(sessionId, { getState, storeState }) - const doc = getDocument(sessionId) as Y.Doc & { whenInitialized?: Promise } +async function getInitializedSessionDocument( + sessionId: string, + bootstrapState?: Uint8Array +): Promise { + const doc = getDocument(sessionId, true, bootstrapState) as Y.Doc & { + whenInitialized?: Promise + } await doc.whenInitialized return doc } @@ -249,20 +244,21 @@ async function getInitializedSessionDocument(sessionId: string): Promise async function getBootstrappedApplyDocument( descriptor: ReturnType ): Promise { - if ( - !(await getExistingDocument(descriptor.yjsSessionId)) && - !(await getState(descriptor.yjsSessionId)) - ) { - if (!descriptor.entityId) { - throw new InvalidInternalYjsRequestError('Saved Yjs session required') - } - const bootstrapped = await bootstrapReviewTarget(descriptor) - if (!bootstrapped.runtime || bootstrapped.runtime.docState !== 'active') { - throw new Error('Yjs review target is not active') - } + const liveDoc = await getExistingDocument(descriptor.yjsSessionId) + if (liveDoc) { + return liveDoc + } + + if (!descriptor.entityId) { + throw new InvalidInternalYjsRequestError('Saved Yjs session required') + } + + const bootstrapped = await createSavedReviewTargetBootstrapUpdate(descriptor) + if (!bootstrapped.runtime || bootstrapped.runtime.docState !== 'active') { + throw new Error('Yjs review target is not active') } - return getInitializedSessionDocument(descriptor.yjsSessionId) + return getInitializedSessionDocument(descriptor.yjsSessionId, bootstrapped.state) } async function handleInternalYjsWorkflowApplyRequest( @@ -287,8 +283,6 @@ async function handleInternalYjsWorkflowApplyRequest( } else { setWorkflowEntityName(doc, body.entityName!) } - await flushDocumentPersistence(workflowId) - sendJson(res, 200, { success: true }) } catch (error) { logger.error('Error applying workflow state', { error, workflowId }) @@ -321,7 +315,6 @@ async function handleInternalYjsEntityApplyRequest( payload: body.fields, }) clearSessionReseededFromCanonical(doc) - await flushDocumentPersistence(entityId) sendJson(res, 200, { success: true }) } catch (error) { @@ -360,7 +353,6 @@ async function handleInternalYjsSessionApplyUpdateRequest( Y.applyUpdate(doc, Buffer.from(updateBase64, 'base64'), YJS_ORIGINS.SAVE) clearSessionReseededFromCanonical(doc) - await flushDocumentPersistence(sessionId) sendJson(res, 200, { success: true }) } catch (error) { @@ -372,22 +364,6 @@ async function handleInternalYjsSessionApplyUpdateRequest( } } -async function getLiveOrPersistedYjsState( - sessionId: string -): Promise<{ liveDoc: Y.Doc | null; state: Uint8Array | null; touchedAt: number | null }> { - const liveDoc = await getExistingDocument(sessionId) - if (liveDoc) { - await flushDocumentPersistence(sessionId) - } - - const state = liveDoc ? Y.encodeStateAsUpdate(liveDoc) : await getState(sessionId) - return { - liveDoc, - state, - touchedAt: state ? await getLastTouchedAt(sessionId) : null, - } -} - async function handleInternalYjsSnapshotRequest( parsedUrl: URL, res: ServerResponse, @@ -402,20 +378,28 @@ async function handleInternalYjsSnapshotRequest( } const descriptor = buildReviewTargetDescriptorFromEnvelope(envelope) - const { liveDoc, state, touchedAt } = await getLiveOrPersistedYjsState(sessionId) + let liveDoc = await getExistingDocument(sessionId) + if (!liveDoc && descriptor.entityId) { + const bootstrapped = await createSavedReviewTargetBootstrapUpdate(descriptor) + if (!bootstrapped.runtime || bootstrapped.runtime.docState !== 'active') { + sendJson(res, 410, { error: 'Session expired', sessionId }) + return + } + liveDoc = await getInitializedSessionDocument(sessionId, bootstrapped.state) + } - if (!state) { + if (!liveDoc) { sendJson(res, 404, { error: 'Session not found', sessionId }) return } - const runtime = liveDoc ? getRuntimeStateFromDoc(liveDoc) : getRuntimeStateFromUpdate(state) + const state = Y.encodeStateAsUpdate(liveDoc) sendJson(res, 200, { snapshotBase64: Buffer.from(state).toString('base64'), descriptor, - runtime, - touchedAt, + runtime: getRuntimeStateFromDoc(liveDoc), + touchedAt: null, }) } catch (error) { logger.error('Error getting Yjs snapshot', { error, path: parsedUrl.pathname }) @@ -429,11 +413,7 @@ async function handleInternalYjsSessionDeleteRequest( sessionId: string ): Promise { try { - if (await getExistingDocument(sessionId)) { - setPersistence(sessionId, { getState, storeState: async () => {} }) - discardDocument(sessionId) - } - await deleteState(sessionId) + discardDocument(sessionId) sendJson(res, 200, { success: true }) } catch (error) { logger.error('Error deleting Yjs session', { error, sessionId }) diff --git a/apps/tradinggoose/socket-server/yjs/persistence.ts b/apps/tradinggoose/socket-server/yjs/persistence.ts deleted file mode 100644 index 4520ae635..000000000 --- a/apps/tradinggoose/socket-server/yjs/persistence.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { getRedisClient, getRedisStorageMode } from '@/lib/redis' - -interface YjsSessionBlob { - state: Buffer - updatedAt: number - expiresAt: number | null -} - -const TTL_MS = 7 * 24 * 60 * 60 * 1000 -const TTL_SECONDS = Math.ceil(TTL_MS / 1000) -const REDIS_KEY_PREFIX = 'yjs:session:' -const MAX_LOCAL_ENTRIES = 100 -const TTL_SWEEP_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes - -const localStore = new Map() - -function stateKey(sessionId: string): string { - return `${REDIS_KEY_PREFIX}${sessionId}:state` -} - -function updatedAtKey(sessionId: string): string { - return `${REDIS_KEY_PREFIX}${sessionId}:updatedAt` -} - -function isExpired(blob: YjsSessionBlob): boolean { - return blob.expiresAt !== null && blob.expiresAt <= Date.now() -} - -function evictOldestLocalEntries(): void { - while (localStore.size > MAX_LOCAL_ENTRIES) { - const oldest = localStore.keys().next().value - if (!oldest) return - localStore.delete(oldest) - } -} - -async function readRedisUpdatedAt(sessionId: string): Promise { - const redis = getRedisClient() - if (!redis) { - return null - } - - const raw = await redis.get(updatedAtKey(sessionId)) - if (!raw) { - return null - } - - const parsed = Number(raw) - return Number.isFinite(parsed) ? parsed : null -} - -async function cleanupExpiredRedisSession(sessionId: string): Promise { - const redis = getRedisClient() - if (!redis) { - return - } - - await redis.del(stateKey(sessionId), updatedAtKey(sessionId)) -} - -function readLocalBlob(sessionId: string): YjsSessionBlob | null { - const blob = localStore.get(sessionId) - if (!blob) { - return null - } - - if (isExpired(blob)) { - localStore.delete(sessionId) - return null - } - - // Move to end for LRU ordering - localStore.delete(sessionId) - localStore.set(sessionId, blob) - - return blob -} - -export async function getState(sessionId: string): Promise { - const mode = getRedisStorageMode() - - if (mode === 'redis') { - const redis = getRedisClient() - if (!redis) { - return null - } - - // Single Redis call — TTL-based expiry (set via pexpire in storeState) - // handles staleness, so a separate updatedAt check is unnecessary and - // avoids a second roundtrip plus a TOCTOU race between the two GETs. - const buf = await redis.getBuffer(stateKey(sessionId)) - if (!buf) { - return null - } - - return new Uint8Array(buf) - } - - const blob = readLocalBlob(sessionId) - return blob ? new Uint8Array(blob.state) : null -} - -export async function storeState(sessionId: string, state: Uint8Array): Promise { - const mode = getRedisStorageMode() - const touchedAt = Date.now() - - if (mode === 'redis') { - const redis = getRedisClient() - if (!redis) { - return - } - - // Zero-copy Buffer wrapper — callers do not retain references to `state` - // after calling storeState, so sharing the underlying ArrayBuffer is safe. - const buf = Buffer.from(state.buffer, state.byteOffset, state.byteLength) - - await redis - .multi() - .set(stateKey(sessionId), buf) - .pexpire(stateKey(sessionId), TTL_MS) - .set(updatedAtKey(sessionId), String(touchedAt)) - .pexpire(updatedAtKey(sessionId), TTL_MS) - .exec() - return - } - - // Delete first so re-insert moves to end for LRU ordering. - // Copy is intentional here — the local Map retains this buffer long-term - // and callers may reuse or mutate the original Uint8Array. - localStore.delete(sessionId) - localStore.set(sessionId, { - state: Buffer.from(state), - updatedAt: touchedAt, - expiresAt: touchedAt + TTL_MS, - }) - - evictOldestLocalEntries() -} - -export async function deleteState(sessionId: string): Promise { - if (getRedisStorageMode() === 'redis') { - const redis = getRedisClient() - if (!redis) { - return - } - await redis.del(stateKey(sessionId), updatedAtKey(sessionId)) - return - } - - localStore.delete(sessionId) -} - -export async function getLastTouchedAt(sessionId: string): Promise { - const mode = getRedisStorageMode() - - if (mode === 'redis') { - const updatedAt = await readRedisUpdatedAt(sessionId) - const redis = getRedisClient() - if (!redis) { - return null - } - - const ttl = await redis.pttl(stateKey(sessionId)) - if (ttl === -2 || (ttl !== -1 && (updatedAt == null || Date.now() - updatedAt > TTL_MS))) { - await cleanupExpiredRedisSession(sessionId) - return null - } - - return updatedAt - } - - return readLocalBlob(sessionId)?.updatedAt ?? null -} - -export function getPersistenceTtlMs(): number { - return TTL_MS -} - -export function getPersistenceTtlSeconds(): number { - return TTL_SECONDS -} - -// Periodic TTL sweep for local store to proactively clean expired entries. -// The interval handle is stored so it can be cleaned up in tests, and -// `.unref()` is called so the timer doesn't prevent process exit. -let ttlSweepInterval: ReturnType | null = null - -if (getRedisStorageMode() !== 'redis') { - ttlSweepInterval = setInterval(() => { - const now = Date.now() - for (const [key, blob] of localStore) { - if (blob.expiresAt !== null && blob.expiresAt <= now) { - localStore.delete(key) - } - } - }, TTL_SWEEP_INTERVAL_MS) - - // Allow the process to exit naturally even if this timer is still pending - if (typeof ttlSweepInterval === 'object' && 'unref' in ttlSweepInterval) { - ttlSweepInterval.unref() - } -} - -/** - * Stops the TTL sweep interval and clears the local store. - * Intended for test teardown to prevent open handles. - */ -export function cleanupPersistence(): void { - if (ttlSweepInterval !== null) { - clearInterval(ttlSweepInterval) - ttlSweepInterval = null - } - localStore.clear() -} diff --git a/apps/tradinggoose/socket-server/yjs/upstream-utils.test.ts b/apps/tradinggoose/socket-server/yjs/upstream-utils.test.ts deleted file mode 100644 index ee1c82a96..000000000 --- a/apps/tradinggoose/socket-server/yjs/upstream-utils.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * @vitest-environment node - */ - -import { EventEmitter } from 'node:events' -import type { IncomingMessage } from 'http' -import * as Y from 'yjs' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { extractPersistedStateFromDoc, setWorkflowState } from '@/lib/yjs/workflow-session' -import { cleanupPersistence } from './persistence' -import { cleanupAllDocuments, getDocument, getExistingDocument, setPersistence, setupWSConnection } from './upstream-utils' - -vi.mock('@/lib/redis', () => ({ - getRedisClient: vi.fn(() => null), - getRedisStorageMode: vi.fn(() => 'local'), -})) - -const mockGetState = vi.fn(async () => null) -const mockStoreState = vi.fn(async () => {}) -let lastStoredState: Uint8Array | null = null - -class MockWebSocket extends EventEmitter { - binaryType = 'arraybuffer' - readyState = 1 - closed = false - send = vi.fn((_: Uint8Array, __: Record, callback?: (err?: Error | null) => void) => { - callback?.(null) - }) - ping = vi.fn() - close = vi.fn(() => { - if (this.closed) { - return - } - - this.closed = true - this.readyState = 3 - this.emit('close') - }) -} - -function createRequest(sessionId: string): IncomingMessage { - return { - url: `/yjs/${encodeURIComponent(sessionId)}`, - headers: { host: 'localhost:3000' }, - } as IncomingMessage -} - -function makeWorkflowState(name: string) { - return { - blocks: { - block1: { - id: 'block1', - type: 'agent', - name, - position: { x: 10, y: 20 }, - subBlocks: {}, - outputs: {}, - enabled: true, - locked: false, - horizontalHandles: true, - isWide: false, - advancedMode: false, - triggerMode: false, - height: 0, - data: {}, - }, - }, - edges: [], - loops: {}, - parallels: {}, - lastSaved: '2026-04-06T00:00:00.000Z', - isDeployed: false, - } -} - -beforeEach(() => { - cleanupAllDocuments() - cleanupPersistence() - mockGetState.mockClear() - mockStoreState.mockClear() - lastStoredState = null -}) - -afterEach(() => { - cleanupAllDocuments() - cleanupPersistence() - vi.clearAllMocks() -}) - -describe('socket-server yjs upstream utils', () => { - it('flushes the latest state before disconnect cleanup removes persistence hooks', async () => { - const sessionId = 'workflow-final-flush' - setPersistence(sessionId, { - getState: mockGetState, - storeState: mockStoreState, - }) - - const doc = getDocument(sessionId) - await getExistingDocument(sessionId) - - const ws = new MockWebSocket() - setupWSConnection(ws as any, createRequest(sessionId), { docId: sessionId, gc: true }) - - setWorkflowState(doc, makeWorkflowState('Initial Agent'), 'test') - setWorkflowState(doc, makeWorkflowState('Updated Agent'), 'test') - - ws.close() - - await vi.waitFor(() => { - expect(mockStoreState).toHaveBeenCalled() - }) - - const lastStoreCall = mockStoreState.mock.calls.at(-1) as [string, Uint8Array] | undefined - lastStoredState = lastStoreCall?.[1] ?? null - expect(lastStoredState).toBeInstanceOf(Uint8Array) - - const persistedDoc = new Y.Doc() - try { - Y.applyUpdate(persistedDoc, lastStoredState as Uint8Array) - const persistedState = extractPersistedStateFromDoc(persistedDoc) - expect(persistedState.blocks.block1).toEqual( - expect.objectContaining({ - id: 'block1', - name: 'Updated Agent', - }) - ) - } finally { - persistedDoc.destroy() - } - - await vi.waitFor(async () => { - expect(await getExistingDocument(sessionId)).toBeNull() - }) - }) -}) diff --git a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts index 5db759e25..736924a81 100644 --- a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts +++ b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts @@ -3,8 +3,8 @@ * * Uses the app's single Yjs runtime and exposes only the helpers this repo * needs: `getDocument`, `getExistingDocument`, `peekDocument`, - * `setupWSConnection`, `setPersistence`, `removeDocument`, `discardDocument`, - * and `cleanupAllDocuments`. + * `setupWSConnection`, `removeDocument`, `discardDocument`, and + * `cleanupAllDocuments`. */ import type { IncomingMessage } from 'http' @@ -13,7 +13,6 @@ import * as syncProtocol from '@y/protocols/sync' import * as decoding from 'lib0/decoding' import * as encoding from 'lib0/encoding' import * as map from 'lib0/map' -import * as mutex from 'lib0/mutex' import type { WebSocket } from 'ws' import * as Y from 'yjs' @@ -25,13 +24,7 @@ const wsReadyStateOpen = 1 const PING_TIMEOUT = 30_000 -export interface YjsPersistence { - getState: (docId: string) => Promise - storeState: (docId: string, state: Uint8Array) => Promise -} - const docs = new Map() -const persistenceMap = new Map() class WSSharedDoc extends Y.Doc { name: string @@ -39,11 +32,6 @@ class WSSharedDoc extends Y.Doc { awareness: awarenessProtocol.Awareness whenInitialized: Promise - private persistScheduled = false - private persistInFlight = false - private persistPending = false - private readonly schedulePersistMutex = mutex.createMutex() - constructor(name: string, gc: boolean) { super({ gc }) this.name = name @@ -84,75 +72,9 @@ class WSSharedDoc extends Y.Doc { syncProtocol.writeUpdate(encoder, update) const message = encoding.toUint8Array(encoder) this.conns.forEach((_ids, conn) => send(this, conn, message)) - this.schedulePersist() }) - this.whenInitialized = this.initialize() - } - - private async initialize(): Promise { - const persistence = persistenceMap.get(this.name) - if (persistence) { - const stored = await persistence.getState(this.name) - if (stored) { - Y.applyUpdate(this, stored) - } - } - } - - private schedulePersist(): void { - this.schedulePersistMutex(() => { - if (this.persistScheduled) { - this.persistPending = true - return - } - - this.persistScheduled = true - queueMicrotask(() => { - this.persistScheduled = false - void this.flushPersistence() - }) - }) - } - - /** - * Flush pending changes to the persistence backend. - * - * TODO(EFF-14): Switch to incremental encoding once the persistence layer - * supports appending deltas rather than full-state replacement. - * The approach would be: - * 1. Store `lastSavedStateVector = Y.encodeStateVector(this)` after each - * successful persist. - * 2. On flush, encode only the delta: - * `Y.encodeStateAsUpdate(this, this.lastSavedStateVector)` - * 3. The persistence layer would need to merge the delta into the stored - * state (e.g. apply it to a scratch Y.Doc and re-encode, or store a - * log of incremental updates and compact periodically). - * Currently the persistence API is replace-only (`storeState` overwrites), - * so incremental deltas would lose earlier state on reload. - */ - async flushPersistence(): Promise { - if (this.persistInFlight) { - this.persistPending = true - return - } - - this.persistInFlight = true - - try { - const persistence = persistenceMap.get(this.name) - if (!persistence) { - return - } - - do { - this.persistPending = false - const state = Y.encodeStateAsUpdate(this) - await persistence.storeState(this.name, state) - } while (this.persistPending) - } finally { - this.persistInFlight = false - } + this.whenInitialized = Promise.resolve() } } @@ -162,17 +84,11 @@ function cleanupDocument(doc: WSSharedDoc): void { } docs.delete(doc.name) - persistenceMap.delete(doc.name) doc.destroy() } function finalizeDocumentCleanup(doc: WSSharedDoc): void { - void doc - .flushPersistence() - .catch(() => {}) - .finally(() => { - cleanupDocument(doc) - }) + cleanupDocument(doc) } function send(doc: WSSharedDoc, conn: WebSocket, message: Uint8Array): void { @@ -239,10 +155,12 @@ function handleMessage(conn: WebSocket, doc: WSSharedDoc, message: Uint8Array): } } -export function getDocument(docId: string, gc = true): Y.Doc { +export function getDocument(docId: string, gc = true, bootstrapState?: Uint8Array): Y.Doc { return map.setIfUndefined(docs, docId, () => { const doc = new WSSharedDoc(docId, gc) - docs.set(docId, doc) + if (bootstrapState) { + Y.applyUpdate(doc, bootstrapState) + } return doc }) } @@ -261,35 +179,20 @@ export async function getExistingDocument(docId: string): Promise return doc } -export async function flushDocumentPersistence(docId: string): Promise { - const doc = docs.get(docId) - if (!doc) { - return - } - - await doc.whenInitialized - await doc.flushPersistence() -} - export function setupWSConnection( conn: WebSocket, _req: IncomingMessage, opts: { docId: string gc?: boolean - persistence?: YjsPersistence - context?: unknown + bootstrapState?: Uint8Array } ): void { - const { docId, gc = true, persistence } = opts - - if (persistence && !persistenceMap.has(docId)) { - persistenceMap.set(docId, persistence) - } + const { docId, gc = true, bootstrapState } = opts conn.binaryType = 'arraybuffer' - const doc = getDocument(docId, gc) as WSSharedDoc + const doc = getDocument(docId, gc, bootstrapState) as WSSharedDoc doc.conns.set(conn, new Set()) conn.on('message', (data: ArrayBuffer) => { @@ -347,16 +250,6 @@ export function setupWSConnection( }) } -export function setPersistence( - docId: string, - hooks: { - getState: (docId: string) => Promise - storeState: (docId: string, state: Uint8Array) => Promise - } -): void { - persistenceMap.set(docId, hooks) -} - export function removeDocument(docId: string): void { const doc = docs.get(docId) if (!doc) { diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts index 326aaa61c..64604a783 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts @@ -5,9 +5,8 @@ import { EventEmitter } from 'node:events' import type { IncomingMessage } from 'http' import type { Duplex } from 'stream' -import type { WebSocketServer } from 'ws' -import * as Y from 'yjs' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WebSocketServer } from 'ws' const mockLogger = { debug: vi.fn(), @@ -17,12 +16,10 @@ const mockLogger = { } const mockAuthenticateYjsConnection = vi.fn() +const mockCreateSavedReviewTargetBootstrapUpdate = vi.fn() const mockVerifyReviewTargetAccess = vi.fn() const mockGetExistingDocument = vi.fn() -const mockSetPersistence = vi.fn() const mockSetupWSConnection = vi.fn() -const mockGetState = vi.fn() -const mockStoreState = vi.fn() class MockYjsAuthError extends Error { constructor( @@ -41,6 +38,13 @@ function createRequest(sessionId: string, accessMode: 'read' | 'write' = 'write' } as IncomingMessage } +function createReviewSessionRequest(sessionId: string): IncomingMessage { + return { + url: `/yjs/${encodeURIComponent(sessionId)}?token=test-token&accessMode=write&targetKind=review_session&sessionId=${encodeURIComponent(sessionId)}&reviewSessionId=${encodeURIComponent(sessionId)}&workspaceId=workspace-3&entityKind=skill&draftSessionId=draft-1`, + headers: { host: 'localhost:3000' }, + } as IncomingMessage +} + function createSocket() { return { write: vi.fn(), @@ -68,12 +72,10 @@ beforeEach(() => { vi.resetModules() mockAuthenticateYjsConnection.mockReset() + mockCreateSavedReviewTargetBootstrapUpdate.mockReset() mockVerifyReviewTargetAccess.mockReset() mockGetExistingDocument.mockReset() - mockSetPersistence.mockReset() mockSetupWSConnection.mockReset() - mockGetState.mockReset() - mockStoreState.mockReset() vi.doMock('@/lib/logs/console/logger', () => ({ createLogger: vi.fn(() => mockLogger), @@ -89,32 +91,18 @@ beforeEach(() => { })) vi.doMock('@/lib/yjs/server/bootstrap-review-target', () => ({ + createSavedReviewTargetBootstrapUpdate: mockCreateSavedReviewTargetBootstrapUpdate, getRuntimeStateFromDoc: vi.fn((doc) => ({ docState: doc.getMap('metadata').get('docState') === 'expired' ? 'expired' : 'active', replaySafe: doc.getMap('metadata').get('reseededFromCanonical') !== true, reseededFromCanonical: doc.getMap('metadata').get('reseededFromCanonical') === true, })), - getRuntimeStateFromUpdate: vi.fn((update: Uint8Array) => { - const doc = new Y.Doc() - Y.applyUpdate(doc, update) - return { - docState: doc.getMap('metadata').get('docState') === 'expired' ? 'expired' : 'active', - replaySafe: doc.getMap('metadata').get('reseededFromCanonical') !== true, - reseededFromCanonical: doc.getMap('metadata').get('reseededFromCanonical') === true, - } - }), })) vi.doMock('./upstream-utils', () => ({ getExistingDocument: mockGetExistingDocument, - setPersistence: mockSetPersistence, setupWSConnection: mockSetupWSConnection, })) - - vi.doMock('./persistence', () => ({ - getState: mockGetState, - storeState: mockStoreState, - })) }) afterEach(() => { @@ -211,7 +199,23 @@ describe('handleYjsUpgrade', () => { isOwner: false, }) mockGetExistingDocument.mockResolvedValue(null) - mockGetState.mockResolvedValue(Y.encodeStateAsUpdate(new Y.Doc())) + const bootstrapState = new Uint8Array([0, 0]) + mockCreateSavedReviewTargetBootstrapUpdate.mockResolvedValue({ + descriptor: { + workspaceId: 'workspace-2', + entityKind: 'workflow', + entityId: sessionId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: sessionId, + }, + runtime: { + docState: 'active', + replaySafe: true, + reseededFromCanonical: true, + }, + state: bootstrapState, + }) const { handleYjsUpgrade } = await loadModule() handleYjsUpgrade(wss, request, socket, Buffer.alloc(0)) @@ -219,18 +223,12 @@ describe('handleYjsUpgrade', () => { expect(mockVerifyReviewTargetAccess).toHaveBeenCalledTimes(1) expect(mockVerifyReviewTargetAccess.mock.calls[0]?.[2]).toBe('write') - expect(mockSetPersistence).toHaveBeenCalledWith( - sessionId, - expect.objectContaining({ - getState: expect.any(Function), - storeState: mockStoreState, - }) - ) + expect(mockCreateSavedReviewTargetBootstrapUpdate).toHaveBeenCalled() expect(wss.handleUpgrade).toHaveBeenCalledTimes(1) expect(mockSetupWSConnection).toHaveBeenCalledWith( expect.anything(), request, - expect.objectContaining({ docId: sessionId, gc: true }) + expect.objectContaining({ bootstrapState, docId: sessionId, gc: true }) ) expect(socket.write).not.toHaveBeenCalled() expect(socket.destroy).not.toHaveBeenCalled() @@ -255,9 +253,9 @@ describe('handleYjsUpgrade', () => { expect(socket.destroy).toHaveBeenCalledTimes(1) }) - it('rejects websocket upgrades when the review target has not been bootstrapped yet', async () => { - const sessionId = 'workflow-unbootstrapped' - const request = createRequest(sessionId) + it('rejects websocket upgrades for missing non-entity review sessions', async () => { + const sessionId = 'review-unbootstrapped' + const request = createReviewSessionRequest(sessionId) const socket = createSocket() const wss = createWebSocketServer() @@ -265,14 +263,14 @@ describe('handleYjsUpgrade', () => { userId: 'user-3', userName: 'User Three', envelope: { - targetKind: 'workflow', + targetKind: 'review_session', sessionId, - workflowId: sessionId, - reviewSessionId: null, + workflowId: null, + reviewSessionId: sessionId, workspaceId: 'workspace-3', - entityKind: 'workflow', - entityId: sessionId, - draftSessionId: null, + entityKind: 'skill', + entityId: null, + draftSessionId: 'draft-1', }, }) @@ -283,7 +281,6 @@ describe('handleYjsUpgrade', () => { isOwner: false, }) mockGetExistingDocument.mockResolvedValue(null) - mockGetState.mockResolvedValue(null) const { handleYjsUpgrade } = await loadModule() handleYjsUpgrade(wss, request, socket, Buffer.alloc(0)) diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.ts index ce2d682b2..c5fb4dd17 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.ts @@ -5,18 +5,18 @@ import { buildReviewTargetDescriptorFromEnvelope } from '@/lib/copilot/review-se import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' import { createLogger } from '@/lib/logs/console/logger' import { + createSavedReviewTargetBootstrapUpdate, getRuntimeStateFromDoc, - getRuntimeStateFromUpdate, } from '@/lib/yjs/server/bootstrap-review-target' import { authenticateYjsConnection, YjsAuthError } from './auth' -import { getState, storeState } from './persistence' -import { getExistingDocument, setPersistence, setupWSConnection } from './upstream-utils' +import { getExistingDocument, setupWSConnection } from './upstream-utils' const logger = createLogger('YjsWsHandler') interface YjsIncomingMessage extends IncomingMessage { yjsSessionId?: string yjsUserId?: string + yjsBootstrapState?: Uint8Array } export function handleYjsUpgrade( @@ -37,15 +37,11 @@ export function handleYjsUpgrade( const yjsSessionId = decodeURIComponent(match[1]) void authenticateAndPrepareUpgrade(yjsSessionId, url) - .then(({ userId, resolvedSessionId }) => { - setPersistence(resolvedSessionId, { - getState, - storeState, - }) - + .then(({ bootstrapState, userId, resolvedSessionId }) => { const yjsReq = request as YjsIncomingMessage yjsReq.yjsSessionId = resolvedSessionId yjsReq.yjsUserId = userId + yjsReq.yjsBootstrapState = bootstrapState ensureConnectionHandler(wss) wss.handleUpgrade(request, socket, head, (ws: WebSocket) => { @@ -66,7 +62,7 @@ export function handleYjsUpgrade( async function authenticateAndPrepareUpgrade( pathSessionId: string, url: URL -): Promise<{ userId: string; resolvedSessionId: string }> { +): Promise<{ bootstrapState?: Uint8Array; userId: string; resolvedSessionId: string }> { const accessMode = parseAccessMode(url) const { userId, envelope } = await authenticateYjsConnection(url) @@ -94,15 +90,13 @@ async function authenticateAndPrepareUpgrade( } const liveDoc = await getExistingDocument(pathSessionId) - const persistedState = liveDoc ? null : await getState(pathSessionId) - const runtime = liveDoc - ? getRuntimeStateFromDoc(liveDoc) - : persistedState - ? getRuntimeStateFromUpdate(persistedState) + const bootstrapped = liveDoc + ? null + : descriptor.entityId + ? await createSavedReviewTargetBootstrapUpdate(descriptor) : null + const runtime = liveDoc ? getRuntimeStateFromDoc(liveDoc) : bootstrapped?.runtime - // Snapshot bootstrap is the only path that materializes a missing review target. - // WebSocket upgrades only attach to an already-bootstrapped Yjs session. if (!runtime) { throw new YjsAuthError(409, 'Review target is not bootstrapped') } @@ -112,6 +106,7 @@ async function authenticateAndPrepareUpgrade( } return { + bootstrapState: bootstrapped?.state, userId, resolvedSessionId: pathSessionId, } @@ -146,7 +141,7 @@ function ensureConnectionHandler(wss: WebSocketServer): void { try { logger.info('Yjs connection established', { docId, userId: yjsReq.yjsUserId }) - setupWSConnection(ws, req, { docId, gc: true }) + setupWSConnection(ws, req, { docId, gc: true, bootstrapState: yjsReq.yjsBootstrapState }) } catch (error) { logger.error('Failed to attach Yjs connection', { docId, error }) ws.close(4409, 'Failed to attach Yjs session') From 328bb506b0de0e2751a2e8e649a3ef98d2be1576 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 00:34:58 -0600 Subject: [PATCH 154/284] feat(mcp): defer device login persistence to approval Remove the start-time rate limit and materialize pending login rows when the approval flow first needs them. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/auth/mcp/start/route.test.ts | 24 +--- .../app/api/auth/mcp/start/route.ts | 30 ++--- apps/tradinggoose/lib/mcp/auth.test.ts | 126 ++++++++++++++++++ apps/tradinggoose/lib/mcp/auth.ts | 119 ++++++++--------- 4 files changed, 189 insertions(+), 110 deletions(-) create mode 100644 apps/tradinggoose/lib/mcp/auth.test.ts diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts index 49c6df2d7..022425eec 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts @@ -4,18 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { MockMcpDeviceLoginRateLimitError, mockStartMcpDeviceLogin } = vi.hoisted(() => ({ - MockMcpDeviceLoginRateLimitError: class extends Error { - constructor(public resetAt: Date) { - super('Too many MCP login starts') - this.name = 'McpDeviceLoginRateLimitError' - } - }, +const { mockStartMcpDeviceLogin } = vi.hoisted(() => ({ mockStartMcpDeviceLogin: vi.fn(), })) vi.mock('@/lib/mcp/auth', () => ({ - McpDeviceLoginRateLimitError: MockMcpDeviceLoginRateLimitError, startMcpDeviceLogin: (...args: unknown[]) => mockStartMcpDeviceLogin(...args), })) @@ -50,19 +43,4 @@ describe('MCP login start route', () => { }) expect(mockStartMcpDeviceLogin).toHaveBeenCalledWith() }) - - it('returns 429 when MCP login starts are rate limited', async () => { - const resetAt = new Date(Date.now() + 30_000) - mockStartMcpDeviceLogin.mockRejectedValueOnce(new MockMcpDeviceLoginRateLimitError(resetAt)) - const { POST } = await import('./route') - - const response = await POST() - - expect(response.status).toBe(429) - expect(response.headers.get('Retry-After')).toBe('30') - await expect(response.json()).resolves.toEqual({ - error: 'Too many MCP login starts', - retryAfter: resetAt.getTime(), - }) - }) }) diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.ts index e97fb3a84..565a12da4 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.ts @@ -1,31 +1,17 @@ import { NextResponse } from 'next/server' -import { McpDeviceLoginRateLimitError, startMcpDeviceLogin } from '@/lib/mcp/auth' +import { startMcpDeviceLogin } from '@/lib/mcp/auth' import { getBaseUrl } from '@/lib/urls/utils' export const dynamic = 'force-dynamic' export async function POST() { const baseUrl = getBaseUrl() - try { - const login = await startMcpDeviceLogin() - const authorizeUrl = new URL('/mcp/authorize', baseUrl) - authorizeUrl.searchParams.set('code', login.code) + const login = await startMcpDeviceLogin() + const authorizeUrl = new URL('/mcp/authorize', baseUrl) + authorizeUrl.searchParams.set('code', login.code) - return NextResponse.json({ - ...login, - authorizeUrl: authorizeUrl.toString(), - }) - } catch (error) { - if (error instanceof McpDeviceLoginRateLimitError) { - const retryAfter = Math.max( - 0, - Math.ceil((error.resetAt.getTime() - Date.now()) / 1000) - ).toString() - return NextResponse.json( - { error: error.message, retryAfter: error.resetAt.getTime() }, - { status: 429, headers: { 'Retry-After': retryAfter } } - ) - } - throw error - } + return NextResponse.json({ + ...login, + authorizeUrl: authorizeUrl.toString(), + }) } diff --git a/apps/tradinggoose/lib/mcp/auth.test.ts b/apps/tradinggoose/lib/mcp/auth.test.ts new file mode 100644 index 000000000..ee5a98e17 --- /dev/null +++ b/apps/tradinggoose/lib/mcp/auth.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { db } = vi.hoisted(() => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + delete: vi.fn(), + update: vi.fn(), + transaction: vi.fn(), + }, +})) + +const verification = Object.fromEntries( + ['id', 'identifier', 'value', 'expiresAt', 'createdAt', 'updatedAt'].map((field) => [ + field, + `verification.${field}`, + ]) +) + +vi.mock('@tradinggoose/db', () => ({ db })) +vi.mock('@tradinggoose/db/schema', () => ({ apiKey: {}, verification })) +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions) => ({ conditions })), + eq: vi.fn((field, value) => ({ field, value })), + like: vi.fn((field, value) => ({ field, value })), + lte: vi.fn((field, value) => ({ field, value })), +})) +vi.mock('@/lib/api-key/service', () => ({ createApiKey: vi.fn() })) +vi.mock('@/lib/env', () => ({ env: { INTERNAL_API_SECRET: '12345678901234567890123456789012' } })) +vi.mock('@/lib/urls/utils', () => ({ getBaseUrl: vi.fn(() => 'https://studio.example.test') })) + +function selectRows(...responses: unknown[][]) { + db.select.mockImplementation(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockResolvedValue(responses.shift() ?? []), + })), + })), + })) +} + +function mockDelete() { + db.delete.mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) +} + +function mockInsertValues() { + const values = vi.fn(() => ({ onConflictDoNothing: vi.fn().mockResolvedValue([]) })) + db.insert.mockReturnValue({ values }) + return values +} + +function readCodeFields(code: string) { + const [, createdAt, expiresAt, , verificationKeyHash] = code.split('.') + return { + createdAt: new Date(Number(createdAt)).toISOString(), + expiresAt: new Date(Number(expiresAt)), + verificationKeyHash, + } +} + +describe('MCP device login auth', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-19T12:00:00.000Z')) + vi.clearAllMocks() + mockDelete() + selectRows() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('materializes pending state with the signed expiry from the approval flow', async () => { + const { createMcpDeviceLoginApprovalChallenge, startMcpDeviceLogin } = await import('./auth') + const login = await startMcpDeviceLogin() + expect(login.code).toBeTruthy() + expect(login.verificationKey).toBeTruthy() + expect(login.expiresAt).toBe('2026-06-19T12:10:00.000Z') + expect(db.insert).not.toHaveBeenCalled() + + const fields = readCodeFields(login.code) + selectRows( + [], + [ + { + id: 'device-login-row', + value: JSON.stringify({ + status: 'pending', + createdAt: fields.createdAt, + verificationKeyHash: fields.verificationKeyHash, + }), + expiresAt: fields.expiresAt, + }, + ] + ) + const insertValues = mockInsertValues() + + const challenge = await createMcpDeviceLoginApprovalChallenge({ + code: login.code, + userId: 'user-1', + }) + + expect(challenge.status).toBe('pending') + expect(insertValues).toHaveBeenCalledWith( + expect.objectContaining({ + expiresAt: fields.expiresAt, + }) + ) + }) + + it('deletes the row for a signed code revisited after expiry', async () => { + const { pollMcpDeviceLogin, startMcpDeviceLogin } = await import('./auth') + const login = await startMcpDeviceLogin() + vi.setSystemTime(new Date('2026-06-19T12:11:00.000Z')) + + await expect(pollMcpDeviceLogin(login.code, login.verificationKey)).resolves.toEqual({ + status: 'expired', + }) + expect(db.delete).toHaveBeenCalled() + }) +}) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index dd9a67c81..7310d1ee9 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -1,7 +1,7 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' import { db } from '@tradinggoose/db' -import { apiKey, userRateLimits, verification } from '@tradinggoose/db/schema' -import { and, eq, sql } from 'drizzle-orm' +import { apiKey, verification } from '@tradinggoose/db/schema' +import { and, eq, like, lte } from 'drizzle-orm' import { nanoid } from 'nanoid' import { createApiKey } from '@/lib/api-key/service' import { env } from '@/lib/env' @@ -9,8 +9,6 @@ import { getBaseUrl } from '@/lib/urls/utils' const DEVICE_LOGIN_TTL_MS = 10 * 60 * 1000 const DEVICE_LOGIN_PREFIX = 'mcp:' -const DEVICE_LOGIN_START_RATE_LIMIT = 60 -const DEVICE_LOGIN_START_WINDOW_MS = 60 * 1000 const POLL_INTERVAL_SECONDS = 2 type PendingDeviceLogin = { @@ -37,6 +35,7 @@ type DeviceLogin = { state: DeviceLoginState expiresAt: Date } +type PendingDeviceLoginRecord = DeviceLogin & { state: PendingDeviceLogin } export type McpDeviceLoginPollResult = | { status: 'pending'; intervalSeconds: number; expiresAt: string } @@ -62,13 +61,6 @@ export type McpDeviceLoginStartResult = { intervalSeconds: number } -export class McpDeviceLoginRateLimitError extends Error { - constructor(public resetAt: Date) { - super('Too many MCP login starts') - this.name = 'McpDeviceLoginRateLimitError' - } -} - function hashValue(value: string) { return createHash('sha256').update(value).digest('hex') } @@ -126,6 +118,7 @@ function createDeviceLogin({ return { code, id: buildDeviceLoginId(code), + expiresAt, state: { status: 'pending', createdAt: now.toISOString(), @@ -165,11 +158,7 @@ function parseDeviceLoginCode(code: string): DeviceLogin | null { if (!Number.isFinite(createdAtTime) || !Number.isFinite(expiresAtTime)) { return null } - const expiresAt = new Date(expiresAtTime) - if (expiresAt <= new Date()) { - return null - } return { id: buildDeviceLoginId(code), @@ -215,12 +204,11 @@ function parseDeviceLoginState(value: string): DeviceLoginState | null { } } -async function readDeviceLogin(code: string) { - const parsedLogin = parseDeviceLoginCode(code) - if (!parsedLogin) { - return null - } +function isPendingDeviceLoginRecord(login: DeviceLogin): login is PendingDeviceLoginRecord { + return login.state.status === 'pending' +} +async function readPersistedDeviceLogin(login: DeviceLogin, now: Date) { const [row] = await db .select({ id: verification.id, @@ -228,7 +216,7 @@ async function readDeviceLogin(code: string) { expiresAt: verification.expiresAt, }) .from(verification) - .where(eq(verification.id, parsedLogin.id)) + .where(eq(verification.id, login.id)) .limit(1) if (!row) { @@ -236,7 +224,7 @@ async function readDeviceLogin(code: string) { } const state = parseDeviceLoginState(row.value) - if (!state || row.expiresAt <= new Date()) { + if (!state || row.expiresAt <= now) { await db.delete(verification).where(eq(verification.id, row.id)) return null } @@ -248,6 +236,21 @@ async function readDeviceLogin(code: string) { } } +async function readDeviceLogin(code: string) { + const parsedLogin = parseDeviceLoginCode(code) + if (!parsedLogin) { + return null + } + + const now = new Date() + if (parsedLogin.expiresAt <= now) { + await db.delete(verification).where(eq(verification.id, parsedLogin.id)) + return null + } + + return (await readPersistedDeviceLogin(parsedLogin, now)) ?? parsedLogin +} + async function updateDeviceLoginState( login: DeviceLogin, nextState: DeviceLoginState @@ -264,40 +267,32 @@ async function updateDeviceLoginState( return Boolean(updated) } -async function enforceDeviceLoginStartRateLimit(now: Date) { - const windowStart = new Date(now.getTime() - DEVICE_LOGIN_START_WINDOW_MS) - const [record] = await db - .insert(userRateLimits) +async function deleteExpiredDeviceLogins(now: Date) { + await db + .delete(verification) + .where( + and( + like(verification.identifier, `${DEVICE_LOGIN_PREFIX}%`), + lte(verification.expiresAt, now) + ) + ) +} + +async function persistPendingDeviceLogin(login: PendingDeviceLoginRecord, now: Date) { + await deleteExpiredDeviceLogins(now) + await db + .insert(verification) .values({ - referenceId: `${DEVICE_LOGIN_PREFIX}start:${getDeviceLoginDeploymentScope()}`, - syncApiRequests: 0, - asyncApiRequests: 0, - apiEndpointRequests: 1, - windowStart: now, - lastRequestAt: now, - isRateLimited: false, - rateLimitResetAt: null, - }) - .onConflictDoUpdate({ - target: userRateLimits.referenceId, - set: { - apiEndpointRequests: sql`CASE WHEN ${userRateLimits.windowStart} < ${windowStart.toISOString()} THEN 1 ELSE ${userRateLimits.apiEndpointRequests} + 1 END`, - windowStart: sql`CASE WHEN ${userRateLimits.windowStart} < ${windowStart.toISOString()} THEN ${now.toISOString()} ELSE ${userRateLimits.windowStart} END`, - lastRequestAt: now, - }, - }) - .returning({ - apiEndpointRequests: userRateLimits.apiEndpointRequests, - windowStart: userRateLimits.windowStart, + id: login.id, + identifier: login.id, + value: JSON.stringify(login.state), + expiresAt: login.expiresAt, + createdAt: new Date(login.state.createdAt), + updatedAt: now, }) + .onConflictDoNothing({ target: verification.id }) - if (!record || record.apiEndpointRequests <= DEVICE_LOGIN_START_RATE_LIMIT) { - return - } - - throw new McpDeviceLoginRateLimitError( - new Date(new Date(record.windowStart).getTime() + DEVICE_LOGIN_START_WINDOW_MS) - ) + return readPersistedDeviceLogin(login, now) } export async function startMcpDeviceLogin(): Promise { @@ -306,16 +301,6 @@ export async function startMcpDeviceLogin(): Promise const expiresAt = new Date(now.getTime() + DEVICE_LOGIN_TTL_MS) const login = createDeviceLogin({ expiresAt, now, verificationKey }) - await enforceDeviceLoginStartRateLimit(now) - await db.insert(verification).values({ - id: login.id, - identifier: login.id, - value: JSON.stringify(login.state), - expiresAt: login.expiresAt, - createdAt: now, - updatedAt: now, - }) - return { code: login.code, verificationKey, @@ -331,10 +316,14 @@ export async function createMcpDeviceLoginApprovalChallenge({ code: string userId: string }): Promise { - const login = await readDeviceLogin(code) - if (!login) { + const parsedLogin = await readDeviceLogin(code) + if (!parsedLogin) { return { status: 'expired' } } + const login = isPendingDeviceLoginRecord(parsedLogin) + ? await persistPendingDeviceLogin(parsedLogin, new Date()) + : parsedLogin + if (!login) return { status: 'expired' } if (login.state.status === 'approved') { if (login.state.userId !== userId) { From c376d1e3131c78951a131a7fb243c65cac51635e Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 00:35:21 -0600 Subject: [PATCH 155/284] refactor(copilot): require resolved review target runtime Treat bootstrapped review targets as always having runtime state and tighten the helper imports accordingly. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/copilot/review-sessions/runtime.ts | 7 +++++-- apps/tradinggoose/lib/copilot/review-sessions/types.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/review-sessions/runtime.ts b/apps/tradinggoose/lib/copilot/review-sessions/runtime.ts index d2c51bd7d..b3c74a918 100644 --- a/apps/tradinggoose/lib/copilot/review-sessions/runtime.ts +++ b/apps/tradinggoose/lib/copilot/review-sessions/runtime.ts @@ -1,5 +1,8 @@ -import * as Y from 'yjs' -import type { ReviewTargetDocState, ReviewTargetRuntimeState } from '@/lib/copilot/review-sessions/types' +import type * as Y from 'yjs' +import type { + ReviewTargetDocState, + ReviewTargetRuntimeState, +} from '@/lib/copilot/review-sessions/types' function isReviewTargetDocState(value: unknown): value is ReviewTargetDocState { return value === 'active' || value === 'expired' diff --git a/apps/tradinggoose/lib/copilot/review-sessions/types.ts b/apps/tradinggoose/lib/copilot/review-sessions/types.ts index 9a9b67d79..4336061e4 100644 --- a/apps/tradinggoose/lib/copilot/review-sessions/types.ts +++ b/apps/tradinggoose/lib/copilot/review-sessions/types.ts @@ -36,7 +36,7 @@ export interface ReviewTargetRuntimeState { export interface ResolvedReviewTarget { descriptor: ReviewTargetDescriptor - runtime: ReviewTargetRuntimeState | null + runtime: ReviewTargetRuntimeState } export const YJS_TARGET_KINDS = ['workflow', 'entity', 'review_session'] as const From 0d70317c93f6e5baa9ef0377338b5c45e0c5ac3e Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 01:14:44 -0600 Subject: [PATCH 156/284] feat(copilot): expose mutation tools to trusted agents Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/copilot/mcp/route.test.ts | 28 +++++++++++++++++++ .../tradinggoose/app/api/copilot/mcp/route.ts | 4 +-- .../lib/copilot/tools/server/router.test.ts | 3 ++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index d7a064ff4..9a0ad0db7 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -132,6 +132,8 @@ describe('Copilot MCP route', () => { expect(body.result.instructions).toContain( 'Do not store workspaceId, entityId, or entity targets' ) + expect(body.result.instructions).toContain('trusted personal coding agents') + expect(body.result.instructions).toContain('Mutating tools execute directly') }) it('accepts a case-insensitive bearer auth scheme', async () => { @@ -227,6 +229,32 @@ describe('Copilot MCP route', () => { expect(body.result.content[0].text).toBe(JSON.stringify({ workflows: [] }, null, 2)) }) + it('dispatches external MCP mutation tools with full personal-agent access', async () => { + const { POST } = await import('./route') + mockGetMcpServerToolIds.mockReturnValueOnce(['edit_workflow']) + mockRouteExecution.mockResolvedValueOnce({ success: true }) + + const response = await POST( + createMcpRequest({ + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'edit_workflow', + arguments: { workflowId: 'workflow-1', mermaid: 'graph TD' }, + }, + }) + ) + const body = await response.json() + + expect(mockRouteExecution).toHaveBeenCalledWith( + 'edit_workflow', + { workflowId: 'workflow-1', mermaid: 'graph TD' }, + { userId: 'user-1', accessLevel: 'full' } + ) + expect(body.result.structuredContent).toEqual({ success: true }) + }) + it('returns per-entry invalid request errors for malformed batches', async () => { const { POST } = await import('./route') diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index 703b7e676..17a18b9c3 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -116,9 +116,9 @@ async function buildInstructions(userId: string) { : ['- No accessible workspaces were found.'] return [ - 'TradingGoose Copilot MCP exposes server-side Copilot tools that are safe for external MCP access.', + 'TradingGoose Copilot MCP exposes server-side Copilot tools for trusted personal coding agents, including direct mutation tools.', 'Local MCP config stores only this user auth token. Do not store workspaceId, entityId, or entity targets in the local MCP config.', - 'Use entityId for read/edit/rename tools that target an existing entity. Credential, OAuth, and environment reads require scope="personal" for the authenticated user or scope="workspace" with workspaceId. Workspace-scoped tools, including list/create, Google Drive, and workspace account reads, require workspaceId. Environment writes use the same personal/workspace scope rule.', + 'Use entityId for read/edit/rename tools that target an existing entity. Mutating tools execute directly for the authenticated personal API key; Studio review tokens are not part of the external MCP protocol. Credential, OAuth, and environment reads require scope="personal" for the authenticated user or scope="workspace" with workspaceId. Workspace-scoped tools, including list/create, Google Drive, and workspace account reads, require workspaceId. Environment writes use the same personal/workspace scope rule.', 'MCP server documents redact header/env secret values as [redacted]. Keep [redacted] to preserve an existing secret, send a concrete value to replace it, or omit the key to delete it.', 'Accessible workspaces for the authenticated user:', ...workspaceLines, diff --git a/apps/tradinggoose/lib/copilot/tools/server/router.test.ts b/apps/tradinggoose/lib/copilot/tools/server/router.test.ts index bb18033a5..710de31f1 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/router.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/router.test.ts @@ -239,6 +239,9 @@ describe('copilot contract registry', () => { it('uses an explicit external MCP tool list', () => { expect(getMcpServerToolIds()).toContain('list_workflows') + expect(getMcpServerToolIds()).toContain('edit_workflow') + expect(getMcpServerToolIds()).toContain('set_environment_variables') + expect(getMcpServerToolIds()).toContain('create_mcp_server') expect(getMcpServerToolIds()).toContain('get_available_blocks') expect(getMcpServerToolIds()).not.toContain('make_api_request') }) From a83bc85f76d10897968f0dabc9f3a3f84011615b Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 01:18:30 -0600 Subject: [PATCH 157/284] refactor(workflows): move variable remapping into import export Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/templates/[id]/use/route.ts | 9 +++-- .../workflows/[id]/duplicate/route.test.ts | 14 -------- .../app/api/workflows/[id]/duplicate/route.ts | 2 +- .../app/api/workflows/route.test.ts | 12 ------- apps/tradinggoose/app/api/workflows/route.ts | 3 +- apps/tradinggoose/lib/workflows/db-helpers.ts | 24 ------------- .../lib/workflows/import-export.ts | 36 ++++++++++++++----- apps/tradinggoose/lib/workflows/import.ts | 2 +- 8 files changed, 37 insertions(+), 65 deletions(-) diff --git a/apps/tradinggoose/app/api/templates/[id]/use/route.ts b/apps/tradinggoose/app/api/templates/[id]/use/route.ts index e8474a355..4680b8658 100644 --- a/apps/tradinggoose/app/api/templates/[id]/use/route.ts +++ b/apps/tradinggoose/app/api/templates/[id]/use/route.ts @@ -5,10 +5,11 @@ import { type NextRequest, NextResponse } from 'next/server' import { v4 as uuidv4 } from 'uuid' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' -import { generateRequestId } from '@/lib/utils' import { getBaseUrl } from '@/lib/urls/utils' +import { generateRequestId } from '@/lib/utils' +import { regenerateWorkflowStateIds } from '@/lib/workflows/db-helpers' +import { remapVariableIds } from '@/lib/workflows/import-export' import { normalizeVariables } from '@/lib/workflows/variable-utils' -import { regenerateWorkflowStateIds, remapVariableIds } from '@/lib/workflows/db-helpers' const logger = createLogger('TemplateUseAPI') @@ -65,7 +66,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const now = new Date() const templateState = - templateData.state && typeof templateData.state === 'object' ? (templateData.state as any) : null + templateData.state && typeof templateData.state === 'object' + ? (templateData.state as any) + : null const templateVariables = normalizeVariables(templateState?.variables) const remappedVariables = remapVariableIds(templateVariables, newWorkflowId) diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts index 2e660b569..55656bb19 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts @@ -6,7 +6,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('Workflow Duplicate API Route', () => { let loadWorkflowStateMock: ReturnType - let remapVariableIdsMock: ReturnType let regenerateWorkflowStateIdsMock: ReturnType let saveWorkflowToNormalizedTablesMock: ReturnType let insertValuesMock: ReturnType @@ -43,18 +42,6 @@ describe('Workflow Duplicate API Route', () => { vi.clearAllMocks() loadWorkflowStateMock = vi.fn() - remapVariableIdsMock = vi.fn((variables, newWorkflowId: string) => - Object.fromEntries( - Object.values(variables as Record).map((variable, index) => [ - `remapped-${index + 1}`, - { - ...variable, - id: `remapped-${index + 1}`, - workflowId: newWorkflowId, - }, - ]) - ) - ) regenerateWorkflowStateIdsMock = vi.fn((state) => JSON.parse(JSON.stringify(state))) saveWorkflowToNormalizedTablesMock = vi.fn(async (_workflowId, state) => ({ success: true, @@ -124,7 +111,6 @@ describe('Workflow Duplicate API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ loadWorkflowState: loadWorkflowStateMock, - remapVariableIds: remapVariableIdsMock, regenerateWorkflowStateIds: regenerateWorkflowStateIdsMock, saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, })) diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts index 262868f4d..e60d7a1f1 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -11,9 +11,9 @@ import { generateRequestId } from '@/lib/utils' import { loadWorkflowState, regenerateWorkflowStateIds, - remapVariableIds, saveWorkflowToNormalizedTables, } from '@/lib/workflows/db-helpers' +import { remapVariableIds } from '@/lib/workflows/import-export' import { normalizeVariables } from '@/lib/workflows/variable-utils' import type { Variable } from '@/stores/variables/types' import type { WorkflowState } from '@/stores/workflows/workflow/types' diff --git a/apps/tradinggoose/app/api/workflows/route.test.ts b/apps/tradinggoose/app/api/workflows/route.test.ts index f13d67e5b..fed29e8f2 100644 --- a/apps/tradinggoose/app/api/workflows/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/route.test.ts @@ -88,18 +88,6 @@ describe('Workflow API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - remapVariableIds: vi.fn((variables: Record, workflowId: string) => - Object.fromEntries( - Object.entries(variables).map(([key, variable]) => [ - key, - { - ...variable, - id: crypto.randomUUID(), - workflowId, - }, - ]) - ) - ), saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, })) diff --git a/apps/tradinggoose/app/api/workflows/route.ts b/apps/tradinggoose/app/api/workflows/route.ts index 8cbd187d3..3db1ab274 100644 --- a/apps/tradinggoose/app/api/workflows/route.ts +++ b/apps/tradinggoose/app/api/workflows/route.ts @@ -8,8 +8,9 @@ import { getStableVibrantColor } from '@/lib/colors' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' -import { remapVariableIds, saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' +import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' +import { remapVariableIds } from '@/lib/workflows/import-export' import { normalizeVariables } from '@/lib/workflows/variable-utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 6cb31edc2..22577e13e 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -22,7 +22,6 @@ import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' import { inferWorkflowDirectionFromState } from '@/lib/workflows/workflow-direction' import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' import { extractPersistedStateFromDoc } from '@/lib/yjs/workflow-session' -import type { Variable } from '@/stores/variables/types' import type { BlockState, Loop, @@ -218,29 +217,6 @@ export function toISOStringOrUndefined( return resolveStoredDateValue(value)?.toISOString() } -/** - * Create a deep copy of a variables record with fresh IDs and the given - * `newWorkflowId`. Used when duplicating a workflow or instantiating a - * template so variable references are independent. - */ -export function remapVariableIds( - sourceVariables: Record, - newWorkflowId: string -): Record { - const remapped: Record = {} - - for (const variable of Object.values(sourceVariables)) { - const newVarId = crypto.randomUUID() - remapped[newVarId] = { - ...variable, - id: newVarId, - workflowId: newWorkflowId, - } - } - - return remapped -} - export async function ensureUniqueBlockIds( workflowId: string, state: WorkflowState diff --git a/apps/tradinggoose/lib/workflows/import-export.ts b/apps/tradinggoose/lib/workflows/import-export.ts index 0c6c2242b..d6013b4f9 100644 --- a/apps/tradinggoose/lib/workflows/import-export.ts +++ b/apps/tradinggoose/lib/workflows/import-export.ts @@ -12,6 +12,7 @@ import { import { type ExportWorkflowState, sanitizeForExport } from '@/lib/workflows/json-sanitizer' import { normalizeVariables } from '@/lib/workflows/variable-utils' import type { SkillDefinition } from '@/stores/skills/types' +import type { Variable } from '@/stores/variables/types' import type { WorkflowState } from '@/stores/workflows/workflow/types' export const WORKFLOW_EXPORT_SOURCE = 'workflowEditor' @@ -46,15 +47,32 @@ type ParseWorkflowImportResult = { matched: boolean } -const WorkflowTransferSchema = z - .object({ - name: z - .string() - .transform(normalizeInlineWhitespace) - .pipe(z.string().min(1, 'Workflow name is required')), - description: z.string().transform(normalizeString).optional().default(''), - state: z.unknown(), - }) +export function remapVariableIds( + sourceVariables: Record, + newWorkflowId: string +): Record { + const remapped: Record = {} + + for (const variable of Object.values(sourceVariables)) { + const newVarId = crypto.randomUUID() + remapped[newVarId] = { + ...variable, + id: newVarId, + workflowId: newWorkflowId, + } + } + + return remapped +} + +const WorkflowTransferSchema = z.object({ + name: z + .string() + .transform(normalizeInlineWhitespace) + .pipe(z.string().min(1, 'Workflow name is required')), + description: z.string().transform(normalizeString).optional().default(''), + state: z.unknown(), +}) const WorkflowImportEnvelopeSchema = TradingGooseExportEnvelopeSchema.extend({ workflows: z.array(WorkflowTransferSchema).length(1, 'Exactly one workflow is required'), diff --git a/apps/tradinggoose/lib/workflows/import.ts b/apps/tradinggoose/lib/workflows/import.ts index e67709523..c90da1d1d 100644 --- a/apps/tradinggoose/lib/workflows/import.ts +++ b/apps/tradinggoose/lib/workflows/import.ts @@ -1,6 +1,6 @@ import { createLogger } from '@/lib/logs/console/logger' -import { remapVariableIds } from '@/lib/workflows/db-helpers' import { + remapVariableIds, resolveImportedWorkflowName, type WorkflowTransferRecord, } from '@/lib/workflows/import-export' From 0c4b925b36fd30c5ba6c9ba8503c5e033ce903dc Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 01:18:47 -0600 Subject: [PATCH 158/284] test(yjs): cover bridge failure propagation Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/workflows/db-helpers.test.ts | 9 +++++++++ apps/tradinggoose/lib/workflows/db-helpers.ts | 1 + .../lib/yjs/server/apply-entity-state.test.ts | 17 +++++++++++++++++ .../lib/yjs/server/apply-entity-state.ts | 1 + 4 files changed, 28 insertions(+) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index c69913425..df8098655 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -1076,6 +1076,15 @@ describe('Database Helpers', () => { expect(result).toBeNull() expect(mockDb.select).not.toHaveBeenCalled() }) + + it('does not fall back to direct DB reads when the Yjs bridge fails', async () => { + mockReadBootstrappedReviewTargetSnapshot.mockRejectedValue(new Error('bridge unavailable')) + + await expect(dbHelpers.loadWorkflowState(mockWorkflowId)).rejects.toThrow( + 'bridge unavailable' + ) + expect(mockDb.select).not.toHaveBeenCalled() + }) }) describe('advancedMode persistence comparison with isWide', () => { diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 22577e13e..4ec89955a 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -148,6 +148,7 @@ export async function loadWorkflowState( const { readBootstrappedReviewTargetSnapshot } = await import( '@/lib/yjs/server/bootstrap-review-target' ) + // Existing workflow reads intentionally use the live Yjs bridge; DB is only the bootstrap seed. const snapshot = await readBootstrappedReviewTargetSnapshot({ workspaceId: null, entityKind: 'workflow', diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts index ac17c7d74..fa6c3da7d 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts @@ -143,4 +143,21 @@ describe('applySavedEntityPersistedState', () => { expect(mockApplyEntityStateInSocketServer).not.toHaveBeenCalled() expect(events).toEqual(['snapshot', 'db']) }) + + it('does not materialize DB state when the saved-entity Yjs apply fails', async () => { + const { applySavedEntityPersistedState } = await import('./apply-entity-state') + mockApplyEntityStateInSocketServer.mockRejectedValueOnce(new TypeError('fetch failed')) + + await expect( + applySavedEntityPersistedState('skill', 'skill-1', 'workspace-1', { + name: 'Copilot Skill', + description: 'Copilot description', + content: 'Use the Copilot input.', + }) + ).rejects.toThrow('fetch failed') + + expect(mockGetYjsSnapshot).not.toHaveBeenCalled() + expect(mockDbUpdate).not.toHaveBeenCalled() + expect(events).toEqual([]) + }) }) diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index fd19bc05d..9ea52238e 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -177,6 +177,7 @@ export async function applySavedEntityPersistedState( fields: Record ): Promise { const normalizedFields = normalizeSavedEntityFields(entityKind, fields) + // Existing saved-entity mutations must enter the live Yjs session before DB materialization. await applyEntityStateInSocketServer(entityId, entityKind, normalizedFields) await persistSavedEntityYjsState(entityKind, entityId, workspaceId) } From 5d5059946b2c4ba16e2ae19a155dc2043916a1b1 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 01:19:06 -0600 Subject: [PATCH 159/284] fix(api-key): reject malformed keys before lookup Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/api-key/service.test.ts | 70 +++++++++++++++++++ apps/tradinggoose/lib/api-key/service.ts | 14 ++++ 2 files changed, 84 insertions(+) create mode 100644 apps/tradinggoose/lib/api-key/service.test.ts diff --git a/apps/tradinggoose/lib/api-key/service.test.ts b/apps/tradinggoose/lib/api-key/service.test.ts new file mode 100644 index 000000000..87f3c1434 --- /dev/null +++ b/apps/tradinggoose/lib/api-key/service.test.ts @@ -0,0 +1,70 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDbSelect } = vi.hoisted(() => ({ + mockDbSelect: vi.fn(), +})) + +vi.mock('@tradinggoose/db', () => ({ + db: { + select: (...args: unknown[]) => mockDbSelect(...args), + update: vi.fn(), + }, +})) + +vi.mock('@tradinggoose/db/schema', () => ({ + apiKey: { + id: 'apiKey.id', + userId: 'apiKey.userId', + workspaceId: 'apiKey.workspaceId', + type: 'apiKey.type', + key: 'apiKey.key', + expiresAt: 'apiKey.expiresAt', + lastUsed: 'apiKey.lastUsed', + }, +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions) => ({ conditions })), + eq: vi.fn((field, value) => ({ field, value })), + inArray: vi.fn((field, values) => ({ field, values })), +})) + +vi.mock('@/lib/env', () => ({ env: {} })) +vi.mock('@/lib/logs/console/logger', () => ({ + createLogger: () => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }), +})) + +function mockApiKeyRows(rows: unknown[]) { + mockDbSelect.mockReturnValue({ + from: vi.fn(() => ({ + where: vi.fn().mockResolvedValue(rows), + })), + }) +} + +describe('API key service', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + mockApiKeyRows([]) + }) + + it('rejects malformed API keys before reading key records', async () => { + const { authenticateApiKeyFromHeader } = await import('./service') + + await expect(authenticateApiKeyFromHeader('not-a-generated-key')).resolves.toMatchObject({ + success: false, + error: 'Invalid API key', + }) + expect(mockDbSelect).not.toHaveBeenCalled() + }) +}) diff --git a/apps/tradinggoose/lib/api-key/service.ts b/apps/tradinggoose/lib/api-key/service.ts index 1a911a390..1b7c312fc 100644 --- a/apps/tradinggoose/lib/api-key/service.ts +++ b/apps/tradinggoose/lib/api-key/service.ts @@ -7,6 +7,7 @@ import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('ApiKeyService') +const API_KEY_SECRET_PATTERN = /^[A-Za-z0-9_-]{32}$/ export interface ApiKeyAuthOptions { userId?: string @@ -54,6 +55,9 @@ export async function authenticateApiKeyFromHeader( if (!apiKey) { return { success: false, error: 'API key required' } } + if (!isApiKeyFormat(apiKey)) { + return { success: false, error: 'Invalid API key' } + } try { const conditions: SQL[] = [] @@ -271,6 +275,16 @@ export function generateEncryptedApiKey(): string { return `sk-tradinggoose-${nanoid(32)}` } +export function isApiKeyFormat(apiKey: string): boolean { + if (isEncryptedApiKeyFormat(apiKey)) { + return API_KEY_SECRET_PATTERN.test(apiKey.slice('sk-tradinggoose-'.length)) + } + if (isPlainApiKeyFormat(apiKey)) { + return API_KEY_SECRET_PATTERN.test(apiKey.slice('tradinggoose_'.length)) + } + return false +} + export function isEncryptedApiKeyFormat(apiKey: string): boolean { return apiKey.startsWith('sk-tradinggoose-') } From dbdfcf91b5c68b0dea8481993b2056d7cf0d3a2d Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 02:03:40 -0600 Subject: [PATCH 160/284] refactor(yjs): centralize state persistence in socket server Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/mcp/servers/[id]/route.ts | 5 +- .../app/api/templates/[id]/use/route.ts | 55 +++-- .../app/api/workflows/[id]/route.test.ts | 8 +- .../app/api/workflows/[id]/route.ts | 3 +- .../api/workflows/[id]/state/route.test.ts | 166 -------------- .../app/api/workflows/[id]/state/route.ts | 210 ------------------ .../sessions/[sessionId]/snapshot/route.ts | 23 +- .../tools/server/entities/mcp-server.ts | 5 +- .../tools/server/entities/shared.test.ts | 48 ++-- .../copilot/tools/server/entities/shared.ts | 4 +- .../lib/custom-tools/operations.ts | 4 +- .../lib/indicators/custom/operations.ts | 4 +- apps/tradinggoose/lib/knowledge/service.ts | 5 +- .../lib/skills/operations.test.ts | 2 +- apps/tradinggoose/lib/skills/operations.ts | 4 +- apps/tradinggoose/lib/workflows/db-helpers.ts | 48 ++++ .../tradinggoose/lib/workflows/import.test.ts | 122 ++++------ apps/tradinggoose/lib/workflows/import.ts | 14 +- .../lib/yjs/server/apply-entity-state.test.ts | 106 ++++----- .../lib/yjs/server/apply-entity-state.ts | 56 +---- .../yjs/server/apply-workflow-state.test.ts | 137 ++---------- .../lib/yjs/server/apply-workflow-state.ts | 111 +-------- .../lib/yjs/server/bootstrap-review-target.ts | 5 + apps/tradinggoose/socket-server/index.test.ts | 136 +++++++----- .../tradinggoose/socket-server/routes/http.ts | 33 ++- .../socket-server/yjs/upstream-utils.ts | 30 ++- .../socket-server/yjs/ws-handler.test.ts | 8 + .../socket-server/yjs/ws-handler.ts | 38 +++- .../stores/workflows/registry/store.ts | 3 + .../stores/workflows/registry/types.ts | 1 + .../components/control-bar/auto-layout.ts | 114 +--------- .../components/workflow-create-menu.tsx | 16 -- 32 files changed, 438 insertions(+), 1086 deletions(-) delete mode 100644 apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts delete mode 100644 apps/tradinggoose/app/api/workflows/[id]/state/route.ts diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts index 0b872fa1d..6f936075d 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts @@ -9,7 +9,7 @@ import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { - applySavedEntityPersistedState, + applySavedEntityState, SavedEntityPersistenceError, } from '@/lib/yjs/server/apply-entity-state' import { UpdateMcpServerSchema } from '../schema' @@ -93,10 +93,9 @@ export const PATCH = withMcpAuth('write')( updatedAt: new Date(), } - await applySavedEntityPersistedState( + await applySavedEntityState( 'mcp_server', nextServer.id, - workspaceId, savedEntityRowToFields('mcp_server', nextServer) ) diff --git a/apps/tradinggoose/app/api/templates/[id]/use/route.ts b/apps/tradinggoose/app/api/templates/[id]/use/route.ts index 4680b8658..47321ac2a 100644 --- a/apps/tradinggoose/app/api/templates/[id]/use/route.ts +++ b/apps/tradinggoose/app/api/templates/[id]/use/route.ts @@ -5,9 +5,11 @@ import { type NextRequest, NextResponse } from 'next/server' import { v4 as uuidv4 } from 'uuid' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' -import { getBaseUrl } from '@/lib/urls/utils' import { generateRequestId } from '@/lib/utils' -import { regenerateWorkflowStateIds } from '@/lib/workflows/db-helpers' +import { + regenerateWorkflowStateIds, + saveWorkflowToNormalizedTables, +} from '@/lib/workflows/db-helpers' import { remapVariableIds } from '@/lib/workflows/import-export' import { normalizeVariables } from '@/lib/workflows/variable-utils' @@ -69,6 +71,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ templateData.state && typeof templateData.state === 'object' ? (templateData.state as any) : null + if ( + !templateState || + typeof templateState.blocks !== 'object' || + !templateState.blocks || + !Array.isArray(templateState.edges) + ) { + return NextResponse.json({ error: 'Template workflow state is missing' }, { status: 409 }) + } const templateVariables = normalizeVariables(templateState?.variables) const remappedVariables = remapVariableIds(templateVariables, newWorkflowId) @@ -86,33 +96,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ lastSynced: now, }) - if (templateState) { - const regeneratedState = regenerateWorkflowStateIds(templateState) - // Strip template variables from the regenerated state (we use remapped ones) - // but include the remapped variables so the save route persists them to Yjs + DB - const { variables: _templateVars, ...stateWithoutTemplateVars } = regeneratedState as any - const stateWithVariables = { - ...stateWithoutTemplateVars, - variables: remappedVariables, - } - - const stateResponse = await fetch(`${getBaseUrl()}/api/workflows/${newWorkflowId}/state`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - cookie: request.headers.get('cookie') || '', - }, - body: JSON.stringify(stateWithVariables), - }) + const regeneratedState = regenerateWorkflowStateIds(templateState) + const { variables: _templateVars, ...stateWithoutTemplateVars } = regeneratedState as any + const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, { + ...stateWithoutTemplateVars, + lastSaved: now.toISOString(), + isDeployed: false, + deployedAt: undefined, + }) - if (!stateResponse.ok) { - logger.error(`[${requestId}] Failed to save workflow state for template use`) - await db.delete(workflow).where(eq(workflow.id, newWorkflowId)) - return NextResponse.json( - { error: 'Failed to create workflow from template' }, - { status: 500 } - ) - } + if (!saveResult.success) { + logger.error(`[${requestId}] Failed to save workflow state for template use`) + await db.delete(workflow).where(eq(workflow.id, newWorkflowId)) + return NextResponse.json( + { error: 'Failed to create workflow from template' }, + { status: 500 } + ) } await db diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index deb0a29a2..3db8ee1aa 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -96,13 +96,7 @@ describe('Workflow By ID API Route', () => { function expectWorkflowRenameApplied() { expect(mockLoadWorkflowState).not.toHaveBeenCalled() - expect(mockApplyWorkflowEntityName).toHaveBeenCalledWith( - 'workflow-123', - 'Updated Workflow', - expect.objectContaining({ - updatedAt: expect.any(Date), - }) - ) + expect(mockApplyWorkflowEntityName).toHaveBeenCalledWith('workflow-123', 'Updated Workflow') } describe('GET /api/workflows/[id]', () => { diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index 8f0936870..bca49e979 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -384,8 +384,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const updatedWorkflow = await applyWorkflowEntityName( workflowId, - updates.name ?? workflowData.name, - updateData + updates.name ?? workflowData.name ) const elapsed = Date.now() - startTime diff --git a/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts deleted file mode 100644 index 95101248c..000000000 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { NextRequest } from 'next/server' -/** - * @vitest-environment node - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -describe('Workflow State API Route', () => { - let applyWorkflowStateMock: ReturnType - - const createRequest = (body: Record) => - new NextRequest('http://localhost:3000/api/workflows/workflow-id/state', { - method: 'PUT', - body: JSON.stringify(body), - headers: { - 'Content-Type': 'application/json', - }, - }) - - const validStateBody = { - blocks: { - 'block-1': { - id: 'block-1', - type: 'agent', - name: 'Agent', - position: { x: 0, y: 0 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - variables: {}, - } - - beforeEach(() => { - vi.resetModules() - vi.clearAllMocks() - - applyWorkflowStateMock = vi.fn().mockResolvedValue(undefined) - - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) - - vi.doMock('@/lib/logs/console/logger', () => ({ - createLogger: vi.fn(() => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - })), - })) - - vi.doMock('@/lib/utils', () => ({ - generateRequestId: vi.fn(() => 'request-id'), - })) - - vi.doMock('@/lib/workflows/utils', () => ({ - validateWorkflowPermissions: vi.fn().mockResolvedValue({ - error: null, - session: { user: { id: 'user-id' } }, - workflow: { - id: 'workflow-id', - name: 'Workflow', - workspaceId: 'workspace-id', - variables: { - 'db-var': { - id: 'db-var', - workflowId: 'workflow-id', - name: 'dbVar', - type: 'plain', - value: 'db value', - }, - }, - }, - }), - })) - - vi.doMock('@/lib/workflows/validation', () => ({ - sanitizeAgentToolsInBlocks: vi.fn((blocks) => ({ - blocks, - warnings: [], - })), - })) - - vi.doMock('@/lib/workflows/db-helpers', () => ({ - ensureUniqueBlockIds: vi.fn(async (_workflowId: string, state: any) => state), - ensureUniqueEdgeIds: vi.fn(async (_workflowId: string, state: any) => state), - toISOStringOrUndefined: vi.fn((value: string | number | Date | null | undefined) => - value == null ? undefined : new Date(value).toISOString() - ), - })) - - vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ - applyWorkflowState: applyWorkflowStateMock, - })) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('rejects workflow saves that omit variables', async () => { - const { PUT } = await import('@/app/api/workflows/[id]/state/route') - const bodyWithoutVariables = { ...validStateBody } as Record - bodyWithoutVariables.variables = undefined - const response = await PUT(createRequest(bodyWithoutVariables), { - params: Promise.resolve({ id: 'workflow-id' }), - }) - - expect(response.status).toBe(400) - expect(applyWorkflowStateMock).not.toHaveBeenCalled() - }) - - it('replaces variables when the request body includes them', async () => { - const { PUT } = await import('@/app/api/workflows/[id]/state/route') - const variables = { - 'request-var': { - id: 'request-var', - workflowId: 'workflow-id', - name: 'requestVar', - type: 'plain', - value: 'request value', - }, - } - const response = await PUT( - createRequest({ - ...validStateBody, - variables, - }), - { - params: Promise.resolve({ id: 'workflow-id' }), - } - ) - - expect(response.status).toBe(200) - expect(applyWorkflowStateMock).toHaveBeenCalledWith( - 'workflow-id', - expect.any(Object), - variables, - 'Workflow' - ) - }) - - it('returns an error when workflow state apply fails', async () => { - applyWorkflowStateMock.mockRejectedValueOnce(new Error('validation failed')) - - const { PUT } = await import('@/app/api/workflows/[id]/state/route') - const response = await PUT( - createRequest({ - ...validStateBody, - variables: {}, - }), - { - params: Promise.resolve({ id: 'workflow-id' }), - } - ) - - expect(response.status).toBe(500) - expect(applyWorkflowStateMock).toHaveBeenCalledOnce() - }) -}) diff --git a/apps/tradinggoose/app/api/workflows/[id]/state/route.ts b/apps/tradinggoose/app/api/workflows/[id]/state/route.ts deleted file mode 100644 index 6ba5a2aab..000000000 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { createLogger } from '@/lib/logs/console/logger' -import { generateRequestId } from '@/lib/utils' -import { - ensureUniqueBlockIds, - ensureUniqueEdgeIds, - toISOStringOrUndefined, -} from '@/lib/workflows/db-helpers' -import { validateWorkflowPermissions } from '@/lib/workflows/utils' -import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' -import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' -import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' - -const logger = createLogger('WorkflowStateAPI') - -const PositionSchema = z.object({ - x: z.number(), - y: z.number(), -}) - -const BlockDataSchema = z.object({ - parentId: z.string().optional(), - extent: z.literal('parent').optional(), - width: z.number().optional(), - height: z.number().optional(), - collection: z.unknown().optional(), - count: z.number().optional(), - loopType: z.enum(['for', 'forEach', 'while', 'doWhile']).optional(), - whileCondition: z.string().optional(), - parallelType: z.enum(['collection', 'count']).optional(), - type: z.string().optional(), -}) - -const SubBlockStateSchema = z.object({ - id: z.string(), - type: z.string(), - value: z.any(), -}) - -const BlockOutputSchema = z.any() - -const BlockLayoutSchema = z.object({ - measuredWidth: z.number().optional(), - measuredHeight: z.number().optional(), -}) - -const BlockStateSchema = z.object({ - id: z.string(), - type: z.string(), - name: z.string(), - position: PositionSchema, - subBlocks: z.record(SubBlockStateSchema), - outputs: z.record(BlockOutputSchema), - enabled: z.boolean(), - horizontalHandles: z.boolean().optional(), - isWide: z.boolean().optional(), - height: z.number().optional(), - advancedMode: z.boolean().optional(), - triggerMode: z.boolean().optional(), - data: BlockDataSchema.optional(), - layout: BlockLayoutSchema.optional(), -}) - -const EdgeSchema = z.object({ - id: z.string(), - source: z.string(), - target: z.string(), - sourceHandle: z.string().optional(), - targetHandle: z.string().optional(), - type: z.string().optional(), - animated: z.boolean().optional(), - style: z.record(z.any()).optional(), - data: z.record(z.any()).optional(), - label: z.string().optional(), - labelStyle: z.record(z.any()).optional(), - labelShowBg: z.boolean().optional(), - labelBgStyle: z.record(z.any()).optional(), - labelBgPadding: z.array(z.number()).optional(), - labelBgBorderRadius: z.number().optional(), - markerStart: z.string().optional(), - markerEnd: z.string().optional(), -}) - -const LoopSchema = z.object({ - id: z.string(), - nodes: z.array(z.string()), - iterations: z.number(), - loopType: z.enum(['for', 'forEach', 'while', 'doWhile']), - forEachItems: z.union([z.array(z.any()), z.record(z.any()), z.string()]).optional(), - whileCondition: z.string().optional(), -}) - -const ParallelSchema = z.object({ - id: z.string(), - nodes: z.array(z.string()), - distribution: z.union([z.array(z.any()), z.record(z.any()), z.string()]).optional(), - count: z.number().optional(), - parallelType: z.enum(['count', 'collection']).optional(), -}) - -const WorkflowStateSchema = z.object({ - direction: z.enum(['TD', 'LR']).optional(), - blocks: z.record(BlockStateSchema), - edges: z.array(EdgeSchema), - loops: z.record(LoopSchema).optional(), - parallels: z.record(ParallelSchema).optional(), - lastSaved: z.number().optional(), - isDeployed: z.boolean().optional(), - deployedAt: z.coerce.date().optional(), - variables: z.record(z.any()), -}) - -/** - * PUT /api/workflows/[id]/state - * Save complete workflow state to Yjs and materialize derived database tables. - */ -export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const startTime = Date.now() - const { id: workflowId } = await params - - try { - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(workflowId, requestId, 'write') - if (error || !session?.user?.id || !workflowData) { - return NextResponse.json( - { error: error?.message ?? 'Unauthorized' }, - { status: error?.status ?? 401 } - ) - } - const userId = session.user.id - - // Parse and validate request body - const body = await request.json() - const state = WorkflowStateSchema.parse(body) - - // Sanitize custom tools in agent blocks before saving - const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks(state.blocks as any) - - // Filter out blocks without type or name before saving - const filteredBlocks = Object.entries(sanitizedBlocks).reduce( - (acc, [blockId, block]: [string, any]) => { - if (!block?.type) { - logger.warn(`[${requestId}] Skipping block ${blockId} due to missing type`) - return acc - } - - acc[blockId] = { - ...block, - id: block.id || blockId, - name: typeof block.name === 'string' ? block.name : '', - enabled: block.enabled !== undefined ? block.enabled : true, - horizontalHandles: block.horizontalHandles !== undefined ? block.horizontalHandles : true, - isWide: block.isWide !== undefined ? block.isWide : false, - height: block.height !== undefined ? block.height : 0, - subBlocks: block.subBlocks || {}, - outputs: block.outputs || {}, - } - - return acc - }, - {} as typeof state.blocks - ) - - const workflowState = { - ...(state.direction !== undefined ? { direction: state.direction } : {}), - blocks: filteredBlocks, - edges: state.edges, - loops: state.loops || {}, - parallels: state.parallels || {}, - lastSaved: toISOStringOrUndefined(state.lastSaved) ?? new Date().toISOString(), - isDeployed: state.isDeployed || false, - deployedAt: toISOStringOrUndefined(state.deployedAt), - } - - const stateWithUniqueBlockIds = await ensureUniqueBlockIds(workflowId, workflowState as any) - const persistedWorkflowState = await ensureUniqueEdgeIds(workflowId, stateWithUniqueBlockIds) - - await applyWorkflowState( - workflowId, - persistedWorkflowState as WorkflowSnapshot, - state.variables, - workflowData.name - ) - - const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Successfully saved workflow ${workflowId} state in ${elapsed}ms`) - - return NextResponse.json({ success: true, warnings }, { status: 200 }) - } catch (error: any) { - const elapsed = Date.now() - startTime - logger.error( - `[${requestId}] Error saving workflow ${workflowId} state after ${elapsed}ms`, - error - ) - - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request body', details: error.errors }, - { status: 400 } - ) - } - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts b/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts index 145b9af21..cbfa365f9 100644 --- a/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts +++ b/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts @@ -6,16 +6,14 @@ import { } from '@/lib/copilot/review-sessions/identity' import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' import { mcpService } from '@/lib/mcp/service' -import type { SavedEntityKind } from '@/lib/yjs/entity-state' -import { - persistSavedEntityYjsState, - SavedEntityPersistenceError, -} from '@/lib/yjs/server/apply-entity-state' import { ReviewTargetBootstrapError, readBootstrappedReviewTargetSnapshot, } from '@/lib/yjs/server/bootstrap-review-target' -import { applyYjsUpdateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { + applyYjsUpdateInSocketServer, + SocketServerBridgeError, +} from '@/lib/yjs/server/snapshot-bridge' export const dynamic = 'force-dynamic' @@ -110,7 +108,6 @@ export async function POST( if (descriptor.entityKind === 'workflow' || !descriptor.entityId || !descriptor.workspaceId) { return NextResponse.json({ error: 'Saved entity Yjs session required' }, { status: 400 }) } - const entityKind: SavedEntityKind = descriptor.entityKind try { const { updateBase64 } = (await request.json().catch(() => ({}))) as { @@ -125,7 +122,6 @@ export async function POST( request.nextUrl.search, updateBase64 ) - await persistSavedEntityYjsState(entityKind, descriptor.entityId, descriptor.workspaceId) if (descriptor.entityKind === 'mcp_server') { mcpService.clearCache(descriptor.workspaceId) @@ -133,12 +129,15 @@ export async function POST( return NextResponse.json({ success: true }) } catch (error) { - if ( - error instanceof SavedEntityPersistenceError || - error instanceof ReviewTargetBootstrapError - ) { + if (error instanceof ReviewTargetBootstrapError) { return NextResponse.json({ error: error.message }, { status: error.status }) } + if (error instanceof SocketServerBridgeError) { + return NextResponse.json( + { error: error.body || 'Failed to save Yjs session' }, + { status: error.status } + ) + } return NextResponse.json({ error: 'Failed to save Yjs session' }, { status: 500 }) } diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts index bb4e36633..7dfb53f38 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -7,7 +7,7 @@ import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' import { mcpService } from '@/lib/mcp/service' import type { McpTransport } from '@/lib/mcp/types' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' -import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' +import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' import { buildDocumentEnvelope, type EntityCreateResult, @@ -165,10 +165,9 @@ async function applyMcpServerDocument(input: { input.entityId, input.workspaceId ) - await applySavedEntityPersistedState( + await applySavedEntityState( ENTITY_KIND_MCP_SERVER, input.entityId, - input.workspaceId, preserveMcpServerSecretPlaceholders(input.fields, currentFields) ) mcpService.clearCache(input.workspaceId) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts index d91899f7a..2606aa48b 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts @@ -12,8 +12,8 @@ import { executeUpdateEntityDocumentMutation, } from './shared' -const { mockApplySavedEntityPersistedState } = vi.hoisted(() => ({ - mockApplySavedEntityPersistedState: vi.fn(), +const { mockApplySavedEntityState } = vi.hoisted(() => ({ + mockApplySavedEntityState: vi.fn(), })) const mockCheckWorkspaceAccess = vi.hoisted(() => vi.fn()) const mockReadBootstrappedSavedEntityFields = vi.hoisted(() => vi.fn()) @@ -28,8 +28,7 @@ vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ })) vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ - applySavedEntityPersistedState: (...args: unknown[]) => - mockApplySavedEntityPersistedState(...args), + applySavedEntityState: (...args: unknown[]) => mockApplySavedEntityState(...args), })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ @@ -77,16 +76,11 @@ describe('entity document mutation helpers', () => { }) expect(result).not.toHaveProperty('requiresReview') expect(result).not.toHaveProperty('preview') - expect(mockApplySavedEntityPersistedState).toHaveBeenCalledWith( - 'skill', - 'skill-1', - 'workspace-1', - { - name: 'Updated Skill', - description: 'Updated description', - content: 'Use the updated process.', - } - ) + expect(mockApplySavedEntityState).toHaveBeenCalledWith('skill', 'skill-1', { + name: 'Updated Skill', + description: 'Updated description', + content: 'Use the updated process.', + }) expect(mockReadBootstrappedSavedEntityFields).not.toHaveBeenCalled() }) @@ -118,12 +112,7 @@ describe('entity document mutation helpers', () => { } ) - expect(mockApplySavedEntityPersistedState).toHaveBeenCalledWith( - 'skill', - 'skill-1', - 'workspace-1', - nextFields - ) + expect(mockApplySavedEntityState).toHaveBeenCalledWith('skill', 'skill-1', nextFields) }) it('preserves indicator input metadata when applying document updates', async () => { @@ -153,17 +142,12 @@ describe('entity document mutation helpers', () => { { userId: 'user-1', accessLevel: 'full' } ) - expect(mockApplySavedEntityPersistedState).toHaveBeenCalledWith( - 'indicator', - 'indicator-1', - 'workspace-1', - { - name: 'Updated Indicator', - color: '#10b981', - pineCode: "const mode = input.string('fast', 'Mode')", - inputMeta, - } - ) + expect(mockApplySavedEntityState).toHaveBeenCalledWith('indicator', 'indicator-1', { + name: 'Updated Indicator', + color: '#10b981', + pineCode: "const mode = input.string('fast', 'Mode')", + inputMeta, + }) }) it('rejects MCP server create documents without a URL', async () => { @@ -223,7 +207,7 @@ describe('entity document mutation helpers', () => { ) ).rejects.toThrow('Invalid MCP server URL: URL is required and must be a string') - expect(mockApplySavedEntityPersistedState).not.toHaveBeenCalled() + expect(mockApplySavedEntityState).not.toHaveBeenCalled() }) it('keeps Studio create mutations in review mode', async () => { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index c2f35b50a..4572333f6 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -19,7 +19,7 @@ import { import { checkWorkspaceAccess } from '@/lib/permissions/utils' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { readBootstrappedSavedEntityFields } from '@/lib/yjs/server/bootstrap-review-target' -import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' +import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' export type SavedEntityDocumentKind = EntityDocumentKind export type EntityDocumentArgs = { @@ -279,7 +279,7 @@ export async function executeUpdateEntityDocumentMutation( if (apply) { await apply({ entityId, fields, workspaceId }) } else { - await applySavedEntityPersistedState(kind, entityId, workspaceId, fields) + await applySavedEntityState(kind, entityId, fields) } return { success: true, diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index f166a6429..0b8d091be 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -8,7 +8,7 @@ import { } from '@/lib/custom-tools/import-export' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' +import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' const logger = createLogger('CustomToolsOperations') @@ -122,7 +122,7 @@ export async function saveCustomTool({ throw new Error(`A tool with the title "${tool.title}" already exists in this workspace`) } - await applySavedEntityPersistedState('custom_tool', tool.id, workspaceId, { + await applySavedEntityState('custom_tool', tool.id, { title: tool.title, schemaText: JSON.stringify(tool.schema, null, 2), codeText: tool.code, diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index d4285ca88..342e54774 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -9,7 +9,7 @@ import { import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' +import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' const logger = createLogger('IndicatorsOperations') @@ -109,7 +109,7 @@ export async function saveIndicator({ throw new Error(`Indicator ${indicator.id} was not found`) } - await applySavedEntityPersistedState('indicator', indicator.id, workspaceId, { + await applySavedEntityState('indicator', indicator.id, { name: indicator.name, color: existing.color ?? getStableVibrantColor(indicator.id), pineCode: indicator.pineCode, diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index cefe8c8e5..bc0349ee0 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -21,7 +21,7 @@ import type { } from '@/lib/knowledge/types' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/permissions/utils' -import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' +import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('KnowledgeBaseService') @@ -369,10 +369,9 @@ export async function applyKnowledgeBaseMetadata( throw new Error(`Knowledge base ${knowledgeBaseId} not found`) } - await applySavedEntityPersistedState( + await applySavedEntityState( ENTITY_KIND_KNOWLEDGE_BASE, knowledgeBaseId, - existing.workspaceId, fields ) diff --git a/apps/tradinggoose/lib/skills/operations.test.ts b/apps/tradinggoose/lib/skills/operations.test.ts index 57218b68b..1c1c504c9 100644 --- a/apps/tradinggoose/lib/skills/operations.test.ts +++ b/apps/tradinggoose/lib/skills/operations.test.ts @@ -30,7 +30,7 @@ vi.mock('nanoid', () => ({ })) vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ - applySavedEntityPersistedState: vi.fn(), + applySavedEntityState: vi.fn(), })) import { importSkills } from '@/lib/skills/operations' diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index cf55289e6..efb91108b 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -9,7 +9,7 @@ import { type SkillTransferRecord, } from '@/lib/skills/import-export' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityPersistedState } from '@/lib/yjs/server/apply-entity-state' +import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('SkillsOperations') @@ -151,7 +151,7 @@ export async function saveSkill({ throw new Error(`A skill with the name "${currentSkill.name}" already exists in this workspace`) } - await applySavedEntityPersistedState('skill', currentSkill.id, workspaceId, { + await applySavedEntityState('skill', currentSkill.id, { name: currentSkill.name, description: currentSkill.description, content: currentSkill.content, diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 4ec89955a..514503420 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -891,6 +891,54 @@ export async function saveWorkflowToNormalizedTables( } } +export async function saveWorkflowYjsDocToDb(workflowId: string, doc: Y.Doc): Promise { + const state = extractPersistedStateFromDoc(doc) + const entityName = doc.getMap('metadata').get('entityName') + const workflowName = typeof entityName === 'string' ? entityName.trim() : '' + const syncedAt = new Date() + const workflowState: WorkflowState = { + ...(state.direction !== undefined ? { direction: state.direction } : {}), + blocks: state.blocks, + edges: state.edges, + loops: state.loops, + parallels: state.parallels, + lastSaved: syncedAt.toISOString(), + isDeployed: state.isDeployed, + deployedAt: state.deployedAt, + } + + const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState, async (tx) => { + const [updatedWorkflow] = await tx + .update(workflow) + .set({ + lastSynced: syncedAt, + updatedAt: syncedAt, + ...(workflowName ? { name: workflowName } : {}), + variables: state.variables, + ...(state.isDeployed === undefined + ? {} + : { + isDeployed: state.isDeployed, + deployedAt: state.isDeployed + ? state.deployedAt + ? new Date(state.deployedAt) + : syncedAt + : null, + }), + }) + .where(eq(workflow.id, workflowId)) + .returning({ id: workflow.id }) + + if (!updatedWorkflow) { + throw new Error('Workflow not found') + } + }) + + if (!saveResult.success) { + throw new Error(saveResult.error || 'Failed to materialize workflow Yjs state') + } +} + /** * Deploy a workflow by creating a new deployment version */ diff --git a/apps/tradinggoose/lib/workflows/import.test.ts b/apps/tradinggoose/lib/workflows/import.test.ts index fa8f4414a..6dd764178 100644 --- a/apps/tradinggoose/lib/workflows/import.test.ts +++ b/apps/tradinggoose/lib/workflows/import.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { importWorkflowFromJsonContent } from '@/lib/workflows/import' describe('workflow import orchestration', () => { - it('creates the workflow row before persisting the imported state', async () => { + it('creates the workflow with imported state as creation-time initialization', async () => { const payload = { version: '1', fileType: 'tradingGooseExport', @@ -44,50 +44,34 @@ describe('workflow import orchestration', () => { name: string description: string workspaceId: string + initialWorkflowState: any }) => { callOrder.push('createWorkflow') expect(params).toMatchObject({ name: 'Primary Workflow (imported) 1', description: 'Workflow imported from the unified schema', workspaceId: 'workspace-1', + initialWorkflowState: { + edges: [], + loops: {}, + parallels: {}, + }, }) + expect(Object.keys(params.initialWorkflowState.blocks)).toHaveLength(1) return 'workflow-1' } ) - const persistWorkflowState = vi.fn(async (workflowId: string, state: unknown) => { - callOrder.push('persistWorkflowState') - expect(workflowId).toBe('workflow-1') - expect(state).toMatchObject({ - edges: [], - loops: {}, - parallels: {}, - variables: {}, - }) - - expect(Object.keys((state as { blocks: Record }).blocks)).toHaveLength(1) - - const [firstBlock] = Object.values( - (state as { blocks: Record }).blocks - ) - expect(firstBlock).toMatchObject({ - type: 'agent', - name: 'Agent 1', - }) - }) - const workflowId = await importWorkflowFromJsonContent({ content: JSON.stringify(payload), workspaceId: 'workspace-1', existingWorkflowNames: ['Primary Workflow'], createWorkflow, - persistWorkflowState, }) expect(workflowId).toBe('workflow-1') - expect(callOrder).toEqual(['createWorkflow', 'persistWorkflowState']) + expect(callOrder).toEqual(['createWorkflow']) expect(createWorkflow).toHaveBeenCalledTimes(1) - expect(persistWorkflowState).toHaveBeenCalledTimes(1) }) it('relinks imported skills into workflow blocks before persisting', async () => { @@ -182,64 +166,54 @@ describe('workflow import orchestration', () => { name: string description: string workspaceId: string + initialWorkflowState: any }) => { expect(params).toMatchObject({ name: 'Primary Workflow (imported) 1', description: 'Workflow imported from the unified schema', workspaceId: 'workspace-1', }) - return 'workflow-1' - } - ) - const persistWorkflowState = vi.fn(async (workflowId: string, state: unknown) => { - expect(workflowId).toBe('workflow-1') + const workflowState = params.initialWorkflowState as { + variables: Record + blocks: Record< + string, + { + subBlocks?: Record< + string, + { + value?: Array<{ skillId: string; name: string }> + } + > + } + > + } + + expect(workflowState.variables).toEqual({ + 'var-1': { + id: 'var-1', + workflowId: 'workflow-source', + name: 'risk', + type: 'plain', + value: 'medium', + }, + }) + + const [firstBlock] = Object.values(workflowState.blocks) - const workflowState = state as { - variables: Record - blocks: Record< - string, + expect(firstBlock?.subBlocks?.skills?.value).toEqual([ + { + skillId: 'skill-1', + name: 'Market Research (imported) 1', + }, { - subBlocks?: Record< - string, - { - value?: Array<{ skillId: string; name: string }> - } - > - } - > + skillId: 'skill-2', + name: 'Execution Plan', + }, + ]) + return 'workflow-1' } - - const [remappedVariable] = Object.values(workflowState.variables) as Array<{ - id: string - workflowId: string - name: string - type: string - value: string - }> - expect(Object.keys(workflowState.variables)).toHaveLength(1) - expect(workflowState.variables[remappedVariable.id]).toBe(remappedVariable) - expect(remappedVariable).toMatchObject({ - workflowId: 'workflow-1', - name: 'risk', - type: 'plain', - value: 'medium', - }) - expect(remappedVariable.id).not.toBe('var-1') - - const [firstBlock] = Object.values(workflowState.blocks) - - expect(firstBlock?.subBlocks?.skills?.value).toEqual([ - { - skillId: 'skill-1', - name: 'Market Research (imported) 1', - }, - { - skillId: 'skill-2', - name: 'Execution Plan', - }, - ]) - }) + ) const workflowId = await importWorkflowFromJsonContent({ content: JSON.stringify(payload), @@ -247,11 +221,9 @@ describe('workflow import orchestration', () => { existingWorkflowNames: ['Primary Workflow'], importedSkillsBySourceName, createWorkflow, - persistWorkflowState, }) expect(workflowId).toBe('workflow-1') expect(createWorkflow).toHaveBeenCalledTimes(1) - expect(persistWorkflowState).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/tradinggoose/lib/workflows/import.ts b/apps/tradinggoose/lib/workflows/import.ts index c90da1d1d..fc4f73505 100644 --- a/apps/tradinggoose/lib/workflows/import.ts +++ b/apps/tradinggoose/lib/workflows/import.ts @@ -1,6 +1,5 @@ import { createLogger } from '@/lib/logs/console/logger' import { - remapVariableIds, resolveImportedWorkflowName, type WorkflowTransferRecord, } from '@/lib/workflows/import-export' @@ -19,6 +18,7 @@ type CreateWorkflowParams = { name: string description: string workspaceId: string + initialWorkflowState: ImportedWorkflowState } type ImportWorkflowFromJsonContentParams = { @@ -27,7 +27,6 @@ type ImportWorkflowFromJsonContentParams = { existingWorkflowNames: Iterable importedSkillsBySourceName?: Map createWorkflow: (params: CreateWorkflowParams) => Promise - persistWorkflowState: (workflowId: string, state: ImportedWorkflowState) => Promise } function relinkWorkflowSkillValues( @@ -95,7 +94,6 @@ export async function importWorkflowFromJsonContent({ existingWorkflowNames, importedSkillsBySourceName, createWorkflow, - persistWorkflowState, }: ImportWorkflowFromJsonContentParams): Promise { if (!workspaceId) { throw new Error('Workspace ID is required to import workflows') @@ -126,6 +124,7 @@ export async function importWorkflowFromJsonContent({ name: resolvedName, description: workflowData.description, workspaceId, + initialWorkflowState: workflowData.state, }) logger.info('Created workflow row for imported workflow', { @@ -133,14 +132,5 @@ export async function importWorkflowFromJsonContent({ workflowName: resolvedName, }) - await persistWorkflowState(workflowId, { - ...workflowData.state, - variables: remapVariableIds(workflowData.state.variables, workflowId), - }) - - logger.info('Persisted imported workflow state', { - workflowId, - }) - return workflowId } diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts index fa6c3da7d..d2dcfabda 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts @@ -9,7 +9,6 @@ const { events, mockApplyEntityStateInSocketServer, mockDbUpdate, - mockGetYjsSnapshot, mockUpdateReturning, mockUpdateSet, mockUpdateWhere, @@ -17,7 +16,6 @@ const { events: [] as string[], mockApplyEntityStateInSocketServer: vi.fn(), mockDbUpdate: vi.fn(), - mockGetYjsSnapshot: vi.fn(), mockUpdateReturning: vi.fn(), mockUpdateSet: vi.fn(), mockUpdateWhere: vi.fn(), @@ -51,42 +49,24 @@ vi.mock('@/lib/custom-tools/schema', () => ({ vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ applyEntityStateInSocketServer: mockApplyEntityStateInSocketServer, - getYjsSnapshot: mockGetYjsSnapshot, })) -function buildSkillSnapshotBase64(fields: { name: string; description: string; content: string }) { +function buildSkillDoc(fields: { name: string; description: string; content: string }) { const doc = new Y.Doc() - try { - const map = doc.getMap('fields') - map.set('name', fields.name) - map.set('description', fields.description) - map.set('content', fields.content) - return Buffer.from(Y.encodeStateAsUpdate(doc)).toString('base64') - } finally { - doc.destroy() - } + const map = doc.getMap('fields') + map.set('name', fields.name) + map.set('description', fields.description) + map.set('content', fields.content) + return doc } -describe('applySavedEntityPersistedState', () => { +describe('applySavedEntityState', () => { beforeEach(() => { vi.clearAllMocks() events.length = 0 mockApplyEntityStateInSocketServer.mockImplementation(async () => { events.push('yjs') }) - mockGetYjsSnapshot.mockImplementation(async () => { - events.push('snapshot') - return { - snapshotBase64: buildSkillSnapshotBase64({ - name: 'Yjs Skill', - description: 'Yjs description', - content: 'Use the Yjs document.', - }), - descriptor: {}, - runtime: {}, - touchedAt: Date.now(), - } - }) mockUpdateReturning.mockResolvedValue([{ id: 'skill-1' }]) mockUpdateWhere.mockReturnValue({ returning: mockUpdateReturning }) mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }) @@ -96,10 +76,10 @@ describe('applySavedEntityPersistedState', () => { }) }) - it('applies entity changes to Yjs before persisting the post-apply Yjs snapshot to DB', async () => { - const { applySavedEntityPersistedState } = await import('./apply-entity-state') + it('applies entity changes to the socket-owned Yjs session without app-side DB materialization', async () => { + const { applySavedEntityState } = await import('./apply-entity-state') - await applySavedEntityPersistedState('skill', 'skill-1', 'workspace-1', { + await applySavedEntityState('skill', 'skill-1', { name: 'Copilot Skill', description: 'Copilot description', content: 'Use the Copilot input.', @@ -110,53 +90,59 @@ describe('applySavedEntityPersistedState', () => { description: 'Copilot description', content: 'Use the Copilot input.', }) - expect(mockGetYjsSnapshot).toHaveBeenCalledWith( - 'skill-1', - expect.objectContaining({ - targetKind: 'entity', - sessionId: 'skill-1', - workspaceId: 'workspace-1', - entityKind: 'skill', - entityId: 'skill-1', - }) - ) - expect(mockUpdateSet).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'Yjs Skill', - description: 'Yjs description', - content: 'Use the Yjs document.', - }) - ) - expect(events).toEqual(['yjs', 'snapshot', 'db']) + expect(mockDbUpdate).not.toHaveBeenCalled() + expect(events).toEqual(['yjs']) }) - it('leaves saved-entity Yjs unchanged when materialization fails', async () => { - const { persistSavedEntityYjsState } = await import('./apply-entity-state') - mockUpdateReturning.mockResolvedValueOnce([]) + it('materializes saved-entity DB state from a provided Yjs document', async () => { + const { saveSavedEntityYjsDocToDb } = await import('./apply-entity-state') + const doc = buildSkillDoc({ + name: 'Yjs Skill', + description: 'Yjs description', + content: 'Use the Yjs document.', + }) - await expect( - persistSavedEntityYjsState('skill', 'skill-1', 'workspace-1') - ).rejects.toMatchObject({ - status: 404, + try { + await saveSavedEntityYjsDocToDb('skill', 'skill-1', doc) + } finally { + doc.destroy() + } + + expect(mockUpdateSet).toHaveBeenCalledWith({ + name: 'Yjs Skill', + description: 'Yjs description', + content: 'Use the Yjs document.', + updatedAt: expect.any(Date), }) + expect(events).toEqual(['db']) + }) - expect(mockApplyEntityStateInSocketServer).not.toHaveBeenCalled() - expect(events).toEqual(['snapshot', 'db']) + it('throws when document materialization cannot find the saved entity row', async () => { + const { saveSavedEntityYjsDocToDb } = await import('./apply-entity-state') + const doc = buildSkillDoc({ name: 'Yjs Skill', description: '', content: '' }) + mockUpdateReturning.mockResolvedValueOnce([]) + + try { + await expect(saveSavedEntityYjsDocToDb('skill', 'skill-1', doc)).rejects.toMatchObject({ + status: 404, + }) + } finally { + doc.destroy() + } }) it('does not materialize DB state when the saved-entity Yjs apply fails', async () => { - const { applySavedEntityPersistedState } = await import('./apply-entity-state') + const { applySavedEntityState } = await import('./apply-entity-state') mockApplyEntityStateInSocketServer.mockRejectedValueOnce(new TypeError('fetch failed')) await expect( - applySavedEntityPersistedState('skill', 'skill-1', 'workspace-1', { + applySavedEntityState('skill', 'skill-1', { name: 'Copilot Skill', description: 'Copilot description', content: 'Use the Copilot input.', }) ).rejects.toThrow('fetch failed') - expect(mockGetYjsSnapshot).not.toHaveBeenCalled() expect(mockDbUpdate).not.toHaveBeenCalled() expect(events).toEqual([]) }) diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index 9ea52238e..c72808a95 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -7,16 +7,12 @@ import { skill, } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' -import * as Y from 'yjs' +import type * as Y from 'yjs' import { normalizeEntityFields } from '@/lib/copilot/entity-documents' -import { - buildYjsTransportEnvelope, - serializeYjsTransportEnvelope, -} from '@/lib/copilot/review-sessions/identity' import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' import { getEntityFields } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' -import { applyEntityStateInSocketServer, getYjsSnapshot } from '@/lib/yjs/server/snapshot-bridge' +import { applyEntityStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' export class SavedEntityPersistenceError extends Error { constructor( @@ -136,60 +132,20 @@ async function persistSavedEntityState( } } -async function readAppliedYjsEntityFields( - entityKind: SavedEntityKind, - entityId: string, - workspaceId: string -): Promise> { - const snapshot = await getYjsSnapshot( - entityId, - serializeYjsTransportEnvelope( - buildYjsTransportEnvelope({ - workspaceId, - entityKind, - entityId, - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: entityId, - }) - ) - ) - if (!snapshot.snapshotBase64) { - throw new SavedEntityPersistenceError( - 404, - `Saved ${entityKind} ${entityId} Yjs state is missing` - ) - } - - const doc = new Y.Doc() - try { - Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) - return getEntityFields(doc, entityKind) - } finally { - doc.destroy() - } -} - -export async function applySavedEntityPersistedState( +export async function applySavedEntityState( entityKind: SavedEntityKind, entityId: string, - workspaceId: string, fields: Record ): Promise { const normalizedFields = normalizeSavedEntityFields(entityKind, fields) - // Existing saved-entity mutations must enter the live Yjs session before DB materialization. await applyEntityStateInSocketServer(entityId, entityKind, normalizedFields) - await persistSavedEntityYjsState(entityKind, entityId, workspaceId) } -export async function persistSavedEntityYjsState( +export async function saveSavedEntityYjsDocToDb( entityKind: SavedEntityKind, entityId: string, - workspaceId: string + doc: Y.Doc ): Promise { - const yjsFields = normalizeSavedEntityFields( - entityKind, - await readAppliedYjsEntityFields(entityKind, entityId, workspaceId) - ) + const yjsFields = normalizeSavedEntityFields(entityKind, getEntityFields(doc, entityKind)) await persistSavedEntityState(entityKind, entityId, yjsFields) } diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts index 5ab16b2ee..ae0f02304 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts @@ -3,17 +3,17 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import * as Y from 'yjs' -import { replaceWorkflowDocumentState } from '@/lib/yjs/workflow-session' const { mockApplyWorkflowEntityNameInSocketServer, mockApplyWorkflowStateInSocketServer, mockDbUpdate, + mockDbSelect, mockEnsureUniqueBlockIds, mockEnsureUniqueEdgeIds, - mockGetYjsSnapshot, - mockSaveWorkflowToNormalizedTables, + mockSelectFrom, + mockSelectLimit, + mockSelectWhere, mockUpdateReturning, mockUpdateSet, mockUpdateWhere, @@ -22,10 +22,12 @@ const { mockApplyWorkflowEntityNameInSocketServer: vi.fn(), mockApplyWorkflowStateInSocketServer: vi.fn(), mockDbUpdate: vi.fn(), + mockDbSelect: vi.fn(), mockEnsureUniqueBlockIds: vi.fn(), mockEnsureUniqueEdgeIds: vi.fn(), - mockGetYjsSnapshot: vi.fn(), - mockSaveWorkflowToNormalizedTables: vi.fn(), + mockSelectFrom: vi.fn(), + mockSelectLimit: vi.fn(), + mockSelectWhere: vi.fn(), mockUpdateReturning: vi.fn(), mockUpdateSet: vi.fn(), mockUpdateWhere: vi.fn(), @@ -34,6 +36,7 @@ const { vi.mock('@tradinggoose/db', () => ({ db: { + select: mockDbSelect, update: mockDbUpdate, }, workflow: { @@ -48,30 +51,15 @@ vi.mock('drizzle-orm', () => ({ vi.mock('@/lib/workflows/db-helpers', () => ({ ensureUniqueBlockIds: mockEnsureUniqueBlockIds, ensureUniqueEdgeIds: mockEnsureUniqueEdgeIds, - saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, })) vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ applyWorkflowEntityNameInSocketServer: mockApplyWorkflowEntityNameInSocketServer, applyWorkflowStateInSocketServer: mockApplyWorkflowStateInSocketServer, - getYjsSnapshot: mockGetYjsSnapshot, })) const emptyWorkflowState = { blocks: {}, edges: [], loops: {}, parallels: {} } -function buildWorkflowSnapshotBase64( - workflowState: Parameters[1], - variables: Record = {} -): string { - const doc = new Y.Doc() - try { - replaceWorkflowDocumentState(doc, workflowState, variables, 'Workflow Name') - return Buffer.from(Y.encodeStateAsUpdate(doc)).toString('base64') - } finally { - doc.destroy() - } -} - describe('applyWorkflowState', () => { beforeEach(() => { vi.clearAllMocks() @@ -79,44 +67,34 @@ describe('applyWorkflowState', () => { mockApplyWorkflowStateInSocketServer.mockResolvedValue(undefined) mockEnsureUniqueBlockIds.mockImplementation(async (_workflowId, state) => state) mockEnsureUniqueEdgeIds.mockImplementation(async (_workflowId, state) => state) - mockSaveWorkflowToNormalizedTables.mockImplementation(async (_workflowId, state, commit) => { - await commit?.({ update: mockDbUpdate }, state) - return { success: true } - }) - mockGetYjsSnapshot.mockImplementation(async () => ({ - snapshotBase64: buildWorkflowSnapshotBase64(emptyWorkflowState), - })) mockUpdateReturning.mockResolvedValue([{ id: 'workflow-1' }]) mockUpdateWhere.mockReturnValue({ returning: mockUpdateReturning }) mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }) mockDbUpdate.mockReturnValue({ set: mockUpdateSet }) + mockSelectLimit.mockResolvedValue([{ id: 'workflow-1', name: 'Renamed Workflow' }]) + mockSelectWhere.mockReturnValue({ limit: mockSelectLimit }) + mockSelectFrom.mockReturnValue({ where: mockSelectWhere }) + mockDbSelect.mockReturnValue({ from: mockSelectFrom }) }) - it('renames workflow entity metadata before non-blocking Yjs name sync', async () => { - mockApplyWorkflowEntityNameInSocketServer.mockRejectedValueOnce(new Error('socket offline')) + it('renames workflow entity metadata through the socket-owned Yjs document', async () => { const { applyWorkflowEntityName } = await import('./apply-workflow-state') - await applyWorkflowEntityName('workflow-1', 'Renamed Workflow', { - description: 'Updated description', - }) + const updatedWorkflow = await applyWorkflowEntityName('workflow-1', 'Renamed Workflow') expect(mockApplyWorkflowEntityNameInSocketServer).toHaveBeenCalledWith( 'workflow-1', 'Renamed Workflow' ) expect(mockApplyWorkflowStateInSocketServer).not.toHaveBeenCalled() - expect(mockUpdateSet).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'Renamed Workflow', - description: 'Updated description', - }) - ) - expect(mockDbUpdate.mock.invocationCallOrder[0]).toBeLessThan( + expect(mockDbUpdate).not.toHaveBeenCalled() + expect(mockDbSelect.mock.invocationCallOrder[0]).toBeGreaterThan( mockApplyWorkflowEntityNameInSocketServer.mock.invocationCallOrder[0] ) + expect(updatedWorkflow).toMatchObject({ id: 'workflow-1', name: 'Renamed Workflow' }) }) - it('persists the applied Yjs workflow state after publishing to Yjs', async () => { + it('publishes normalized workflow state to the socket-owned Yjs document', async () => { mockEnsureUniqueBlockIds.mockImplementationOnce(async () => ({ blocks: { 'normalized-block': { @@ -133,29 +111,6 @@ describe('applyWorkflowState', () => { loops: {}, parallels: {}, })) - mockGetYjsSnapshot.mockResolvedValueOnce({ - snapshotBase64: buildWorkflowSnapshotBase64( - { - blocks: { - 'yjs-block': { - id: 'yjs-block', - type: 'agent', - name: 'Yjs Agent', - position: { x: 0, y: 0 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - isDeployed: true, - deployedAt: '2026-06-19T12:00:00.000Z', - }, - { apiKey: { id: 'apiKey', value: 'from-yjs' } } - ), - }) const { applyWorkflowState } = await import('./apply-workflow-state') @@ -191,40 +146,7 @@ describe('applyWorkflowState', () => { {}, 'Workflow Name' ) - - expect(mockGetYjsSnapshot).toHaveBeenCalledWith( - 'workflow-1', - expect.objectContaining({ - targetKind: 'workflow', - sessionId: 'workflow-1', - workflowId: 'workflow-1', - entityKind: 'workflow', - entityId: 'workflow-1', - }) - ) - expect(mockSaveWorkflowToNormalizedTables).toHaveBeenCalledOnce() - expect(mockSaveWorkflowToNormalizedTables.mock.calls[0][1]).toMatchObject({ - blocks: { - 'yjs-block': expect.objectContaining({ id: 'yjs-block' }), - }, - }) - expect(mockSaveWorkflowToNormalizedTables.mock.calls[0][2]).toEqual(expect.any(Function)) - expect(mockApplyWorkflowStateInSocketServer.mock.invocationCallOrder[0]).toBeLessThan( - mockGetYjsSnapshot.mock.invocationCallOrder[0] - ) - expect(mockGetYjsSnapshot.mock.invocationCallOrder[0]).toBeLessThan( - mockSaveWorkflowToNormalizedTables.mock.invocationCallOrder[0] - ) - expect(mockSaveWorkflowToNormalizedTables.mock.invocationCallOrder[0]).toBeLessThan( - mockDbUpdate.mock.invocationCallOrder[0] - ) - expect(mockUpdateSet).toHaveBeenCalledWith( - expect.objectContaining({ - variables: { apiKey: { id: 'apiKey', value: 'from-yjs' } }, - isDeployed: true, - deployedAt: new Date('2026-06-19T12:00:00.000Z'), - }) - ) + expect(mockDbUpdate).not.toHaveBeenCalled() }) it('does not commit workflow DB changes when the Yjs socket apply fails', async () => { @@ -236,25 +158,6 @@ describe('applyWorkflowState', () => { 'fetch failed' ) - expect(mockSaveWorkflowToNormalizedTables).not.toHaveBeenCalled() - expect(mockGetYjsSnapshot).not.toHaveBeenCalled() - expect(mockDbUpdate).not.toHaveBeenCalled() - }) - - it('leaves workflow Yjs unchanged when materialization fails after Yjs apply', async () => { - mockSaveWorkflowToNormalizedTables.mockResolvedValueOnce({ - success: false, - error: 'db failed', - }) - - const { applyWorkflowState } = await import('./apply-workflow-state') - - await expect(applyWorkflowState('workflow-1', emptyWorkflowState, {})).rejects.toThrow( - 'db failed' - ) - - expect(mockApplyWorkflowStateInSocketServer).toHaveBeenCalledOnce() - expect(mockGetYjsSnapshot).toHaveBeenCalledOnce() expect(mockDbUpdate).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index ef414ee67..602cfc264 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -1,69 +1,11 @@ import { db, workflow } from '@tradinggoose/db' import { eq } from 'drizzle-orm' -import * as Y from 'yjs' -import { - buildYjsTransportEnvelope, - serializeYjsTransportEnvelope, -} from '@/lib/copilot/review-sessions/identity' -import { - ensureUniqueBlockIds, - ensureUniqueEdgeIds, - saveWorkflowToNormalizedTables, -} from '@/lib/workflows/db-helpers' +import { ensureUniqueBlockIds, ensureUniqueEdgeIds } from '@/lib/workflows/db-helpers' import { applyWorkflowEntityNameInSocketServer, applyWorkflowStateInSocketServer, - getYjsSnapshot, } from '@/lib/yjs/server/snapshot-bridge' -import { - createWorkflowSnapshot, - extractPersistedStateFromDoc, - type WorkflowSnapshot, -} from '@/lib/yjs/workflow-session' - -async function readAppliedYjsWorkflowState(workflowId: string): Promise<{ - workflowState: WorkflowSnapshot - variables: Record -}> { - const snapshot = await getYjsSnapshot( - workflowId, - serializeYjsTransportEnvelope( - buildYjsTransportEnvelope({ - workspaceId: null, - entityKind: 'workflow', - entityId: workflowId, - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: workflowId, - }) - ) - ) - - if (!snapshot.snapshotBase64) { - throw new Error(`Workflow ${workflowId} Yjs state is missing`) - } - - const doc = new Y.Doc() - try { - Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) - const state = extractPersistedStateFromDoc(doc) - return { - workflowState: createWorkflowSnapshot({ - ...(state.direction !== undefined ? { direction: state.direction } : {}), - blocks: state.blocks, - edges: state.edges, - loops: state.loops, - parallels: state.parallels, - lastSaved: new Date(state.lastSaved).toISOString(), - isDeployed: state.isDeployed, - deployedAt: state.deployedAt, - }), - variables: state.variables, - } - } finally { - doc.destroy() - } -} +import { createWorkflowSnapshot, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' export async function applyWorkflowState( workflowId: string, @@ -91,59 +33,22 @@ export async function applyWorkflowState( }) await applyWorkflowStateInSocketServer(workflowId, storedWorkflowState, variables, entityName) - - const appliedState = await readAppliedYjsWorkflowState(workflowId) - const { deployedAt: appliedDeployedAt, isDeployed } = appliedState.workflowState - const saveResult = await saveWorkflowToNormalizedTables( - workflowId, - appliedState.workflowState, - async (tx) => { - const [updatedWorkflow] = await tx - .update(workflow) - .set({ - lastSynced: syncedAt, - updatedAt: syncedAt, - variables: appliedState.variables, - ...(isDeployed === undefined - ? {} - : { - isDeployed, - deployedAt: isDeployed - ? appliedDeployedAt - ? new Date(appliedDeployedAt) - : syncedAt - : null, - }), - }) - .where(eq(workflow.id, workflowId)) - .returning({ id: workflow.id }) - - if (!updatedWorkflow) { - throw new Error('Workflow not found') - } - } - ) - if (!saveResult.success) { - throw new Error(saveResult.error || 'Failed to materialize workflow state') - } } export async function applyWorkflowEntityName( workflowId: string, - entityName: string, - fields: Partial = {} + entityName: string ): Promise { + await applyWorkflowEntityNameInSocketServer(workflowId, entityName) + const [updatedWorkflow] = await db - .update(workflow) - .set({ ...fields, name: entityName, updatedAt: fields.updatedAt ?? new Date() }) + .select() + .from(workflow) .where(eq(workflow.id, workflowId)) - .returning() - + .limit(1) if (!updatedWorkflow) { throw new Error('Workflow not found') } - await applyWorkflowEntityNameInSocketServer(workflowId, entityName).catch(() => undefined) - return updatedWorkflow } diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 37cc3fd12..8901bbc2b 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -153,6 +153,11 @@ export async function createSavedReviewTargetBootstrapUpdate( const metadata = getMetadataMap(doc) metadata.set('bootstrap-touch', Date.now()) + metadata.set('entityKind', descriptor.entityKind) + metadata.set('entityId', descriptor.entityId) + metadata.set('workspaceId', descriptor.workspaceId) + metadata.set('draftSessionId', descriptor.draftSessionId) + metadata.set('reviewSessionId', descriptor.reviewSessionId) metadata.set('reseededFromCanonical', true) if (workflowName) { metadata.set('entityName', workflowName) diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 748c39bc3..6a3ce1eff 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -18,6 +18,22 @@ import { getExistingDocument, } from '@/socket-server/yjs/upstream-utils' +const { + mockSaveSavedEntityYjsDocToDb, + mockSaveWorkflowYjsDocToDb, + savedEntityStates, + savedWorkflowStates, +} = vi.hoisted(() => ({ + mockSaveSavedEntityYjsDocToDb: vi.fn(), + mockSaveWorkflowYjsDocToDb: vi.fn(), + savedEntityStates: [] as Array<{ + entityKind: string + entityId: string + fields: Record + }>, + savedWorkflowStates: [] as Array>, +})) + vi.mock(import('@/lib/env'), async (importOriginal) => { const actual = await importOriginal() return { @@ -36,6 +52,22 @@ vi.mock('@/lib/redis', () => ({ getRedisStorageMode: vi.fn(() => 'local'), })) +vi.mock('@/lib/workflows/db-helpers', () => ({ + saveWorkflowYjsDocToDb: mockSaveWorkflowYjsDocToDb, +})) + +vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ + SavedEntityPersistenceError: class SavedEntityPersistenceError extends Error { + constructor( + public status: number, + message: string + ) { + super(message) + } + }, + saveSavedEntityYjsDocToDb: mockSaveSavedEntityYjsDocToDb, +})) + vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ createSavedReviewTargetBootstrapUpdate: vi.fn(async (descriptor) => ({ descriptor, @@ -185,6 +217,18 @@ describe('Socket Server Index Integration', () => { beforeEach(async () => { cleanupAllDocuments() + savedWorkflowStates.length = 0 + savedEntityStates.length = 0 + mockSaveWorkflowYjsDocToDb.mockImplementation(async (_workflowId, doc) => { + savedWorkflowStates.push(extractPersistedStateFromDoc(doc)) + }) + mockSaveSavedEntityYjsDocToDb.mockImplementation(async (entityKind, entityId, doc) => { + savedEntityStates.push({ + entityKind, + entityId, + fields: getEntityFields(doc, entityKind), + }) + }) // Create HTTP server httpServer = createServer() @@ -312,31 +356,21 @@ describe('Socket Server Index Integration', () => { ) expect(response.statusCode).toBe(200) - expect(await getExistingDocument('workflow-1')).toBeTruthy() - - const persisted = await getExistingDocument('workflow-1') - expect(persisted).toBeTruthy() - - const doc = new Y.Doc() - try { - Y.applyUpdate(doc, Y.encodeStateAsUpdate(persisted!)) - const state = extractPersistedStateFromDoc(doc) - expect(state.blocks['block-1']).toEqual( - expect.objectContaining({ - id: 'block-1', - name: 'Applied Agent', - }) - ) - expect(state.variables.var1).toEqual( - expect.objectContaining({ - id: 'var1', - name: 'token', - value: 'secret', - }) - ) - } finally { - doc.destroy() - } + expect(mockSaveWorkflowYjsDocToDb).toHaveBeenCalledWith('workflow-1', expect.any(Y.Doc)) + expect(await getExistingDocument('workflow-1')).toBeNull() + expect(savedWorkflowStates[0]?.blocks['block-1']).toEqual( + expect.objectContaining({ + id: 'block-1', + name: 'Applied Agent', + }) + ) + expect(savedWorkflowStates[0]?.variables.var1).toEqual( + expect.objectContaining({ + id: 'var1', + name: 'token', + value: 'secret', + }) + ) const renameResponse = await sendHttpRequestWithOptions( PORT, @@ -352,23 +386,8 @@ describe('Socket Server Index Integration', () => { ) expect(renameResponse.statusCode).toBe(200) - const renamedPersisted = await getExistingDocument('workflow-1') - expect(renamedPersisted).toBeTruthy() - - const renamedDoc = new Y.Doc() - try { - Y.applyUpdate(renamedDoc, Y.encodeStateAsUpdate(renamedPersisted!)) - const renamedState = extractPersistedStateFromDoc(renamedDoc) - expect(renamedState.blocks['block-1']).toEqual( - expect.objectContaining({ - id: 'block-1', - name: 'Applied Agent', - }) - ) - expect(renamedDoc.getMap('metadata').get('entityName')).toBe('Renamed Workflow') - } finally { - renamedDoc.destroy() - } + expect(mockSaveWorkflowYjsDocToDb).toHaveBeenCalledTimes(2) + expect(await getExistingDocument('workflow-1')).toBeNull() }) it('should apply saved entity state through Yjs', async () => { @@ -393,23 +412,18 @@ describe('Socket Server Index Integration', () => { ) expect(response.statusCode).toBe(200) - expect(await getExistingDocument('skill-1')).toBeTruthy() - - const persisted = await getExistingDocument('skill-1') - expect(persisted).toBeTruthy() - - const doc = new Y.Doc() - try { - Y.applyUpdate(doc, Y.encodeStateAsUpdate(persisted!)) - expect(getEntityFields(doc, 'skill')).toEqual({ - name: 'Risk Skill', - description: 'Position sizing rules', - content: 'Keep risk below one percent.', - }) - expect(doc.getMap('metadata').get('reseededFromCanonical')).toBeUndefined() - } finally { - doc.destroy() - } + expect(savedEntityStates).toEqual([ + { + entityKind: 'skill', + entityId: 'skill-1', + fields: { + name: 'Risk Skill', + description: 'Position sizing rules', + content: 'Keep risk below one percent.', + }, + }, + ]) + expect(await getExistingDocument('skill-1')).toBeNull() }) it('should return the internal Yjs workflow snapshot through the generic session route', async () => { @@ -499,7 +513,7 @@ describe('Socket Server Index Integration', () => { ) expect(response.statusCode).toBe(200) - expect(await getExistingDocument('missing-workflow')).toBeTruthy() + expect(await getExistingDocument('missing-workflow')).toBeNull() }) it('should bootstrap a saved entity snapshot into a live Yjs document', async () => { @@ -515,7 +529,7 @@ describe('Socket Server Index Integration', () => { ) expect(response.statusCode).toBe(200) - expect(await getExistingDocument('skill-stale')).toBeTruthy() + expect(await getExistingDocument('skill-stale')).toBeNull() }) }) diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index c39b34ba8..341e6cae2 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -6,7 +6,12 @@ import { } from '@/lib/copilot/review-sessions/identity' import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' import { env } from '@/lib/env' +import { saveWorkflowYjsDocToDb } from '@/lib/workflows/db-helpers' import { seedEntitySession } from '@/lib/yjs/entity-session' +import { + SavedEntityPersistenceError, + saveSavedEntityYjsDocToDb, +} from '@/lib/yjs/server/apply-entity-state' import { createSavedReviewTargetBootstrapUpdate, getRuntimeStateFromDoc, @@ -20,6 +25,7 @@ import { import { getMonitorRuntimeLockHealth } from '@/socket-server/monitor-runtime-lock' import { discardDocument, + discardDocumentIfIdle, getDocument, getExistingDocument, } from '@/socket-server/yjs/upstream-utils' @@ -283,6 +289,8 @@ async function handleInternalYjsWorkflowApplyRequest( } else { setWorkflowEntityName(doc, body.entityName!) } + await saveWorkflowYjsDocToDb(workflowId, doc) + discardDocumentIfIdle(workflowId) sendJson(res, 200, { success: true }) } catch (error) { logger.error('Error applying workflow state', { error, workflowId }) @@ -315,11 +323,18 @@ async function handleInternalYjsEntityApplyRequest( payload: body.fields, }) clearSessionReseededFromCanonical(doc) + await saveSavedEntityYjsDocToDb(body.entityKind, entityId, doc) + discardDocumentIfIdle(entityId) sendJson(res, 200, { success: true }) } catch (error) { logger.error('Error applying entity state', { error, entityId }) - const status = error instanceof InvalidInternalYjsRequestError ? 400 : 500 + const status = + error instanceof InvalidInternalYjsRequestError + ? 400 + : error instanceof SavedEntityPersistenceError + ? error.status + : 500 sendJson(res, status, { error: error instanceof Error ? error.message : 'Failed to apply entity state', }) @@ -353,11 +368,20 @@ async function handleInternalYjsSessionApplyUpdateRequest( Y.applyUpdate(doc, Buffer.from(updateBase64, 'base64'), YJS_ORIGINS.SAVE) clearSessionReseededFromCanonical(doc) + if (descriptor.entityKind !== 'workflow' && descriptor.entityId) { + await saveSavedEntityYjsDocToDb(descriptor.entityKind, descriptor.entityId, doc) + discardDocumentIfIdle(sessionId) + } sendJson(res, 200, { success: true }) } catch (error) { logger.error('Error applying Yjs session update', { error, path: parsedUrl.pathname }) - const status = error instanceof InvalidInternalYjsRequestError ? 400 : 500 + const status = + error instanceof InvalidInternalYjsRequestError + ? 400 + : error instanceof SavedEntityPersistenceError + ? error.status + : 500 sendJson(res, status, { error: error instanceof Error ? error.message : 'Failed to apply session update', }) @@ -379,6 +403,7 @@ async function handleInternalYjsSnapshotRequest( const descriptor = buildReviewTargetDescriptorFromEnvelope(envelope) let liveDoc = await getExistingDocument(sessionId) + let bootstrappedForRequest = false if (!liveDoc && descriptor.entityId) { const bootstrapped = await createSavedReviewTargetBootstrapUpdate(descriptor) if (!bootstrapped.runtime || bootstrapped.runtime.docState !== 'active') { @@ -386,6 +411,7 @@ async function handleInternalYjsSnapshotRequest( return } liveDoc = await getInitializedSessionDocument(sessionId, bootstrapped.state) + bootstrappedForRequest = true } if (!liveDoc) { @@ -401,6 +427,9 @@ async function handleInternalYjsSnapshotRequest( runtime: getRuntimeStateFromDoc(liveDoc), touchedAt: null, }) + if (bootstrappedForRequest) { + discardDocumentIfIdle(sessionId) + } } catch (error) { logger.error('Error getting Yjs snapshot', { error, path: parsedUrl.pathname }) sendJson(res, 400, { error: 'Failed to get snapshot' }) diff --git a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts index 736924a81..b22946305 100644 --- a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts +++ b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts @@ -25,12 +25,14 @@ const wsReadyStateOpen = 1 const PING_TIMEOUT = 30_000 const docs = new Map() +type DocumentIdleHandler = (docId: string, doc: Y.Doc) => Promise | void class WSSharedDoc extends Y.Doc { name: string conns: Map> awareness: awarenessProtocol.Awareness whenInitialized: Promise + onDocumentIdle?: DocumentIdleHandler constructor(name: string, gc: boolean) { super({ gc }) @@ -88,7 +90,20 @@ function cleanupDocument(doc: WSSharedDoc): void { } function finalizeDocumentCleanup(doc: WSSharedDoc): void { - cleanupDocument(doc) + if (!doc.onDocumentIdle) { + cleanupDocument(doc) + return + } + + void Promise.resolve(doc.onDocumentIdle(doc.name, doc)) + .then(() => { + if (doc.conns.size === 0) { + cleanupDocument(doc) + } + }) + .catch((error) => { + console.error('[yjs upstream-utils] Failed to persist idle document', error) + }) } function send(doc: WSSharedDoc, conn: WebSocket, message: Uint8Array): void { @@ -186,13 +201,15 @@ export function setupWSConnection( docId: string gc?: boolean bootstrapState?: Uint8Array + onDocumentIdle?: DocumentIdleHandler } ): void { - const { docId, gc = true, bootstrapState } = opts + const { docId, gc = true, bootstrapState, onDocumentIdle } = opts conn.binaryType = 'arraybuffer' const doc = getDocument(docId, gc, bootstrapState) as WSSharedDoc + doc.onDocumentIdle = onDocumentIdle doc.conns.set(conn, new Set()) conn.on('message', (data: ArrayBuffer) => { @@ -287,6 +304,15 @@ export function discardDocument(docId: string): void { }) } +export function discardDocumentIfIdle(docId: string): void { + const doc = docs.get(docId) + if (!doc || doc.conns.size > 0) { + return + } + + cleanupDocument(doc) +} + export function cleanupAllDocuments(): void { for (const docId of Array.from(docs.keys())) { removeDocument(docId) diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts index 64604a783..71e453b22 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts @@ -99,6 +99,14 @@ beforeEach(() => { })), })) + vi.doMock('@/lib/workflows/db-helpers', () => ({ + saveWorkflowYjsDocToDb: vi.fn(), + })) + + vi.doMock('@/lib/yjs/server/apply-entity-state', () => ({ + saveSavedEntityYjsDocToDb: vi.fn(), + })) + vi.doMock('./upstream-utils', () => ({ getExistingDocument: mockGetExistingDocument, setupWSConnection: mockSetupWSConnection, diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.ts index c5fb4dd17..fa13186ae 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.ts @@ -1,9 +1,13 @@ import type { IncomingMessage } from 'http' import type { Duplex } from 'stream' import type { WebSocket, WebSocketServer } from 'ws' +import type * as Y from 'yjs' import { buildReviewTargetDescriptorFromEnvelope } from '@/lib/copilot/review-sessions/identity' import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' import { createLogger } from '@/lib/logs/console/logger' +import { saveWorkflowYjsDocToDb } from '@/lib/workflows/db-helpers' +import type { SavedEntityKind } from '@/lib/yjs/entity-state' +import { saveSavedEntityYjsDocToDb } from '@/lib/yjs/server/apply-entity-state' import { createSavedReviewTargetBootstrapUpdate, getRuntimeStateFromDoc, @@ -12,6 +16,13 @@ import { authenticateYjsConnection, YjsAuthError } from './auth' import { getExistingDocument, setupWSConnection } from './upstream-utils' const logger = createLogger('YjsWsHandler') +const savedEntityKinds = new Set([ + 'skill', + 'custom_tool', + 'indicator', + 'knowledge_base', + 'mcp_server', +]) interface YjsIncomingMessage extends IncomingMessage { yjsSessionId?: string @@ -19,6 +30,26 @@ interface YjsIncomingMessage extends IncomingMessage { yjsBootstrapState?: Uint8Array } +async function persistIdleDocument(docId: string, doc: Y.Doc): Promise { + const metadata = doc.getMap('metadata') + const entityKind = metadata.get('entityKind') + if ( + metadata.get('entityId') !== docId || + metadata.get('draftSessionId') !== null || + metadata.get('reviewSessionId') !== null + ) { + return + } + + if (entityKind === 'workflow') { + await saveWorkflowYjsDocToDb(docId, doc) + return + } + if (typeof entityKind === 'string' && savedEntityKinds.has(entityKind as SavedEntityKind)) { + await saveSavedEntityYjsDocToDb(entityKind as SavedEntityKind, docId, doc) + } +} + export function handleYjsUpgrade( wss: WebSocketServer, request: IncomingMessage, @@ -141,7 +172,12 @@ function ensureConnectionHandler(wss: WebSocketServer): void { try { logger.info('Yjs connection established', { docId, userId: yjsReq.yjsUserId }) - setupWSConnection(ws, req, { docId, gc: true, bootstrapState: yjsReq.yjsBootstrapState }) + setupWSConnection(ws, req, { + docId, + gc: true, + bootstrapState: yjsReq.yjsBootstrapState, + onDocumentIdle: persistIdleDocument, + }) } catch (error) { logger.error('Failed to attach Yjs connection', { docId, error }) ws.close(4409, 'Failed to attach Yjs session') diff --git a/apps/tradinggoose/stores/workflows/registry/store.ts b/apps/tradinggoose/stores/workflows/registry/store.ts index 94166bcf0..7849cca05 100644 --- a/apps/tradinggoose/stores/workflows/registry/store.ts +++ b/apps/tradinggoose/stores/workflows/registry/store.ts @@ -886,6 +886,9 @@ export const useWorkflowRegistry = create()( description: options.description ?? 'New workflow', workspaceId, folderId: options.folderId || null, + ...(options.initialWorkflowState === undefined + ? {} + : { initialWorkflowState: options.initialWorkflowState }), } const response = await fetch('/api/workflows', { diff --git a/apps/tradinggoose/stores/workflows/registry/types.ts b/apps/tradinggoose/stores/workflows/registry/types.ts index d1214e0e8..855c3f6b3 100644 --- a/apps/tradinggoose/stores/workflows/registry/types.ts +++ b/apps/tradinggoose/stores/workflows/registry/types.ts @@ -75,6 +75,7 @@ export interface WorkflowRegistryActions { description?: string workspaceId?: string folderId?: string | null + initialWorkflowState?: any }) => Promise duplicateWorkflow: (sourceId: string) => Promise readWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts index 416aa3397..7660fbefc 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts @@ -14,40 +14,6 @@ interface AutoLayoutOptions { } } -function sanitizeEdgesForStateSave(edges: any[]): any[] { - return edges.flatMap((edge: any, index: number) => { - const source = typeof edge?.source === 'string' ? edge.source.trim() : '' - const target = typeof edge?.target === 'string' ? edge.target.trim() : '' - - if (!source || !target) { - return [] - } - - const sourceHandle = - typeof edge?.sourceHandle === 'string' && edge.sourceHandle.length > 0 - ? edge.sourceHandle - : undefined - const targetHandle = - typeof edge?.targetHandle === 'string' && edge.targetHandle.length > 0 - ? edge.targetHandle - : undefined - - return [ - { - ...edge, - id: - typeof edge?.id === 'string' && edge.id.length > 0 - ? edge.id - : `${source}-${sourceHandle || 'source'}-${target}-${targetHandle || 'target'}-${index}`, - source, - target, - ...(sourceHandle ? { sourceHandle } : {}), - ...(targetHandle ? { targetHandle } : {}), - }, - ] - }) -} - export async function applyAutoLayoutToWorkflow( workflowId: string, blocks: Record, @@ -156,9 +122,7 @@ export async function applyAutoLayoutAndUpdateStore({ try { const { getRegisteredWorkflowSession } = await import('@/lib/yjs/workflow-session-registry') - const { getVariablesSnapshot, readWorkflowSnapshot, readWorkflowMap } = await import( - '@/lib/yjs/workflow-session' - ) + const { readWorkflowSnapshot, readWorkflowMap } = await import('@/lib/yjs/workflow-session') const { YJS_ORIGINS } = await import('@/lib/yjs/transaction-origins') const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') @@ -229,81 +193,7 @@ export async function applyAutoLayoutAndUpdateStore({ workflowId: resolvedWorkflowId, channelId, }) - - try { - const updatedSnapshot = readWorkflowSnapshot(doc) - - const stateToSave = { - ...updatedSnapshot, - deploymentStatuses: undefined, - needsRedeployment: undefined, - dragStartPosition: undefined, - } - - const cleanedWorkflowState = { - ...stateToSave, - deployedAt: (stateToSave as any).deployedAt - ? new Date((stateToSave as any).deployedAt) - : undefined, - loops: stateToSave.loops || {}, - parallels: stateToSave.parallels || {}, - edges: sanitizeEdgesForStateSave(stateToSave.edges || []), - variables: getVariablesSnapshot(doc), - } - - const response = await fetch(`/api/workflows/${resolvedWorkflowId}/state`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(cleanedWorkflowState), - }) - - if (!response.ok) { - let errorMessage = `HTTP ${response.status}: ${response.statusText}` - try { - const errorData = await response.json() - const details = - typeof errorData?.details === 'string' - ? errorData.details - : JSON.stringify(errorData?.details || errorData) - errorMessage = errorData?.error - ? `${errorData.error}${details ? ` - ${details}` : ''}` - : errorMessage - } catch { - // Ignore JSON parse errors and fall back to generic message - } - - throw new Error(errorMessage) - } - - logger.info('Auto layout successfully persisted', { - workflowId: resolvedWorkflowId, - channelId, - }) - return { success: true } - } catch (saveError) { - const message = - saveError instanceof Error && saveError.message - ? saveError.message - : JSON.stringify(saveError) - logger.error('Failed to persist auto layout, reverting Yjs doc:', { - workflowId: resolvedWorkflowId, - error: message, - }) - - doc.transact(() => { - const wMap = readWorkflowMap(doc) - wMap.set('blocks', blocks) - }, YJS_ORIGINS.SYSTEM) - - return { - success: false, - error: `Failed to save positions: ${ - saveError instanceof Error ? saveError.message : 'Unknown error' - }`, - } - } + return { success: true } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown store update error' logger.error('Failed to update store with auto layout:', { diff --git a/apps/tradinggoose/widgets/widgets/list_workflow/components/workflow-create-menu.tsx b/apps/tradinggoose/widgets/widgets/list_workflow/components/workflow-create-menu.tsx index a4cdde533..d4b3b0626 100644 --- a/apps/tradinggoose/widgets/widgets/list_workflow/components/workflow-create-menu.tsx +++ b/apps/tradinggoose/widgets/widgets/list_workflow/components/workflow-create-menu.tsx @@ -114,21 +114,6 @@ export function DashboardWorkflowCreateMenu({ .filter((workflow) => workflow.workspaceId === workspaceId) .map((workflow) => workflow.name) - const persistWorkflowState = async (workflowId: string, state: unknown) => { - const response = await fetch(`/api/workflows/${workflowId}/state`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(state), - }) - - if (!response.ok) { - logger.error('Failed to persist imported workflow to database') - throw new Error('Failed to save workflow') - } - } - let importedSkillsBySourceName: | ReturnType | undefined @@ -151,7 +136,6 @@ export function DashboardWorkflowCreateMenu({ existingWorkflowNames, importedSkillsBySourceName, createWorkflow, - persistWorkflowState, }) logger.info('Workflow imported successfully from dashboard widget') From 9c6db3c1cb56b6061130557daac103e663bac29f Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 02:29:48 -0600 Subject: [PATCH 161/284] feat(mcp): rate limit public MCP auth endpoints Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/auth/mcp/poll/route.test.ts | 46 +++++++++++-- .../app/api/auth/mcp/poll/route.ts | 6 ++ .../app/api/auth/mcp/start/route.test.ts | 38 ++++++++++- .../app/api/auth/mcp/start/route.ts | 10 ++- apps/tradinggoose/lib/api/rate-limit.ts | 64 ++++++++++++++++++- .../services/queue/ExecutionLimiter.ts | 10 ++- 6 files changed, 156 insertions(+), 18 deletions(-) diff --git a/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts index 244d88705..45bf25a3b 100644 --- a/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts @@ -5,10 +5,16 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockPollMcpDeviceLogin } = vi.hoisted(() => ({ +const { mockCheckPublicApiEndpointRateLimit, mockPollMcpDeviceLogin } = vi.hoisted(() => ({ + mockCheckPublicApiEndpointRateLimit: vi.fn(), mockPollMcpDeviceLogin: vi.fn(), })) +vi.mock('@/lib/api/rate-limit', () => ({ + checkPublicApiEndpointRateLimit: (...args: unknown[]) => + mockCheckPublicApiEndpointRateLimit(...args), +})) + vi.mock('@/lib/mcp/auth', () => ({ pollMcpDeviceLogin: (...args: unknown[]) => mockPollMcpDeviceLogin(...args), })) @@ -16,6 +22,12 @@ vi.mock('@/lib/mcp/auth', () => ({ describe('MCP login poll route', () => { beforeEach(() => { vi.clearAllMocks() + mockCheckPublicApiEndpointRateLimit.mockResolvedValue({ + allowed: true, + remaining: 119, + resetAt: new Date('2026-06-19T12:01:00.000Z'), + limit: 120, + }) mockPollMcpDeviceLogin.mockResolvedValue({ status: 'approved', apiKey: 'sk-tradinggoose-token', @@ -25,13 +37,12 @@ describe('MCP login poll route', () => { it('polls the device login by code and verification key', async () => { const { POST } = await import('./route') + const request = new NextRequest('https://studio.example.test/api/auth/mcp/poll', { + method: 'POST', + body: JSON.stringify({ code: 'login-code', verificationKey: 'verification-key' }), + }) - const response = await POST( - new NextRequest('https://studio.example.test/api/auth/mcp/poll', { - method: 'POST', - body: JSON.stringify({ code: 'login-code', verificationKey: 'verification-key' }), - }) - ) + const response = await POST(request) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ @@ -39,6 +50,7 @@ describe('MCP login poll route', () => { apiKey: 'sk-tradinggoose-token', expiresAt: '2026-06-19T12:00:00.000Z', }) + expect(mockCheckPublicApiEndpointRateLimit).toHaveBeenCalledWith(request, 'mcp-auth-poll') expect(mockPollMcpDeviceLogin).toHaveBeenCalledWith('login-code', 'verification-key') }) @@ -55,4 +67,24 @@ describe('MCP login poll route', () => { expect(response.status).toBe(400) expect(mockPollMcpDeviceLogin).not.toHaveBeenCalled() }) + + it('rejects polls when the public endpoint rate limit is exhausted', async () => { + mockCheckPublicApiEndpointRateLimit.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date('2026-06-19T12:01:00.000Z'), + limit: 120, + }) + const { POST } = await import('./route') + + const response = await POST( + new NextRequest('https://studio.example.test/api/auth/mcp/poll', { + method: 'POST', + body: JSON.stringify({ code: 'login-code', verificationKey: 'verification-key' }), + }) + ) + + expect(response.status).toBe(429) + expect(mockPollMcpDeviceLogin).not.toHaveBeenCalled() + }) }) diff --git a/apps/tradinggoose/app/api/auth/mcp/poll/route.ts b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts index b71a00af1..aaf6a7fa3 100644 --- a/apps/tradinggoose/app/api/auth/mcp/poll/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { checkPublicApiEndpointRateLimit } from '@/lib/api/rate-limit' import { pollMcpDeviceLogin } from '@/lib/mcp/auth' export const dynamic = 'force-dynamic' @@ -12,6 +13,11 @@ const PollRequestSchema = z .strict() export async function POST(request: NextRequest) { + const rateLimit = await checkPublicApiEndpointRateLimit(request, 'mcp-auth-poll') + if (!rateLimit.allowed) { + return NextResponse.json({ error: rateLimit.error || 'Rate limit exceeded' }, { status: 429 }) + } + const parsed = PollRequestSchema.safeParse(await request.json().catch(() => null)) if (!parsed.success) { return NextResponse.json({ error: 'Invalid MCP login poll request' }, { status: 400 }) diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts index 022425eec..3b39ad89a 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts @@ -2,12 +2,19 @@ * @vitest-environment node */ +import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockStartMcpDeviceLogin } = vi.hoisted(() => ({ +const { mockCheckPublicApiEndpointRateLimit, mockStartMcpDeviceLogin } = vi.hoisted(() => ({ + mockCheckPublicApiEndpointRateLimit: vi.fn(), mockStartMcpDeviceLogin: vi.fn(), })) +vi.mock('@/lib/api/rate-limit', () => ({ + checkPublicApiEndpointRateLimit: (...args: unknown[]) => + mockCheckPublicApiEndpointRateLimit(...args), +})) + vi.mock('@/lib/mcp/auth', () => ({ startMcpDeviceLogin: (...args: unknown[]) => mockStartMcpDeviceLogin(...args), })) @@ -16,6 +23,12 @@ describe('MCP login start route', () => { beforeEach(() => { vi.clearAllMocks() vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://studio.example.test') + mockCheckPublicApiEndpointRateLimit.mockResolvedValue({ + allowed: true, + remaining: 19, + resetAt: new Date('2026-06-19T12:01:00.000Z'), + limit: 20, + }) mockStartMcpDeviceLogin.mockResolvedValue({ code: 'login-code', verificationKey: 'verification-key', @@ -30,8 +43,11 @@ describe('MCP login start route', () => { it('starts a browser approval login and returns an absolute approval URL', async () => { const { POST } = await import('./route') + const request = new NextRequest('https://studio.example.test/api/auth/mcp/start', { + method: 'POST', + }) - const response = await POST() + const response = await POST(request) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ @@ -41,6 +57,24 @@ describe('MCP login start route', () => { intervalSeconds: 2, authorizeUrl: 'https://studio.example.test/mcp/authorize?code=login-code', }) + expect(mockCheckPublicApiEndpointRateLimit).toHaveBeenCalledWith(request, 'mcp-auth-start') expect(mockStartMcpDeviceLogin).toHaveBeenCalledWith() }) + + it('rejects login starts when the public endpoint rate limit is exhausted', async () => { + mockCheckPublicApiEndpointRateLimit.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date('2026-06-19T12:01:00.000Z'), + limit: 20, + }) + const { POST } = await import('./route') + + const response = await POST( + new NextRequest('https://studio.example.test/api/auth/mcp/start', { method: 'POST' }) + ) + + expect(response.status).toBe(429) + expect(mockStartMcpDeviceLogin).not.toHaveBeenCalled() + }) }) diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.ts index 565a12da4..3ae560c4b 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.ts @@ -1,10 +1,16 @@ -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { checkPublicApiEndpointRateLimit } from '@/lib/api/rate-limit' import { startMcpDeviceLogin } from '@/lib/mcp/auth' import { getBaseUrl } from '@/lib/urls/utils' export const dynamic = 'force-dynamic' -export async function POST() { +export async function POST(request: NextRequest) { + const rateLimit = await checkPublicApiEndpointRateLimit(request, 'mcp-auth-start') + if (!rateLimit.allowed) { + return NextResponse.json({ error: rateLimit.error || 'Rate limit exceeded' }, { status: 429 }) + } + const baseUrl = getBaseUrl() const login = await startMcpDeviceLogin() const authorizeUrl = new URL('/mcp/authorize', baseUrl) diff --git a/apps/tradinggoose/lib/api/rate-limit.ts b/apps/tradinggoose/lib/api/rate-limit.ts index af88fcfd9..e7d46bc0e 100644 --- a/apps/tradinggoose/lib/api/rate-limit.ts +++ b/apps/tradinggoose/lib/api/rate-limit.ts @@ -1,5 +1,7 @@ +import { createHash } from 'node:crypto' import { getPersonalEffectiveSubscription } from '@/lib/billing/core/subscription' import { isBillingEnabledForRuntime } from '@/lib/billing/settings' +import type { BillingTierRecord } from '@/lib/billing/tiers' import { createLogger } from '@/lib/logs/console/logger' import { ExecutionLimiter } from '@/services/queue/ExecutionLimiter' @@ -16,7 +18,18 @@ export interface RateLimitResult { failureKind?: 'auth' | 'dependency' } -export type ApiRateLimitEndpoint = 'api-endpoint' | 'copilot-mcp' | 'logs' | 'logs-detail' +export type ApiRateLimitEndpoint = + | 'api-endpoint' + | 'copilot-mcp' + | 'logs' + | 'logs-detail' + | 'mcp-auth-start' + | 'mcp-auth-poll' + +const PUBLIC_API_ENDPOINT_LIMITS: Partial> = { + 'mcp-auth-start': 20, + 'mcp-auth-poll': 120, +} function getApiEndpointRateLimitScope(userId: string, endpoint: ApiRateLimitEndpoint) { return endpoint === 'api-endpoint' @@ -104,3 +117,52 @@ export async function checkApiEndpointRateLimit( } } } + +function getRequesterKey(request: Request): string { + const forwardedFor = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() + const requester = + request.headers.get('cf-connecting-ip')?.trim() || + request.headers.get('x-real-ip')?.trim() || + forwardedFor || + 'unknown' + const userAgent = request.headers.get('user-agent')?.trim() || 'unknown' + const host = request.headers.get('host')?.trim() || new URL(request.url).host + return createHash('sha256').update(`${host}\n${requester}\n${userAgent}`).digest('hex') +} + +export async function checkPublicApiEndpointRateLimit( + request: Request, + endpoint: Extract +): Promise { + const limit = PUBLIC_API_ENDPOINT_LIMITS[endpoint] ?? 0 + const scopeId = `public:${endpoint}:${getRequesterKey(request)}` + + const result = await rateLimiter.checkRateLimitWithSubscription( + scopeId, + { + referenceType: 'user', + referenceId: scopeId, + tier: { + displayName: endpoint, + syncRateLimitPerMinute: 0, + asyncRateLimitPerMinute: 0, + apiEndpointRateLimitPerMinute: limit, + } as BillingTierRecord, + }, + 'api-endpoint', + false, + { + scopeType: 'user', + scopeId, + organizationId: null, + userId: null, + }, + { enforceWithoutBilling: true } + ) + + return { + ...result, + limit, + userId: scopeId, + } +} diff --git a/apps/tradinggoose/services/queue/ExecutionLimiter.ts b/apps/tradinggoose/services/queue/ExecutionLimiter.ts index 2412fe363..73b1a89b7 100644 --- a/apps/tradinggoose/services/queue/ExecutionLimiter.ts +++ b/apps/tradinggoose/services/queue/ExecutionLimiter.ts @@ -9,10 +9,7 @@ import { getTierRateLimits, } from '@/lib/billing/tiers' import { createLogger } from '@/lib/logs/console/logger' -import { - type RateLimitCounterType, - type TriggerType, -} from '@/services/queue/types' +import type { RateLimitCounterType, TriggerType } from '@/services/queue/types' const logger = createLogger('ExecutionLimiter') const RATE_LIMIT_WINDOW_MS = 60_000 @@ -115,10 +112,11 @@ export class ExecutionLimiter { subscription: SubscriptionInfo | null, triggerType: TriggerType = 'manual', isAsync = false, - billingScope?: BillingScope | null + billingScope?: BillingScope | null, + options: { enforceWithoutBilling?: boolean } = {} ): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> { try { - if (!(await isBillingEnabledForRuntime())) { + if (!options.enforceWithoutBilling && !(await isBillingEnabledForRuntime())) { return createPermissiveRateLimitResult() } From a393925771bf31c118e67234ba25f3970c0d3e17 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 02:30:04 -0600 Subject: [PATCH 162/284] fix(yjs): discard idle documents after failed persistence Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/socket-server/index.test.ts | 87 ++++++++++++++++++- .../tradinggoose/socket-server/routes/http.ts | 61 ++++++++----- .../socket-server/yjs/upstream-utils.ts | 8 +- 3 files changed, 129 insertions(+), 27 deletions(-) diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 6a3ce1eff..57fb1c8c8 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -8,7 +8,7 @@ import { io as createClient } from 'socket.io-client' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import * as Y from 'yjs' import { createLogger } from '@/lib/logs/console/logger' -import { getEntityFields } from '@/lib/yjs/entity-session' +import { getEntityFields, seedEntitySession } from '@/lib/yjs/entity-session' import { extractPersistedStateFromDoc, setWorkflowState } from '@/lib/yjs/workflow-session' import { createSocketIOServer } from '@/socket-server/config/socket' import { createHttpHandler } from '@/socket-server/routes/http' @@ -16,6 +16,7 @@ import { cleanupAllDocuments, getDocument, getExistingDocument, + setupWSConnection, } from '@/socket-server/yjs/upstream-utils' const { @@ -426,6 +427,66 @@ describe('Socket Server Index Integration', () => { expect(await getExistingDocument('skill-1')).toBeNull() }) + it('should discard an idle workflow document when materialization fails', async () => { + mockSaveWorkflowYjsDocToDb.mockRejectedValueOnce(new Error('database unavailable')) + + const response = await sendHttpRequestWithOptions( + PORT, + '/internal/yjs/workflows/workflow-failed/apply-state', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-secret': INTERNAL_SECRET, + }, + body: JSON.stringify({ + workflowState: { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + lastSaved: '2026-04-06T00:00:00.000Z', + isDeployed: false, + }, + }), + } + ) + + expect(response.statusCode).toBe(500) + expect(await getExistingDocument('workflow-failed')).toBeNull() + }) + + it('should discard an idle saved entity document when update materialization fails', async () => { + mockSaveSavedEntityYjsDocToDb.mockRejectedValueOnce(new Error('database unavailable')) + const updateDoc = new Y.Doc() + seedEntitySession(updateDoc, { + entityKind: 'skill', + payload: { + name: 'Unsaved Skill', + description: 'Draft', + content: 'Draft content', + }, + }) + const updateBase64 = Buffer.from(Y.encodeStateAsUpdate(updateDoc)).toString('base64') + updateDoc.destroy() + + const response = await sendHttpRequestWithOptions( + PORT, + '/internal/yjs/sessions/skill-update-failed/apply-update?targetKind=entity&sessionId=skill-update-failed&workspaceId=workspace-1&entityKind=skill&entityId=skill-update-failed', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-secret': INTERNAL_SECRET, + }, + body: JSON.stringify({ updateBase64 }), + } + ) + + expect(response.statusCode).toBe(500) + expect(await getExistingDocument('skill-update-failed')).toBeNull() + }) + it('should return the internal Yjs workflow snapshot through the generic session route', async () => { const { getRuntimeStateFromDoc } = await import('@/lib/yjs/server/bootstrap-review-target') @@ -533,6 +594,30 @@ describe('Socket Server Index Integration', () => { }) }) + describe('Yjs document cleanup', () => { + it('should discard an idle document even when final persistence fails', async () => { + const conn = new (await import('node:events')).EventEmitter() as any + conn.readyState = 1 + conn.send = vi.fn((_message, _options, callback) => callback?.()) + conn.ping = vi.fn() + conn.close = vi.fn() + const onDocumentIdle = vi.fn().mockRejectedValue(new Error('database unavailable')) + + setupWSConnection(conn, {} as any, { + docId: 'idle-save-failed', + onDocumentIdle, + }) + expect(await getExistingDocument('idle-save-failed')).not.toBeNull() + + conn.emit('close') + await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) + + expect(onDocumentIdle).toHaveBeenCalledWith('idle-save-failed', expect.any(Y.Doc)) + expect(await getExistingDocument('idle-save-failed')).toBeNull() + }) + }) + describe('Socket.IO Server Configuration', () => { it('should create Socket.IO server with proper configuration', () => { expect(io).toBeDefined() diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index 341e6cae2..9927778e6 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -275,22 +275,28 @@ async function handleInternalYjsWorkflowApplyRequest( ): Promise { try { const body = parseApplyWorkflowStateRequest(await readJsonBody(req)) - const doc = await getBootstrappedApplyDocument({ + const descriptor = { workspaceId: null, entityKind: 'workflow', entityId: workflowId, draftSessionId: null, reviewSessionId: null, yjsSessionId: workflowId, - }) + } as const + const doc = await getBootstrappedApplyDocument(descriptor) - if (body.workflowState) { - replaceWorkflowDocumentState(doc, body.workflowState, body.variables, body.entityName) - } else { - setWorkflowEntityName(doc, body.entityName!) + try { + if (body.workflowState) { + replaceWorkflowDocumentState(doc, body.workflowState, body.variables, body.entityName) + } else { + setWorkflowEntityName(doc, body.entityName!) + } + await saveWorkflowYjsDocToDb(workflowId, doc) + discardDocumentIfIdle(workflowId) + } catch (error) { + discardDocumentIfIdle(descriptor.yjsSessionId) + throw error } - await saveWorkflowYjsDocToDb(workflowId, doc) - discardDocumentIfIdle(workflowId) sendJson(res, 200, { success: true }) } catch (error) { logger.error('Error applying workflow state', { error, workflowId }) @@ -309,22 +315,28 @@ async function handleInternalYjsEntityApplyRequest( ): Promise { try { const body = parseApplyEntityStateRequest(await readJsonBody(req)) - const doc = await getBootstrappedApplyDocument({ + const descriptor = { workspaceId: null, entityKind: body.entityKind, entityId, draftSessionId: null, reviewSessionId: null, yjsSessionId: entityId, - }) + } as const + const doc = await getBootstrappedApplyDocument(descriptor) - seedEntitySession(doc, { - entityKind: body.entityKind, - payload: body.fields, - }) - clearSessionReseededFromCanonical(doc) - await saveSavedEntityYjsDocToDb(body.entityKind, entityId, doc) - discardDocumentIfIdle(entityId) + try { + seedEntitySession(doc, { + entityKind: body.entityKind, + payload: body.fields, + }) + clearSessionReseededFromCanonical(doc) + await saveSavedEntityYjsDocToDb(body.entityKind, entityId, doc) + discardDocumentIfIdle(entityId) + } catch (error) { + discardDocumentIfIdle(descriptor.yjsSessionId) + throw error + } sendJson(res, 200, { success: true }) } catch (error) { @@ -366,11 +378,16 @@ async function handleInternalYjsSessionApplyUpdateRequest( } const doc = await getBootstrappedApplyDocument(descriptor) - Y.applyUpdate(doc, Buffer.from(updateBase64, 'base64'), YJS_ORIGINS.SAVE) - clearSessionReseededFromCanonical(doc) - if (descriptor.entityKind !== 'workflow' && descriptor.entityId) { - await saveSavedEntityYjsDocToDb(descriptor.entityKind, descriptor.entityId, doc) - discardDocumentIfIdle(sessionId) + try { + Y.applyUpdate(doc, Buffer.from(updateBase64, 'base64'), YJS_ORIGINS.SAVE) + clearSessionReseededFromCanonical(doc) + if (descriptor.entityKind !== 'workflow' && descriptor.entityId) { + await saveSavedEntityYjsDocToDb(descriptor.entityKind, descriptor.entityId, doc) + discardDocumentIfIdle(sessionId) + } + } catch (error) { + discardDocumentIfIdle(descriptor.yjsSessionId) + throw error } sendJson(res, 200, { success: true }) diff --git a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts index b22946305..0be41ee75 100644 --- a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts +++ b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts @@ -96,14 +96,14 @@ function finalizeDocumentCleanup(doc: WSSharedDoc): void { } void Promise.resolve(doc.onDocumentIdle(doc.name, doc)) - .then(() => { + .catch((error) => { + console.error('[yjs upstream-utils] Failed to persist idle document', error) + }) + .finally(() => { if (doc.conns.size === 0) { cleanupDocument(doc) } }) - .catch((error) => { - console.error('[yjs upstream-utils] Failed to persist idle document', error) - }) } function send(doc: WSSharedDoc, conn: WebSocket, message: Uint8Array): void { From 3c90792d78f833d1341847f5d7a22f6477388ce3 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 02:30:12 -0600 Subject: [PATCH 163/284] style(queue): format execution limiter test Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/services/queue/ExecutionLimiter.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/tradinggoose/services/queue/ExecutionLimiter.test.ts b/apps/tradinggoose/services/queue/ExecutionLimiter.test.ts index e2d425f2d..687d78887 100644 --- a/apps/tradinggoose/services/queue/ExecutionLimiter.test.ts +++ b/apps/tradinggoose/services/queue/ExecutionLimiter.test.ts @@ -243,7 +243,12 @@ describe('ExecutionLimiter', () => { }) it('allows billed requests when the user has no active subscription tier', async () => { - const result = await rateLimiter.checkRateLimitWithSubscription(testUserId, null, 'api', false) + const result = await rateLimiter.checkRateLimitWithSubscription( + testUserId, + null, + 'api', + false + ) expect(result.allowed).toBe(true) expect(result.remaining).toBe(Number.MAX_SAFE_INTEGER) From 63dafbab0b54972c48a34541d4652898f9876dd0 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 02:50:57 -0600 Subject: [PATCH 164/284] feat(workflows): sync workflow metadata through yjs state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/workflows/[id]/route.test.ts | 86 +++++++++++++++---- .../app/api/workflows/[id]/route.ts | 42 +++------ .../server/entities/workflow-variable.test.ts | 2 +- .../copilot/tools/server/entities/workflow.ts | 21 +++-- apps/tradinggoose/lib/workflows/db-helpers.ts | 12 ++- .../yjs/server/apply-workflow-state.test.ts | 33 ++++--- .../lib/yjs/server/apply-workflow-state.ts | 18 ++-- .../lib/yjs/server/bootstrap-review-target.ts | 39 +++------ .../lib/yjs/server/snapshot-bridge.ts | 12 +-- apps/tradinggoose/lib/yjs/workflow-session.ts | 53 +++++++++--- apps/tradinggoose/socket-server/index.test.ts | 2 +- .../tradinggoose/socket-server/routes/http.ts | 26 ++++-- 12 files changed, 217 insertions(+), 129 deletions(-) diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index 3db8ee1aa..ed2435c48 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -19,7 +19,7 @@ describe('Workflow By ID API Route', () => { const mockReadWorkflowById = vi.fn() const mockReadWorkflowAccessContext = vi.fn() const mockLoadWorkflowState = vi.fn() - const mockApplyWorkflowEntityName = vi.fn() + const mockApplyWorkflowMetadata = vi.fn() const mockDeleteYjsSession = vi.fn() beforeEach(() => { @@ -67,18 +67,20 @@ describe('Workflow By ID API Route', () => { mockReadWorkflowById.mockReset() mockReadWorkflowAccessContext.mockReset() mockLoadWorkflowState.mockReset() - mockApplyWorkflowEntityName.mockReset() + mockApplyWorkflowMetadata.mockReset() mockDeleteYjsSession.mockReset() mockLoadWorkflowState.mockResolvedValue(null) - mockApplyWorkflowEntityName.mockResolvedValue({ + mockApplyWorkflowMetadata.mockResolvedValue({ id: 'workflow-123', name: 'Updated Workflow', + description: 'Updated description', + folderId: 'folder-1', workspaceId: null, }) mockDeleteYjsSession.mockResolvedValue(undefined) vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ - applyWorkflowEntityName: mockApplyWorkflowEntityName, + applyWorkflowMetadata: mockApplyWorkflowMetadata, })) vi.doMock('@/lib/yjs/server/snapshot-bridge', () => ({ deleteYjsSessionInSocketServer: mockDeleteYjsSession, @@ -96,7 +98,9 @@ describe('Workflow By ID API Route', () => { function expectWorkflowRenameApplied() { expect(mockLoadWorkflowState).not.toHaveBeenCalled() - expect(mockApplyWorkflowEntityName).toHaveBeenCalledWith('workflow-123', 'Updated Workflow') + expect(mockApplyWorkflowMetadata).toHaveBeenCalledWith('workflow-123', { + name: 'Updated Workflow', + }) } describe('GET /api/workflows/[id]', () => { @@ -628,7 +632,7 @@ describe('Workflow By ID API Route', () => { expectWorkflowRenameApplied() }) - it('updates DB-only metadata without loading workflow state', async () => { + it('updates workflow metadata through the Yjs session without loading workflow state', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -639,7 +643,11 @@ describe('Workflow By ID API Route', () => { } const updateData = { description: 'New description', folderId: 'folder-1' } - const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() } + mockApplyWorkflowMetadata.mockResolvedValueOnce({ + ...mockWorkflow, + ...updateData, + updatedAt: new Date(), + }) vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ @@ -656,19 +664,58 @@ describe('Workflow By ID API Route', () => { isWorkspaceOwner: false, }) - vi.doMock('@tradinggoose/db', () => ({ - db: { - update: vi.fn().mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([updatedWorkflow]), - }), - }), - }), - }, - workflow: {}, + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'PUT', + body: JSON.stringify(updateData), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const { PUT } = await import('@/app/api/workflows/[id]/route') + const response = await PUT(req, { params }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.workflow.description).toBe('New description') + expect(data.workflow.folderId).toBe('folder-1') + expect(mockLoadWorkflowState).not.toHaveBeenCalled() + expect(mockApplyWorkflowMetadata).toHaveBeenCalledWith('workflow-123', updateData) + }) + + it('updates workflow name, description, and folder in one Yjs metadata patch', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'user-123', + name: 'Test Workflow', + description: 'Old description', + folderId: null, + workspaceId: null, + } + const updateData = { + name: 'Updated Workflow', + description: 'New description', + folderId: 'folder-1', + } + mockApplyWorkflowMetadata.mockResolvedValueOnce({ + ...mockWorkflow, + ...updateData, + updatedAt: new Date(), + }) + + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), })) + mockReadWorkflowById.mockResolvedValueOnce(mockWorkflow) + mockReadWorkflowAccessContext.mockResolvedValueOnce({ + workflow: mockWorkflow, + workspaceOwnerId: null, + workspacePermission: null, + isOwner: true, + isWorkspaceOwner: false, + }) + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', body: JSON.stringify(updateData), @@ -680,10 +727,11 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(200) const data = await response.json() + expect(data.workflow.name).toBe('Updated Workflow') expect(data.workflow.description).toBe('New description') expect(data.workflow.folderId).toBe('folder-1') expect(mockLoadWorkflowState).not.toHaveBeenCalled() - expect(mockApplyWorkflowEntityName).not.toHaveBeenCalled() + expect(mockApplyWorkflowMetadata).toHaveBeenCalledWith('workflow-123', updateData) }) it('should deny update for users with only read permission', async () => { diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index bca49e979..4f66efb1b 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -11,7 +11,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { loadWorkflowState } from '@/lib/workflows/db-helpers' import { readWorkflowAccessContext, readWorkflowById } from '@/lib/workflows/utils' -import { applyWorkflowEntityName } from '@/lib/yjs/server/apply-workflow-state' +import { applyWorkflowMetadata } from '@/lib/yjs/server/apply-workflow-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' @@ -163,6 +163,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const finalWorkflowData = { ...workflowData, + ...(workflowState.name !== undefined ? { name: workflowState.name } : {}), + ...(workflowState.description !== undefined + ? { description: workflowState.description } + : {}), + ...(workflowState.folderId !== undefined ? { folderId: workflowState.folderId } : {}), state: { deploymentStatuses: {}, ...(resolvedState.direction !== undefined ? { direction: resolvedState.direction } : {}), @@ -358,34 +363,15 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const updateData: any = { updatedAt: new Date() } - if (updates.description !== undefined) updateData.description = updates.description - if (updates.folderId !== undefined) updateData.folderId = updates.folderId - - if (updates.name === undefined || updates.name === workflowData.name) { - const [updatedWorkflow] = await db - .update(workflow) - .set(updateData) - .where(eq(workflow.id, workflowId)) - .returning() - - if (!updatedWorkflow) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found while updating metadata`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Successfully updated workflow ${workflowId} in ${elapsed}ms`, { - updates, - }) - - return NextResponse.json({ workflow: updatedWorkflow }, { status: 200 }) + const metadata = { + ...(updates.name !== undefined ? { name: updates.name } : {}), + ...(updates.description !== undefined ? { description: updates.description } : {}), + ...(updates.folderId !== undefined ? { folderId: updates.folderId } : {}), } - - const updatedWorkflow = await applyWorkflowEntityName( - workflowId, - updates.name ?? workflowData.name - ) + const updatedWorkflow = + Object.keys(metadata).length > 0 + ? await applyWorkflowMetadata(workflowId, metadata) + : workflowData const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully updated workflow ${workflowId} in ${elapsed}ms`, { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts index d038150be..2b6d6b9dd 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts @@ -35,7 +35,7 @@ vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ vi.mock('@/lib/yjs/server/apply-workflow-state', () => ({ applyWorkflowState: (...args: any[]) => mockApplyWorkflowState(...args), - applyWorkflowEntityName: vi.fn(), + applyWorkflowMetadata: vi.fn(), })) function workflowSnapshotBase64( diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index 87b676c57..efeab01f8 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -28,11 +28,12 @@ import { serializeWorkflowToTgMermaid, } from '@/lib/workflows/studio-workflow-mermaid' import { isWorkflowVariableType, type WorkflowVariableType } from '@/lib/workflows/value-types' -import { applyWorkflowEntityName, applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { applyWorkflowMetadata, applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' import { createWorkflowSnapshot, getVariablesSnapshot, + readWorkflowEntityMetadata, readWorkflowSnapshot, type WorkflowSnapshot, } from '@/lib/yjs/workflow-session' @@ -224,9 +225,10 @@ export async function loadWorkflowSnapshotForCopilot( const doc = new Y.Doc() try { Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) + const metadata = readWorkflowEntityMetadata(doc) return { workflowId, - entityName: workflowRow.name ?? undefined, + entityName: metadata.name ?? workflowRow.name ?? undefined, workspaceId: workflowRow.workspaceId ?? null, workflowState: { ...readWorkflowSnapshot(doc), @@ -489,7 +491,12 @@ export const createWorkflowServerTool: BaseServerTool< }) try { - await applyWorkflowState(workflowId, workflowState, {}, name) + await applyWorkflowState( + workflowId, + workflowState, + {}, + { name, description, folderId: args.folderId || null } + ) } catch (error) { await db.delete(workflow).where(eq(workflow.id, workflowId)) throw error @@ -514,7 +521,11 @@ export const renameWorkflowServerTool: BaseServerTool<{ entityId: string; name: throw new Error('name is required') } - const { workspaceId: accessWorkspaceId } = await verifyWorkflowContext(workflowId, context, 'write') + const { workspaceId: accessWorkspaceId } = await verifyWorkflowContext( + workflowId, + context, + 'write' + ) const [current] = await db .select({ name: workflow.name, @@ -545,7 +556,7 @@ export const renameWorkflowServerTool: BaseServerTool<{ entityId: string; name: } assertAcceptedServerToolReviewBase(context, currentNameBaseHash) - const updatedWorkflow = await applyWorkflowEntityName(workflowId, nextName) + const updatedWorkflow = await applyWorkflowMetadata(workflowId, { name: nextName }) return { success: true, diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 514503420..47920975e 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -86,6 +86,8 @@ const sanitizeBlockLayout = (layout: unknown): BlockState['layout'] => { export type PersistedWorkflowState = { name?: string | null + description?: string | null + folderId?: string | null direction?: WorkflowDirection blocks: Record edges: any[] @@ -172,6 +174,8 @@ export async function loadWorkflowStateFromSavedTables( db .select({ name: workflow.name, + description: workflow.description, + folderId: workflow.folderId, variables: workflow.variables, updatedAt: workflow.updatedAt, isDeployed: workflow.isDeployed, @@ -189,6 +193,8 @@ export async function loadWorkflowStateFromSavedTables( const savedState = { name: row.name, + description: row.description, + folderId: row.folderId, blocks: normalizedState.blocks, edges: normalizedState.edges, loops: normalizedState.loops, @@ -893,8 +899,6 @@ export async function saveWorkflowToNormalizedTables( export async function saveWorkflowYjsDocToDb(workflowId: string, doc: Y.Doc): Promise { const state = extractPersistedStateFromDoc(doc) - const entityName = doc.getMap('metadata').get('entityName') - const workflowName = typeof entityName === 'string' ? entityName.trim() : '' const syncedAt = new Date() const workflowState: WorkflowState = { ...(state.direction !== undefined ? { direction: state.direction } : {}), @@ -913,7 +917,9 @@ export async function saveWorkflowYjsDocToDb(workflowId: string, doc: Y.Doc): Pr .set({ lastSynced: syncedAt, updatedAt: syncedAt, - ...(workflowName ? { name: workflowName } : {}), + ...(state.name ? { name: state.name } : {}), + ...(state.description !== undefined ? { description: state.description } : {}), + ...(state.folderId !== undefined ? { folderId: state.folderId } : {}), variables: state.variables, ...(state.isDeployed === undefined ? {} diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts index ae0f02304..240c5e418 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockApplyWorkflowEntityNameInSocketServer, + mockApplyWorkflowMetadataInSocketServer, mockApplyWorkflowStateInSocketServer, mockDbUpdate, mockDbSelect, @@ -19,7 +19,7 @@ const { mockUpdateWhere, } = vi.hoisted(() => { return { - mockApplyWorkflowEntityNameInSocketServer: vi.fn(), + mockApplyWorkflowMetadataInSocketServer: vi.fn(), mockApplyWorkflowStateInSocketServer: vi.fn(), mockDbUpdate: vi.fn(), mockDbSelect: vi.fn(), @@ -54,7 +54,7 @@ vi.mock('@/lib/workflows/db-helpers', () => ({ })) vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ - applyWorkflowEntityNameInSocketServer: mockApplyWorkflowEntityNameInSocketServer, + applyWorkflowMetadataInSocketServer: mockApplyWorkflowMetadataInSocketServer, applyWorkflowStateInSocketServer: mockApplyWorkflowStateInSocketServer, })) @@ -63,7 +63,7 @@ const emptyWorkflowState = { blocks: {}, edges: [], loops: {}, parallels: {} } describe('applyWorkflowState', () => { beforeEach(() => { vi.clearAllMocks() - mockApplyWorkflowEntityNameInSocketServer.mockResolvedValue(undefined) + mockApplyWorkflowMetadataInSocketServer.mockResolvedValue(undefined) mockApplyWorkflowStateInSocketServer.mockResolvedValue(undefined) mockEnsureUniqueBlockIds.mockImplementation(async (_workflowId, state) => state) mockEnsureUniqueEdgeIds.mockImplementation(async (_workflowId, state) => state) @@ -77,19 +77,24 @@ describe('applyWorkflowState', () => { mockDbSelect.mockReturnValue({ from: mockSelectFrom }) }) - it('renames workflow entity metadata through the socket-owned Yjs document', async () => { - const { applyWorkflowEntityName } = await import('./apply-workflow-state') + it('updates workflow entity metadata through the socket-owned Yjs document', async () => { + const { applyWorkflowMetadata } = await import('./apply-workflow-state') - const updatedWorkflow = await applyWorkflowEntityName('workflow-1', 'Renamed Workflow') + const updatedWorkflow = await applyWorkflowMetadata('workflow-1', { + name: 'Renamed Workflow', + description: 'Updated description', + folderId: 'folder-1', + }) - expect(mockApplyWorkflowEntityNameInSocketServer).toHaveBeenCalledWith( - 'workflow-1', - 'Renamed Workflow' - ) + expect(mockApplyWorkflowMetadataInSocketServer).toHaveBeenCalledWith('workflow-1', { + name: 'Renamed Workflow', + description: 'Updated description', + folderId: 'folder-1', + }) expect(mockApplyWorkflowStateInSocketServer).not.toHaveBeenCalled() expect(mockDbUpdate).not.toHaveBeenCalled() expect(mockDbSelect.mock.invocationCallOrder[0]).toBeGreaterThan( - mockApplyWorkflowEntityNameInSocketServer.mock.invocationCallOrder[0] + mockApplyWorkflowMetadataInSocketServer.mock.invocationCallOrder[0] ) expect(updatedWorkflow).toMatchObject({ id: 'workflow-1', name: 'Renamed Workflow' }) }) @@ -133,7 +138,7 @@ describe('applyWorkflowState', () => { parallels: {}, }, {}, - 'Workflow Name' + { name: 'Workflow Name' } ) expect(mockApplyWorkflowStateInSocketServer).toHaveBeenCalledWith( @@ -144,7 +149,7 @@ describe('applyWorkflowState', () => { }, }), {}, - 'Workflow Name' + { name: 'Workflow Name' } ) expect(mockDbUpdate).not.toHaveBeenCalled() }) diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index 602cfc264..f608dcbc6 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -2,16 +2,20 @@ import { db, workflow } from '@tradinggoose/db' import { eq } from 'drizzle-orm' import { ensureUniqueBlockIds, ensureUniqueEdgeIds } from '@/lib/workflows/db-helpers' import { - applyWorkflowEntityNameInSocketServer, + applyWorkflowMetadataInSocketServer, applyWorkflowStateInSocketServer, } from '@/lib/yjs/server/snapshot-bridge' -import { createWorkflowSnapshot, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { + createWorkflowSnapshot, + type WorkflowMetadataPatch, + type WorkflowSnapshot, +} from '@/lib/yjs/workflow-session' export async function applyWorkflowState( workflowId: string, workflowState: WorkflowSnapshot, variables?: Record, - entityName?: string + metadata?: WorkflowMetadataPatch ): Promise { const syncedAt = new Date() const appliedWorkflowState = createWorkflowSnapshot({ @@ -32,14 +36,14 @@ export async function applyWorkflowState( : {}), }) - await applyWorkflowStateInSocketServer(workflowId, storedWorkflowState, variables, entityName) + await applyWorkflowStateInSocketServer(workflowId, storedWorkflowState, variables, metadata) } -export async function applyWorkflowEntityName( +export async function applyWorkflowMetadata( workflowId: string, - entityName: string + metadata: WorkflowMetadataPatch ): Promise { - await applyWorkflowEntityNameInSocketServer(workflowId, entityName) + await applyWorkflowMetadataInSocketServer(workflowId, metadata) const [updatedWorkflow] = await db .select() diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 8901bbc2b..46b7e4d54 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -16,7 +16,7 @@ import { readSavedEntityFieldsFromDb, resolveEntityWorkspaceId, } from '@/lib/yjs/server/entity-loaders' -import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' +import { getYjsSnapshot } from '@/lib/yjs/server/snapshot-bridge' import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' import { createWorkflowSnapshot, @@ -51,32 +51,7 @@ export function getRuntimeStateFromUpdate(update: Uint8Array): ReviewTargetRunti export async function readBootstrappedReviewTargetSnapshot(descriptor: ReviewTargetDescriptor) { const bridgeParams = serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor)) - try { - return await getYjsSnapshot(descriptor.yjsSessionId, bridgeParams) - } catch (error) { - if (!(error instanceof SocketServerBridgeError) || error.status !== 404) { - throw error - } - } - - if (!descriptor.entityId) { - return { - snapshotBase64: '', - descriptor, - runtime: { - docState: 'expired' as const, - replaySafe: false, - reseededFromCanonical: false, - }, - } - } - - const bootstrapped = await createSavedReviewTargetBootstrapUpdate(descriptor) - return { - snapshotBase64: Buffer.from(bootstrapped.state).toString('base64'), - descriptor: bootstrapped.descriptor, - runtime: bootstrapped.runtime, - } + return getYjsSnapshot(descriptor.yjsSessionId, bridgeParams) } export async function readBootstrappedSavedEntityFields( @@ -115,12 +90,16 @@ export async function createSavedReviewTargetBootstrapUpdate( const doc = new Y.Doc() try { let workflowName: string | null | undefined + let workflowDescription: string | null | undefined + let workflowFolderId: string | null | undefined if (descriptor.entityKind === 'workflow') { const workflowState = await loadWorkflowStateFromSavedTables(descriptor.entityId) if (!workflowState) { throw new ReviewTargetBootstrapError(404, 'Workflow not found') } workflowName = workflowState.name + workflowDescription = workflowState.description + workflowFolderId = workflowState.folderId setWorkflowState( doc, @@ -162,6 +141,12 @@ export async function createSavedReviewTargetBootstrapUpdate( if (workflowName) { metadata.set('entityName', workflowName) } + if (workflowDescription !== undefined) { + metadata.set('entityDescription', workflowDescription) + } + if (workflowFolderId !== undefined) { + metadata.set('folderId', workflowFolderId) + } const state = Y.encodeStateAsUpdate(doc) return { diff --git a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts index 499bf516f..3db5f7d3a 100644 --- a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts +++ b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts @@ -3,7 +3,7 @@ import type { ReviewTargetRuntimeState, } from '@/lib/copilot/review-sessions/types' import { env, getInternalRealtimeUrl } from '@/lib/env' -import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' +import type { WorkflowMetadataPatch, WorkflowSnapshot } from '@/lib/yjs/workflow-session' export interface YjsSnapshotResponse { snapshotBase64: string @@ -92,25 +92,25 @@ export async function applyWorkflowStateInSocketServer( workflowId: string, workflowState: WorkflowSnapshot, variables?: Record, - entityName?: string + metadata?: WorkflowMetadataPatch ): Promise { await postJsonToSocketServer( `/internal/yjs/workflows/${encodeURIComponent(workflowId)}/apply-state`, { workflowState, ...(variables === undefined ? {} : { variables }), - ...(entityName ? { entityName } : {}), + ...(metadata ? { metadata } : {}), } ) } -export async function applyWorkflowEntityNameInSocketServer( +export async function applyWorkflowMetadataInSocketServer( workflowId: string, - entityName: string + metadata: WorkflowMetadataPatch ): Promise { await postJsonToSocketServer( `/internal/yjs/workflows/${encodeURIComponent(workflowId)}/apply-state`, - { entityName } + { metadata } ) } diff --git a/apps/tradinggoose/lib/yjs/workflow-session.ts b/apps/tradinggoose/lib/yjs/workflow-session.ts index b12f35b24..8ab301499 100644 --- a/apps/tradinggoose/lib/yjs/workflow-session.ts +++ b/apps/tradinggoose/lib/yjs/workflow-session.ts @@ -90,7 +90,11 @@ export function readWorkflowTextFieldFromMap( return existing instanceof Y.Text ? existing : null } -export function readWorkflowTextField(doc: Y.Doc, blockId: string, subBlockId: string): Y.Text | null { +export function readWorkflowTextField( + doc: Y.Doc, + blockId: string, + subBlockId: string +): Y.Text | null { return readWorkflowTextFieldFromMap(readWorkflowTextFieldsMap(doc), blockId, subBlockId) } @@ -240,6 +244,18 @@ export interface WorkflowSnapshot { deployedAt?: string } +export type WorkflowMetadataPatch = { + name?: string + description?: string | null + folderId?: string | null +} + +export type WorkflowMetadataSnapshot = { + name?: string + description?: string | null + folderId?: string | null +} + /** * Applies safe defaults to a partial snapshot. Used by both * `createWorkflowSnapshot` and `readWorkflowSnapshot` so the defaulting @@ -262,9 +278,7 @@ function applySnapshotDefaults(partial: Partial): WorkflowSnap * Creates a WorkflowSnapshot with safe defaults for all fields. * Use this instead of manually spreading `?? {}` / `?? []` at every call site. */ -export function createWorkflowSnapshot( - partial: Partial = {} -): WorkflowSnapshot { +export function createWorkflowSnapshot(partial: Partial = {}): WorkflowSnapshot { return applySnapshotDefaults(partial) } @@ -375,7 +389,7 @@ export function replaceWorkflowDocumentState( doc: Y.Doc, workflowState: WorkflowSnapshot, variables?: Record, - entityName?: string + metadataPatch?: WorkflowMetadataPatch ): void { setWorkflowState(doc, workflowState, YJS_ORIGINS.SYSTEM) @@ -384,17 +398,31 @@ export function replaceWorkflowDocumentState( } doc.transact(() => { - const metadata = getMetadataMap(doc) - metadata.delete('reseededFromCanonical') - if (entityName) metadata.set('entityName', entityName) + getMetadataMap(doc).delete('reseededFromCanonical') }, YJS_ORIGINS.SYSTEM) + if (metadataPatch) setWorkflowEntityMetadata(doc, metadataPatch) +} + +export function readWorkflowEntityMetadata(doc: Y.Doc): WorkflowMetadataSnapshot { + const metadata = getMetadataMap(doc) + return { + ...(typeof metadata.get('entityName') === 'string' + ? { name: metadata.get('entityName') as string } + : {}), + ...(metadata.has('entityDescription') + ? { description: metadata.get('entityDescription') as string | null } + : {}), + ...(metadata.has('folderId') ? { folderId: metadata.get('folderId') as string | null } : {}), + } } -export function setWorkflowEntityName(doc: Y.Doc, entityName: string): void { +export function setWorkflowEntityMetadata(doc: Y.Doc, patch: WorkflowMetadataPatch): void { doc.transact(() => { const metadata = getMetadataMap(doc) metadata.delete('reseededFromCanonical') - metadata.set('entityName', entityName) + if (patch.name !== undefined) metadata.set('entityName', patch.name.trim()) + if (patch.description !== undefined) metadata.set('entityDescription', patch.description) + if (patch.folderId !== undefined) metadata.set('folderId', patch.folderId) }, YJS_ORIGINS.SYSTEM) } @@ -507,6 +535,9 @@ export function setVariables(doc: Y.Doc, variables: Record, origin? * by both the server-side Yjs loader and the template builder. */ export interface PersistedDocState { + name?: string + description?: string | null + folderId?: string | null direction?: WorkflowDirection blocks: Record edges: Edge[] @@ -520,11 +551,13 @@ export interface PersistedDocState { export function extractPersistedStateFromDoc(doc: Y.Doc): PersistedDocState { const snapshot = readWorkflowSnapshot(doc) + const metadata = readWorkflowEntityMetadata(doc) const variables = getVariablesSnapshot(doc) const lastSaved = resolveStoredDateValue(snapshot.lastSaved)?.getTime() ?? Date.now() const deployedAt = resolveStoredDateValue(snapshot.deployedAt)?.toISOString() return { + ...metadata, ...(snapshot.direction !== undefined ? { direction: snapshot.direction } : {}), blocks: snapshot.blocks || {}, edges: snapshot.edges || [], diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 57fb1c8c8..08479ebab 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -382,7 +382,7 @@ describe('Socket Server Index Integration', () => { 'content-type': 'application/json', 'x-internal-secret': INTERNAL_SECRET, }, - body: JSON.stringify({ entityName: 'Renamed Workflow' }), + body: JSON.stringify({ metadata: { name: 'Renamed Workflow' } }), } ) diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index 9927778e6..b52ef99e1 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -19,7 +19,8 @@ import { import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' import { replaceWorkflowDocumentState, - setWorkflowEntityName, + setWorkflowEntityMetadata, + type WorkflowMetadataPatch, type WorkflowSnapshot, } from '@/lib/yjs/workflow-session' import { getMonitorRuntimeLockHealth } from '@/socket-server/monitor-runtime-lock' @@ -55,7 +56,7 @@ const INTERNAL_YJS_SESSION_APPLY_UPDATE_PATH = /^\/internal\/yjs\/sessions\/([^/ type ApplyWorkflowStateRequest = { workflowState?: WorkflowSnapshot variables?: Record - entityName?: string + metadata?: WorkflowMetadataPatch } type SavedEntityKind = Exclude @@ -167,10 +168,19 @@ function parseApplyWorkflowStateRequest(body: unknown): ApplyWorkflowStateReques const candidate = body as Record const workflowState = candidate.workflowState - const entityName = typeof candidate.entityName === 'string' ? candidate.entityName.trim() : '' + if ( + candidate.metadata !== undefined && + (!candidate.metadata || + typeof candidate.metadata !== 'object' || + Array.isArray(candidate.metadata)) + ) { + throw new InvalidInternalYjsRequestError('metadata must be an object') + } + const metadata = + candidate.metadata !== undefined ? (candidate.metadata as WorkflowMetadataPatch) : undefined - if (workflowState === undefined && !entityName) { - throw new InvalidInternalYjsRequestError('workflowState or entityName is required') + if (workflowState === undefined && metadata === undefined) { + throw new InvalidInternalYjsRequestError('workflowState or metadata is required') } if ( @@ -196,7 +206,7 @@ function parseApplyWorkflowStateRequest(body: unknown): ApplyWorkflowStateReques return { workflowState: workflowState as WorkflowSnapshot | undefined, variables: candidate.variables as Record | undefined, - entityName: entityName || undefined, + metadata, } } @@ -287,9 +297,9 @@ async function handleInternalYjsWorkflowApplyRequest( try { if (body.workflowState) { - replaceWorkflowDocumentState(doc, body.workflowState, body.variables, body.entityName) + replaceWorkflowDocumentState(doc, body.workflowState, body.variables, body.metadata) } else { - setWorkflowEntityName(doc, body.entityName!) + setWorkflowEntityMetadata(doc, body.metadata!) } await saveWorkflowYjsDocToDb(workflowId, doc) discardDocumentIfIdle(workflowId) From c846376f42b9863dd95820caa4027092a8b3060e Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 02:51:14 -0600 Subject: [PATCH 165/284] fix(api): ignore user agent in rate limit keys Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/api/rate-limit.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/tradinggoose/lib/api/rate-limit.ts b/apps/tradinggoose/lib/api/rate-limit.ts index e7d46bc0e..989ed24c6 100644 --- a/apps/tradinggoose/lib/api/rate-limit.ts +++ b/apps/tradinggoose/lib/api/rate-limit.ts @@ -125,9 +125,8 @@ function getRequesterKey(request: Request): string { request.headers.get('x-real-ip')?.trim() || forwardedFor || 'unknown' - const userAgent = request.headers.get('user-agent')?.trim() || 'unknown' const host = request.headers.get('host')?.trim() || new URL(request.url).host - return createHash('sha256').update(`${host}\n${requester}\n${userAgent}`).digest('hex') + return createHash('sha256').update(`${host}\n${requester}`).digest('hex') } export async function checkPublicApiEndpointRateLimit( From 9a76243459f3116f81d14673da906d53f84562da Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 03:18:36 -0600 Subject: [PATCH 166/284] refactor(workflows): separate editable state from deployment metadata Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/templates/[id]/use/route.ts | 2 - .../api/workflows/[id]/autolayout/route.ts | 4 +- .../api/workflows/[id]/deploy/route.test.ts | 2 +- .../app/api/workflows/[id]/deploy/route.ts | 4 +- .../[version]/revert/route.test.ts | 8 +-- .../deployments/[version]/revert/route.ts | 9 +-- .../workflows/[id]/duplicate/route.test.ts | 4 +- .../app/api/workflows/[id]/duplicate/route.ts | 26 +++---- .../app/api/workflows/[id]/route.test.ts | 5 +- .../app/api/workflows/[id]/route.ts | 10 ++- .../api/workflows/[id]/status/route.test.ts | 12 +--- .../app/api/workflows/[id]/status/route.ts | 4 +- .../app/api/workflows/route.test.ts | 1 - apps/tradinggoose/app/api/workflows/route.ts | 4 -- .../api/workflows/yaml/export/route.test.ts | 67 +++++++++---------- .../app/api/workflows/yaml/export/route.ts | 26 +++---- .../workflow/use-current-workflow.test.tsx | 4 -- .../hooks/workflow/use-current-workflow.ts | 46 +++---------- .../copilot/tools/server/entities/workflow.ts | 8 +-- .../lib/workflows/db-helpers.test.ts | 11 ++- apps/tradinggoose/lib/workflows/db-helpers.ts | 54 ++------------- .../lib/workflows/execution-runner.test.ts | 6 +- .../workflows/studio-workflow-mermaid.test.ts | 2 - .../lib/workflows/studio-workflow-mermaid.ts | 15 +---- .../lib/yjs/server/apply-workflow-state.ts | 6 +- .../lib/yjs/server/bootstrap-review-target.ts | 2 - apps/tradinggoose/lib/yjs/use-workflow-doc.ts | 20 ++---- apps/tradinggoose/lib/yjs/workflow-session.ts | 24 +------ apps/tradinggoose/socket-server/index.test.ts | 1 - apps/tradinggoose/stores/workflows/index.ts | 3 - 30 files changed, 109 insertions(+), 281 deletions(-) diff --git a/apps/tradinggoose/app/api/templates/[id]/use/route.ts b/apps/tradinggoose/app/api/templates/[id]/use/route.ts index 47321ac2a..d06afd2fa 100644 --- a/apps/tradinggoose/app/api/templates/[id]/use/route.ts +++ b/apps/tradinggoose/app/api/templates/[id]/use/route.ts @@ -101,8 +101,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, { ...stateWithoutTemplateVars, lastSaved: now.toISOString(), - isDeployed: false, - deployedAt: undefined, }) if (!saveResult.success) { diff --git a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts index d07fc560e..2c681f40f 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applyAutoLayout } from '@/lib/workflows/autolayout' -import { loadWorkflowState } from '@/lib/workflows/db-helpers' +import { loadEditableWorkflowState } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' export const dynamic = 'force-dynamic' @@ -61,7 +61,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } } else { logger.info(`[${requestId}] Loading blocks from current workflow state`) - currentWorkflowData = await loadWorkflowState(workflowId) + currentWorkflowData = await loadEditableWorkflowState(workflowId) } if (!currentWorkflowData) { diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts index cf6145158..c7047ec4e 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts @@ -37,7 +37,7 @@ describe('Workflow Deploy API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ deployWorkflow: vi.fn(), - loadWorkflowState: (...args: unknown[]) => mockLoadWorkflowState(...args), + loadEditableWorkflowState: (...args: unknown[]) => mockLoadWorkflowState(...args), })) vi.doMock('@/lib/chat/published-deployment', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts index ea0b85486..551f60109 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts @@ -7,7 +7,7 @@ import { } from '@/lib/chat/published-deployment' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { deployWorkflow, loadWorkflowState } from '@/lib/workflows/db-helpers' +import { deployWorkflow, loadEditableWorkflowState } from '@/lib/workflows/db-helpers' import { hasWorkflowChanged, validateWorkflowPermissions } from '@/lib/workflows/utils' import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' import { pauseMonitorsMissingDeployedTrigger } from '@/app/api/monitors/shared' @@ -99,7 +99,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .limit(1) if (active?.state) { - const currentState = await loadWorkflowState(id) + const currentState = await loadEditableWorkflowState(id) if (currentState) { needsRedeployment = hasWorkflowChanged(currentState, active.state as any) } diff --git a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts index ce4b3d591..ab649f62f 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts @@ -98,8 +98,6 @@ describe('Revert To Deployment Version API Route', () => { loops: partial.loops ?? {}, parallels: partial.parallels ?? {}, lastSaved: partial.lastSaved, - isDeployed: partial.isDeployed, - deployedAt: partial.deployedAt, })), })) @@ -193,10 +191,6 @@ describe('Revert To Deployment Version API Route', () => { }) expect(response.status).toBe(200) - expect(mockApplyWorkflowState).toHaveBeenCalledWith( - 'workflow-1', - expect.any(Object), - undefined - ) + expect(mockApplyWorkflowState).toHaveBeenCalledWith('workflow-1', expect.any(Object), undefined) }) }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts index c13f063cb..9d09833f7 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -3,10 +3,7 @@ import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { - ensureUniqueBlockIds, - ensureUniqueEdgeIds, -} from '@/lib/workflows/db-helpers' +import { ensureUniqueBlockIds, ensureUniqueEdgeIds } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' @@ -87,8 +84,6 @@ export async function POST( loops: deployedState.loops || {}, parallels: deployedState.parallels || {}, lastSaved: Date.now(), - isDeployed: true, - deployedAt: new Date(), } const stateWithUniqueBlockIds = await ensureUniqueBlockIds(id, revertedState) @@ -99,8 +94,6 @@ export async function POST( loops: persistedRevertedState.loops, parallels: persistedRevertedState.parallels, lastSaved: now.toISOString(), - isDeployed: true, - deployedAt: now.toISOString(), }) await applyWorkflowState(id, revertSnapshot, revertVariables) diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts index 55656bb19..13bb006ce 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts @@ -110,7 +110,7 @@ describe('Workflow Duplicate API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadWorkflowState: loadWorkflowStateMock, + loadEditableWorkflowState: loadWorkflowStateMock, regenerateWorkflowStateIds: regenerateWorkflowStateIdsMock, saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, })) @@ -146,7 +146,6 @@ describe('Workflow Duplicate API Route', () => { }, }, lastSaved: Date.now(), - source: 'db', }) const { POST } = await import('@/app/api/workflows/[id]/duplicate/route') @@ -192,7 +191,6 @@ describe('Workflow Duplicate API Route', () => { parallels: {}, variables: {}, lastSaved: Date.now(), - source: 'db', }) saveWorkflowToNormalizedTablesMock.mockResolvedValueOnce({ success: false, diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts index e60d7a1f1..9d34c2ff2 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -9,7 +9,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' import { - loadWorkflowState, + loadEditableWorkflowState, regenerateWorkflowStateIds, saveWorkflowToNormalizedTables, } from '@/lib/workflows/db-helpers' @@ -30,25 +30,22 @@ const DuplicateRequestSchema = z.object({ async function loadSourceWorkflowArtifacts(sourceWorkflowId: string): Promise<{ workflowState: WorkflowState variables: Record - source: 'yjs' | 'db' }> { - const stateWithSource = await loadWorkflowState(sourceWorkflowId) - if (!stateWithSource) { + const editableState = await loadEditableWorkflowState(sourceWorkflowId) + if (!editableState) { throw new Error('Failed to load source workflow state') } return { workflowState: { - ...(stateWithSource.direction !== undefined ? { direction: stateWithSource.direction } : {}), - blocks: stateWithSource.blocks, - edges: stateWithSource.edges, - loops: stateWithSource.loops, - parallels: stateWithSource.parallels, - lastSaved: stateWithSource.lastSaved ?? Date.now(), - isDeployed: false, + ...(editableState.direction !== undefined ? { direction: editableState.direction } : {}), + blocks: editableState.blocks, + edges: editableState.edges, + loops: editableState.loops, + parallels: editableState.parallels, + lastSaved: editableState.lastSaved ?? Date.now(), }, - variables: normalizeVariables(stateWithSource.variables), - source: stateWithSource.source, + variables: normalizeVariables(editableState.variables), } } @@ -136,7 +133,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, { ...duplicatedWorkflowState, lastSaved: now.getTime(), - isDeployed: false, }) if (!saveResult.success) { await db.delete(workflow).where(eq(workflow.id, newWorkflowId)) @@ -144,7 +140,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: } const persistedDuplicatedState = saveResult.normalizedState ?? duplicatedWorkflowState - logger.info(`[${requestId}] Duplicated workflow state using ${sourceArtifacts.source} source`, { + logger.info(`[${requestId}] Duplicated editable workflow state from Yjs`, { sourceWorkflowId, newWorkflowId, blocksCount: Object.keys(persistedDuplicatedState.blocks || {}).length, diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index ed2435c48..1d423083a 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -34,7 +34,7 @@ describe('Workflow By ID API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadWorkflowState: mockLoadWorkflowState, + loadEditableWorkflowState: mockLoadWorkflowState, })) vi.doMock('@tradinggoose/db', () => ({ @@ -160,7 +160,6 @@ describe('Workflow By ID API Route', () => { edges: [], loops: {}, parallels: {}, - source: 'db', } vi.doMock('@/lib/auth', () => ({ @@ -213,7 +212,6 @@ describe('Workflow By ID API Route', () => { edges: [], loops: {}, parallels: {}, - source: 'db', } vi.doMock('@/lib/auth', () => ({ @@ -300,7 +298,6 @@ describe('Workflow By ID API Route', () => { edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2' }], loops: {}, parallels: {}, - source: 'db', } vi.doMock('@/lib/auth', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index 4f66efb1b..d245f717e 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -9,7 +9,7 @@ import { verifyInternalTokenDetailed } from '@/lib/auth/internal' import { hydrateListingUI } from '@/lib/listing/hydrate-ui' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { loadWorkflowState } from '@/lib/workflows/db-helpers' +import { loadEditableWorkflowState } from '@/lib/workflows/db-helpers' import { readWorkflowAccessContext, readWorkflowById } from '@/lib/workflows/utils' import { applyWorkflowMetadata } from '@/lib/yjs/server/apply-workflow-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' @@ -127,14 +127,14 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ logger.debug( `[${requestId}] Attempting to load workflow ${workflowId} from authoritative state` ) - const workflowState = await loadWorkflowState(workflowId) + const workflowState = await loadEditableWorkflowState(workflowId) if (!workflowState) { logger.warn(`[${requestId}] Workflow ${workflowId} is missing saved state`) return NextResponse.json({ error: 'Workflow state is missing' }, { status: 409 }) } - logger.debug(`[${requestId}] Found ${workflowState.source} workflow state for ${workflowId}:`, { + logger.debug(`[${requestId}] Found editable Yjs workflow state for ${workflowId}:`, { blocksCount: Object.keys(workflowState.blocks).length, edgesCount: workflowState.edges.length, loopsCount: Object.keys(workflowState.loops).length, @@ -182,9 +182,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ }, } - logger.info( - `[${requestId}] Loaded workflow ${workflowId} from ${workflowState?.source ?? 'empty state'}` - ) + logger.info(`[${requestId}] Loaded editable workflow ${workflowId} from Yjs`) const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully fetched workflow ${workflowId} in ${elapsed}ms`) diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts index 532e85bdd..887fc0a6e 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts @@ -54,7 +54,7 @@ describe('Workflow Status API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadWorkflowState: mockLoadWorkflowState, + loadEditableWorkflowState: mockLoadWorkflowState, })) vi.doMock('@/lib/workflows/utils', () => ({ @@ -96,10 +96,7 @@ describe('Workflow Status API Route', () => { vi.unstubAllGlobals() }) - it( - 'marks variable-only edits as needing redeployment', - { timeout: 10_000 }, - async () => { + it('marks variable-only edits as needing redeployment', { timeout: 10_000 }, async () => { mockValidateWorkflowAccess.mockResolvedValue({ error: null, workflow: { @@ -121,7 +118,6 @@ describe('Workflow Status API Route', () => { value: 'us-west-2', }, }, - source: 'db', }) mockLimit.mockResolvedValue([ @@ -152,8 +148,7 @@ describe('Workflow Status API Route', () => { const data = await response.json() expect(data.data.needsRedeployment).toBe(true) - } - ) + }) it('reports redeployment when the active deployment state omits current variables', async () => { mockValidateWorkflowAccess.mockResolvedValue({ @@ -177,7 +172,6 @@ describe('Workflow Status API Route', () => { value: 'us-west-2', }, }, - source: 'db', }) mockLimit.mockResolvedValue([ diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts index 0178d9031..b8abc92ce 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts @@ -3,7 +3,7 @@ import { and, desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { loadWorkflowState } from '@/lib/workflows/db-helpers' +import { loadEditableWorkflowState } from '@/lib/workflows/db-helpers' import { hasWorkflowChanged } from '@/lib/workflows/utils' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -28,7 +28,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ if (validation.workflow.isDeployed) { // Load current workflow state and the active deployment version in parallel. const [currentState, [active]] = await Promise.all([ - loadWorkflowState(id), + loadEditableWorkflowState(id), db .select({ state: workflowDeploymentVersion.state }) .from(workflowDeploymentVersion) diff --git a/apps/tradinggoose/app/api/workflows/route.test.ts b/apps/tradinggoose/app/api/workflows/route.test.ts index fed29e8f2..4b12669a5 100644 --- a/apps/tradinggoose/app/api/workflows/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/route.test.ts @@ -159,7 +159,6 @@ describe('Workflow API Route', () => { edges: initialWorkflowState.edges, loops: initialWorkflowState.loops, parallels: initialWorkflowState.parallels, - isDeployed: false, lastSaved: expect.any(Number), }) ) diff --git a/apps/tradinggoose/app/api/workflows/route.ts b/apps/tradinggoose/app/api/workflows/route.ts index 3db1ab274..3ec4f7ab1 100644 --- a/apps/tradinggoose/app/api/workflows/route.ts +++ b/apps/tradinggoose/app/api/workflows/route.ts @@ -58,10 +58,6 @@ function getInitialWorkflowState( loops: loops as WorkflowState['loops'], parallels: parallels as WorkflowState['parallels'], lastSaved: now.getTime(), - isDeployed: false, - deployedAt: undefined, - deploymentStatuses: {}, - needsRedeployment: false, }, variables, } diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts index 2832887e9..344cadb01 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts @@ -92,7 +92,7 @@ describe('Workflow YAML Export API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadWorkflowState: loadWorkflowStateMock, + loadEditableWorkflowState: loadWorkflowStateMock, })) vi.doMock('@/lib/copilot/workflow/block-output-utils', () => ({ @@ -101,10 +101,9 @@ describe('Workflow YAML Export API Route', () => { Object.entries(blocks).map(([blockId, block]) => [ blockId, Object.fromEntries( - Object.entries(block?.subBlocks || {}).map(([subBlockId, subBlock]: [string, any]) => [ - subBlockId, - subBlock?.value, - ]) + Object.entries(block?.subBlocks || {}).map( + ([subBlockId, subBlock]: [string, any]) => [subBlockId, subBlock?.value] + ) ), ]) ) @@ -133,40 +132,39 @@ describe('Workflow YAML Export API Route', () => { 'uses the current workflow state and includes variables in the export payload', { timeout: 10_000 }, async () => { - loadWorkflowStateMock.mockResolvedValue({ - blocks: { - 'live-block': { - id: 'live-block', - type: 'agent', - name: 'Live Agent', - position: { x: 0, y: 0 }, - subBlocks: { - prompt: { id: 'prompt', type: 'long-input', value: 'live value' }, + loadWorkflowStateMock.mockResolvedValue({ + blocks: { + 'live-block': { + id: 'live-block', + type: 'agent', + name: 'Live Agent', + position: { x: 0, y: 0 }, + subBlocks: { + prompt: { id: 'prompt', type: 'long-input', value: 'live value' }, + }, + outputs: {}, + enabled: true, }, - outputs: {}, - enabled: true, }, - }, - edges: [], - loops: {}, - parallels: {}, - variables: { - 'live-var': { - id: 'live-var', - workflowId: 'workflow-id', - name: 'liveVar', - type: 'plain', - value: 'live', + edges: [], + loops: {}, + parallels: {}, + variables: { + 'live-var': { + id: 'live-var', + workflowId: 'workflow-id', + name: 'liveVar', + type: 'plain', + value: 'live', + }, }, - }, - lastSaved: Date.now(), - source: 'db', - }) + lastSaved: Date.now(), + }) - const { GET } = await import('@/app/api/workflows/yaml/export/route') - const response = await GET(createRequest()) + const { GET } = await import('@/app/api/workflows/yaml/export/route') + const response = await GET(createRequest()) - expect(response.status).toBe(200) + expect(response.status).toBe(200) expect(makeRequestMock).toHaveBeenCalledWith( '/api/workflow/to-yaml', expect.objectContaining({ @@ -221,7 +219,6 @@ describe('Workflow YAML Export API Route', () => { }, }, lastSaved: Date.now(), - source: 'db', }) const { GET } = await import('@/app/api/workflows/yaml/export/route') diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts index 9e26b4235..11ac15b8d 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts @@ -3,12 +3,12 @@ import { workflow } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { simAgentClient } from '@/lib/copilot/agent/client' +import { extractSubBlockValuesFromBlocks } from '@/lib/copilot/workflow/block-output-utils' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' -import { simAgentClient } from '@/lib/copilot/agent/client' import { generateRequestId } from '@/lib/utils' -import { loadWorkflowState } from '@/lib/workflows/db-helpers' -import { extractSubBlockValuesFromBlocks } from '@/lib/copilot/workflow/block-output-utils' +import { loadEditableWorkflowState } from '@/lib/workflows/db-helpers' import { getAllBlocks } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' import { resolveOutputType } from '@/blocks/utils' @@ -70,9 +70,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const stateWithSource = await loadWorkflowState(workflowId) + const editableState = await loadEditableWorkflowState(workflowId) - if (!stateWithSource) { + if (!editableState) { return NextResponse.json( { success: false, error: 'Workflow has no state data' }, { status: 400 } @@ -81,18 +81,18 @@ export async function GET(request: NextRequest) { const workflowState: any = { deploymentStatuses: {}, - ...(stateWithSource.direction !== undefined ? { direction: stateWithSource.direction } : {}), - blocks: stateWithSource.blocks, - edges: stateWithSource.edges, - loops: stateWithSource.loops, - parallels: stateWithSource.parallels, - variables: stateWithSource.variables || {}, - lastSaved: stateWithSource.lastSaved ?? Date.now(), + ...(editableState.direction !== undefined ? { direction: editableState.direction } : {}), + blocks: editableState.blocks, + edges: editableState.edges, + loops: editableState.loops, + parallels: editableState.parallels, + variables: editableState.variables || {}, + lastSaved: editableState.lastSaved ?? Date.now(), isDeployed: workflowData.isDeployed ?? false, deployedAt: workflowData.deployedAt, } - logger.info(`[${requestId}] Loaded workflow ${workflowId} from ${stateWithSource.source}`, { + logger.info(`[${requestId}] Loaded editable workflow ${workflowId} from Yjs`, { blocksCount: Object.keys(workflowState.blocks).length, edgesCount: workflowState.edges.length, variablesCount: Object.keys(workflowState.variables || {}).length, diff --git a/apps/tradinggoose/hooks/workflow/use-current-workflow.test.tsx b/apps/tradinggoose/hooks/workflow/use-current-workflow.test.tsx index cfbec3819..884607585 100644 --- a/apps/tradinggoose/hooks/workflow/use-current-workflow.test.tsx +++ b/apps/tradinggoose/hooks/workflow/use-current-workflow.test.tsx @@ -68,8 +68,6 @@ describe('useCurrentWorkflow', () => { edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2' }], loops: {}, parallels: {}, - isDeployed: true, - deployedAt: '2026-04-06T00:00:00.000Z', lastSaved: '2026-04-06T01:00:00.000Z', }) @@ -106,8 +104,6 @@ describe('useCurrentWorkflow', () => { expect(currentWorkflow.getEdgeCount()).toBe(1) expect(currentWorkflow.hasBlocks()).toBe(true) expect(currentWorkflow.hasEdges()).toBe(true) - expect(currentWorkflow.isDeployed).toBe(true) - expect(currentWorkflow.deployedAt?.toISOString()).toBe('2026-04-06T00:00:00.000Z') expect(currentWorkflow.lastSaved).toBe(new Date('2026-04-06T01:00:00.000Z').getTime()) }) }) diff --git a/apps/tradinggoose/hooks/workflow/use-current-workflow.ts b/apps/tradinggoose/hooks/workflow/use-current-workflow.ts index 34a2d1fa9..eb30d0fc8 100644 --- a/apps/tradinggoose/hooks/workflow/use-current-workflow.ts +++ b/apps/tradinggoose/hooks/workflow/use-current-workflow.ts @@ -1,8 +1,8 @@ import { useCallback, useMemo } from 'react' -import { useLatestRef } from '@/hooks/use-latest-ref' import type { Edge } from '@xyflow/react' import { resolveStoredDateValue } from '@/lib/time-format' import { useWorkflowDoc } from '@/lib/yjs/use-workflow-doc' +import { useLatestRef } from '@/hooks/use-latest-ref' import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' /** @@ -15,8 +15,6 @@ export interface CurrentWorkflow { loops: Record parallels: Record lastSaved?: number - isDeployed?: boolean - deployedAt?: Date // Helper methods getBlockById: (blockId: string) => BlockState | undefined getBlockCount: () => number @@ -31,48 +29,24 @@ export interface CurrentWorkflow { * Now reads directly from the Yjs document via use-workflow-doc hooks. */ export function useCurrentWorkflow(): CurrentWorkflow { - const { - blocks, - edges, - loops, - parallels, - isDeployed, - deployedAt: rawDeployedAt, - lastSaved: rawLastSaved, - } = useWorkflowDoc() + const { blocks, edges, loops, parallels, lastSaved: rawLastSaved } = useWorkflowDoc() // Keep refs in sync so stable callbacks always read current data const blocksRef = useLatestRef(blocks) const edgesRef = useLatestRef(edges) // Stable helper callbacks that read from refs — their identity never changes - const getBlockById = useCallback( - (blockId: string) => blocksRef.current?.[blockId], - [] - ) - const getBlockCount = useCallback( - () => Object.keys(blocksRef.current || {}).length, - [] - ) - const getEdgeCount = useCallback( - () => (edgesRef.current || []).length, - [] - ) - const hasBlocks = useCallback( - () => Object.keys(blocksRef.current || {}).length > 0, - [] - ) - const hasEdges = useCallback( - () => (edgesRef.current || []).length > 0, - [] - ) + const getBlockById = useCallback((blockId: string) => blocksRef.current?.[blockId], []) + const getBlockCount = useCallback(() => Object.keys(blocksRef.current || {}).length, []) + const getEdgeCount = useCallback(() => (edgesRef.current || []).length, []) + const hasBlocks = useCallback(() => Object.keys(blocksRef.current || {}).length > 0, []) + const hasEdges = useCallback(() => (edgesRef.current || []).length > 0, []) // Create the abstracted interface - optimized to prevent unnecessary re-renders // Note: stable callbacks (getBlockById, etc.) are intentionally omitted from deps // since their identity never changes (empty dep arrays on useCallback). const currentWorkflow = useMemo((): CurrentWorkflow => { const lastSaved = resolveStoredDateValue(rawLastSaved)?.getTime() - const deployedAt = resolveStoredDateValue(rawDeployedAt) const resolvedBlocks = blocks || {} const resolvedEdges = edges || [] @@ -86,8 +60,6 @@ export function useCurrentWorkflow(): CurrentWorkflow { loops: resolvedLoops, parallels: resolvedParallels, lastSaved, - isDeployed, - deployedAt, // Helper methods — stable references from useCallback above getBlockById, getBlockCount, @@ -95,8 +67,8 @@ export function useCurrentWorkflow(): CurrentWorkflow { hasBlocks, hasEdges, } - // eslint-disable-next-line react-hooks/exhaustive-deps -- stable callbacks (getBlockById, etc.) never change - }, [blocks, edges, loops, parallels, rawLastSaved, rawDeployedAt, isDeployed]) + // eslint-disable-next-line react-hooks/exhaustive-deps -- stable callbacks (getBlockById, etc.) never change + }, [blocks, edges, loops, parallels, rawLastSaved]) return currentWorkflow } diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index efeab01f8..4c06b093d 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -198,8 +198,6 @@ export async function loadWorkflowSnapshotForCopilot( id: workflow.id, name: workflow.name, workspaceId: workflow.workspaceId, - isDeployed: workflow.isDeployed, - deployedAt: workflow.deployedAt, }) .from(workflow) .where(eq(workflow.id, workflowId)) @@ -230,11 +228,7 @@ export async function loadWorkflowSnapshotForCopilot( workflowId, entityName: metadata.name ?? workflowRow.name ?? undefined, workspaceId: workflowRow.workspaceId ?? null, - workflowState: { - ...readWorkflowSnapshot(doc), - isDeployed: workflowRow.isDeployed ?? false, - deployedAt: workflowRow.deployedAt?.toISOString(), - }, + workflowState: readWorkflowSnapshot(doc), variables: getVariablesSnapshot(doc), } } finally { diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index df8098655..25ba98a21 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -1017,7 +1017,7 @@ describe('Database Helpers', () => { }) }) - describe('loadWorkflowState', () => { + describe('loadEditableWorkflowState', () => { it('loads saved workflow state through a bootstrapped Yjs session', async () => { const yjsState = { direction: 'LR' as const, @@ -1035,7 +1035,7 @@ describe('Database Helpers', () => { buildWorkflowSnapshotResponseFromState(yjsState, yjsVariables) ) - const result = await dbHelpers.loadWorkflowState(mockWorkflowId) + const result = await dbHelpers.loadEditableWorkflowState(mockWorkflowId) expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalledWith({ workspaceId: null, @@ -1048,7 +1048,6 @@ describe('Database Helpers', () => { expect(result).toMatchObject({ direction: 'LR', variables: yjsVariables, - source: 'yjs', }) expect(mockDb.select).not.toHaveBeenCalled() }) @@ -1071,16 +1070,16 @@ describe('Database Helpers', () => { }, }) - const result = await dbHelpers.loadWorkflowState(mockWorkflowId) + const result = await dbHelpers.loadEditableWorkflowState(mockWorkflowId) expect(result).toBeNull() expect(mockDb.select).not.toHaveBeenCalled() }) - it('does not fall back to direct DB reads when the Yjs bridge fails', async () => { + it('requires the live Yjs bridge for editable workflow state', async () => { mockReadBootstrappedReviewTargetSnapshot.mockRejectedValue(new Error('bridge unavailable')) - await expect(dbHelpers.loadWorkflowState(mockWorkflowId)).rejects.toThrow( + await expect(dbHelpers.loadEditableWorkflowState(mockWorkflowId)).rejects.toThrow( 'bridge unavailable' ) expect(mockDb.select).not.toHaveBeenCalled() diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 47920975e..e9a17e8da 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -17,7 +17,6 @@ import { serializeYjsTransportEnvelope, } from '@/lib/copilot/review-sessions/identity' import { createLogger } from '@/lib/logs/console/logger' -import { resolveStoredDateValue } from '@/lib/time-format' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' import { inferWorkflowDirectionFromState } from '@/lib/workflows/workflow-direction' import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' @@ -95,8 +94,6 @@ export type PersistedWorkflowState = { parallels: Record variables: Record lastSaved: number - isDeployed?: boolean - deployedAt?: string } export async function loadWorkflowStateFromYjs( @@ -140,17 +137,12 @@ function decodeWorkflowSnapshot(snapshotBase64: string): PersistedWorkflowState } } -export type WorkflowStateWithSource = PersistedWorkflowState & { - source: 'yjs' | 'db' -} - -export async function loadWorkflowState( +export async function loadEditableWorkflowState( workflowId: string -): Promise { +): Promise { const { readBootstrappedReviewTargetSnapshot } = await import( '@/lib/yjs/server/bootstrap-review-target' ) - // Existing workflow reads intentionally use the live Yjs bridge; DB is only the bootstrap seed. const snapshot = await readBootstrappedReviewTargetSnapshot({ workspaceId: null, entityKind: 'workflow', @@ -164,7 +156,7 @@ export async function loadWorkflowState( } const state = decodeWorkflowSnapshot(snapshot.snapshotBase64) - return state ? { ...state, source: 'yjs' } : null + return state } export async function loadWorkflowStateFromSavedTables( @@ -178,8 +170,6 @@ export async function loadWorkflowStateFromSavedTables( folderId: workflow.folderId, variables: workflow.variables, updatedAt: workflow.updatedAt, - isDeployed: workflow.isDeployed, - deployedAt: workflow.deployedAt, }) .from(workflow) .where(eq(workflow.id, workflowId)) @@ -201,8 +191,6 @@ export async function loadWorkflowStateFromSavedTables( parallels: normalizedState.parallels, variables: (row.variables as Record) ?? {}, lastSaved: row.updatedAt?.getTime() ?? Date.now(), - isDeployed: row.isDeployed ?? false, - deployedAt: toISOStringOrUndefined(row.deployedAt), } return { @@ -211,19 +199,6 @@ export async function loadWorkflowStateFromSavedTables( } } -/** - * Safely coerce an unknown value (string, number, Date, null/undefined) to an - * ISO-8601 string. Returns `undefined` when the input cannot be converted. - * - * Useful for normalising `lastSaved` / `deployedAt` values that may arrive as - * epoch numbers from Yjs or as Date objects from the database layer. - */ -export function toISOStringOrUndefined( - value: string | number | Date | null | undefined -): string | undefined { - return resolveStoredDateValue(value)?.toISOString() -} - export async function ensureUniqueBlockIds( workflowId: string, state: WorkflowState @@ -907,8 +882,6 @@ export async function saveWorkflowYjsDocToDb(workflowId: string, doc: Y.Doc): Pr loops: state.loops, parallels: state.parallels, lastSaved: syncedAt.toISOString(), - isDeployed: state.isDeployed, - deployedAt: state.deployedAt, } const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState, async (tx) => { @@ -921,16 +894,6 @@ export async function saveWorkflowYjsDocToDb(workflowId: string, doc: Y.Doc): Pr ...(state.description !== undefined ? { description: state.description } : {}), ...(state.folderId !== undefined ? { folderId: state.folderId } : {}), variables: state.variables, - ...(state.isDeployed === undefined - ? {} - : { - isDeployed: state.isDeployed, - deployedAt: state.isDeployed - ? state.deployedAt - ? new Date(state.deployedAt) - : syncedAt - : null, - }), }) .where(eq(workflow.id, workflowId)) .returning({ id: workflow.id }) @@ -975,16 +938,11 @@ export async function deployWorkflow(params: { } = params try { - const stateWithSource = await loadWorkflowState(workflowId) - if (!stateWithSource) { + const editableState = await loadEditableWorkflowState(workflowId) + if (!editableState) { return { success: false, error: 'Failed to load workflow state' } } - const { - source: _source, - isDeployed: _isDeployed, - deployedAt: _deployedAt, - ...currentState - } = stateWithSource + const currentState = editableState const now = new Date() diff --git a/apps/tradinggoose/lib/workflows/execution-runner.test.ts b/apps/tradinggoose/lib/workflows/execution-runner.test.ts index cffa8b399..ff6174aae 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.test.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.test.ts @@ -66,7 +66,7 @@ vi.mock('@/lib/utils-server', () => ({ vi.mock('@/lib/workflows/db-helpers', () => ({ loadDeployedWorkflowState: vi.fn(), - loadWorkflowState: vi.fn(), + loadEditableWorkflowState: vi.fn(), loadWorkflowStateFromYjs: vi.fn(), })) @@ -432,7 +432,7 @@ describe('loadWorkflowExecutionBlueprint', () => { }) it('uses variables from the active deployment for deployed execution', async () => { - const { loadDeployedWorkflowState, loadWorkflowState } = await import( + const { loadDeployedWorkflowState, loadEditableWorkflowState } = await import( '@/lib/workflows/db-helpers' ) const deployedVariables = { @@ -475,6 +475,6 @@ describe('loadWorkflowExecutionBlueprint', () => { unknown > expect(Object.keys(selectShape)).toEqual(['workspaceId']) - expect(loadWorkflowState).not.toHaveBeenCalled() + expect(loadEditableWorkflowState).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts index f0a6fb662..c377f4a6b 100644 --- a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts +++ b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts @@ -144,8 +144,6 @@ describe('studio workflow Mermaid documents', () => { }, parallels: {}, lastSaved: '2026-04-11T00:00:00.000Z', - isDeployed: false, - deployedAt: '2026-04-10T18:00:00.000Z', } const parallelWorkflowState: WorkflowSnapshot = { diff --git a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts index a08c4eedd..a7dda46bd 100644 --- a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts +++ b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts @@ -1220,10 +1220,7 @@ function parseVisibleWorkflowEdges( const sourceBlock = blocks[sourceRef.blockId] const conditionHandlePrefix = `condition-${sourceRef.blockId}-` - if ( - sourceBlock?.type === 'condition' && - !sourceHandle.startsWith(conditionHandlePrefix) - ) { + if (sourceBlock?.type === 'condition' && !sourceHandle.startsWith(conditionHandlePrefix)) { throw new Error( `Workflow graph Mermaid condition edge from "${sourceRef.blockId}" must use canonical sourceHandle "${conditionHandlePrefix}". Use edit_workflow_block to define condition branches before wiring them.` ) @@ -1908,9 +1905,7 @@ export function serializeWorkflowToTgMermaid( options: { direction?: WorkflowDirection } = {} ): string { const direction = - options.direction ?? - workflowState.direction ?? - inferWorkflowDirectionFromState(workflowState) + options.direction ?? workflowState.direction ?? inferWorkflowDirectionFromState(workflowState) const blocks = workflowState.blocks ?? {} const blockIds = Object.keys(blocks).sort((left, right) => left.localeCompare(right)) const aliases = buildAliasMap(blockIds) @@ -1926,8 +1921,6 @@ export function serializeWorkflowToTgMermaid( version: TG_MERMAID_DOCUMENT_FORMAT, direction, ...(workflowState.lastSaved ? { lastSaved: workflowState.lastSaved } : {}), - ...(workflowState.isDeployed !== undefined ? { isDeployed: workflowState.isDeployed } : {}), - ...(workflowState.deployedAt ? { deployedAt: workflowState.deployedAt } : {}), } satisfies WorkflowDocumentMetadata), ] @@ -1972,9 +1965,7 @@ export function serializeWorkflowToGraphMermaid( options: { direction?: WorkflowDirection } = {} ): string { const direction = - options.direction ?? - workflowState.direction ?? - inferWorkflowDirectionFromState(workflowState) + options.direction ?? workflowState.direction ?? inferWorkflowDirectionFromState(workflowState) const blocks = workflowState.blocks ?? {} const blockIds = Object.keys(blocks).sort((left, right) => left.localeCompare(right)) const aliases = buildAliasMap(blockIds) diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index f608dcbc6..5afdc92e3 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -27,13 +27,9 @@ export async function applyWorkflowState( workflowId, await ensureUniqueBlockIds(workflowId, appliedWorkflowState) ) - const { deployedAt, ...storedStateFields } = normalizedWorkflowState const storedWorkflowState = createWorkflowSnapshot({ - ...storedStateFields, + ...normalizedWorkflowState, lastSaved: syncedAt.toISOString(), - ...(deployedAt - ? { deployedAt: typeof deployedAt === 'string' ? deployedAt : deployedAt.toISOString() } - : {}), }) await applyWorkflowStateInSocketServer(workflowId, storedWorkflowState, variables, metadata) diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 46b7e4d54..a0ef7ad2a 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -110,8 +110,6 @@ export async function createSavedReviewTargetBootstrapUpdate( loops: workflowState.loops, parallels: workflowState.parallels, lastSaved: new Date(workflowState.lastSaved).toISOString(), - isDeployed: workflowState.isDeployed, - deployedAt: workflowState.deployedAt, }), YJS_ORIGINS.SYSTEM ) diff --git a/apps/tradinggoose/lib/yjs/use-workflow-doc.ts b/apps/tradinggoose/lib/yjs/use-workflow-doc.ts index a81ca5f8c..166da2666 100644 --- a/apps/tradinggoose/lib/yjs/use-workflow-doc.ts +++ b/apps/tradinggoose/lib/yjs/use-workflow-doc.ts @@ -842,10 +842,10 @@ export function useWorkflowMutations() { if (blockConfig) { const initValues = blockProperties?.initialSubBlockValues - subBlocks = buildInitialSubBlockStates( - blockConfig.subBlocks, - initValues - ) as Record + subBlocks = buildInitialSubBlockStates(blockConfig.subBlocks, initValues) as Record< + string, + SubBlockState + > const runtimeState = resolveBlockRuntimeState({ blockType: type, @@ -1461,18 +1461,6 @@ export function useWorkflowDoc() { edges, loops, parallels, - isDeployed: useYjsMapValue( - session?.doc ?? null, - YJS_KEYS.WORKFLOW, - YJS_KEYS.IS_DEPLOYED, - false - ), - deployedAt: useYjsMapValue( - session?.doc ?? null, - YJS_KEYS.WORKFLOW, - YJS_KEYS.DEPLOYED_AT, - undefined - ), lastSaved: useYjsMapValue( session?.doc ?? null, YJS_KEYS.WORKFLOW, diff --git a/apps/tradinggoose/lib/yjs/workflow-session.ts b/apps/tradinggoose/lib/yjs/workflow-session.ts index 8ab301499..8a605dc2f 100644 --- a/apps/tradinggoose/lib/yjs/workflow-session.ts +++ b/apps/tradinggoose/lib/yjs/workflow-session.ts @@ -5,7 +5,7 @@ * and provides helpers to read/write the live workflow state. * * Top-level collections: - * - "workflow" (Y.Map) — blocks, edges, loops, parallels, deployment metadata + * - "workflow" (Y.Map) — editable blocks, edges, loops, parallels, and save timestamp * - "textFields" (Y.Map) — text-heavy subblock values keyed by blockId/subBlockId * - "variables" (Y.Map) — per-workflow variable records keyed by variable id * - "metadata" (Y.Map) — session-level workflow metadata (e.g. reseed markers) @@ -37,8 +37,6 @@ export const YJS_KEYS = { PARALLELS: 'parallels', DIRECTION: 'direction', LAST_SAVED: 'lastSaved', - IS_DEPLOYED: 'isDeployed', - DEPLOYED_AT: 'deployedAt', } as const const WORKFLOW_TEXT_FIELD_SEPARATOR = '::' @@ -240,8 +238,6 @@ export interface WorkflowSnapshot { loops: Record parallels: Record lastSaved?: string - isDeployed?: boolean - deployedAt?: string } export type WorkflowMetadataPatch = { @@ -269,8 +265,6 @@ function applySnapshotDefaults(partial: Partial): WorkflowSnap loops: partial.loops ?? {}, parallels: partial.parallels ?? {}, lastSaved: partial.lastSaved, - isDeployed: partial.isDeployed, - deployedAt: partial.deployedAt, } } @@ -306,8 +300,6 @@ export function readWorkflowSnapshot(doc: Y.Doc): WorkflowSnapshot { loops: wMap.get(YJS_KEYS.LOOPS) ?? {}, parallels: wMap.get(YJS_KEYS.PARALLELS) ?? {}, lastSaved: wMap.get(YJS_KEYS.LAST_SAVED), - isDeployed: wMap.get(YJS_KEYS.IS_DEPLOYED), - deployedAt: wMap.get(YJS_KEYS.DEPLOYED_AT), }) } @@ -333,16 +325,13 @@ export function readWorkflowSnapshotCloned(doc: Y.Doc): WorkflowSnapshot { loops, parallels, lastSaved: wMap.get(YJS_KEYS.LAST_SAVED), - isDeployed: wMap.get(YJS_KEYS.IS_DEPLOYED), - deployedAt: wMap.get(YJS_KEYS.DEPLOYED_AT), }) } /** * Applies a full workflow state to the Yjs document inside a single - * transaction. Optional fields (lastSaved, isDeployed, deployedAt) are only - * written when present in the incoming state so callers can do partial - * updates by omitting them. + * transaction. Optional lastSaved is only written when present so callers can + * do partial updates by omitting it. * * @param origin - Yjs transaction origin tag (defaults to `'system'`) */ @@ -356,8 +345,6 @@ export function setWorkflowState(doc: Y.Doc, state: WorkflowSnapshot, origin?: s wMap.set(YJS_KEYS.LOOPS, state.loops ?? {}) wMap.set(YJS_KEYS.PARALLELS, state.parallels ?? {}) if (state.lastSaved !== undefined) wMap.set(YJS_KEYS.LAST_SAVED, state.lastSaved) - if (state.isDeployed !== undefined) wMap.set(YJS_KEYS.IS_DEPLOYED, state.isDeployed) - if (state.deployedAt !== undefined) wMap.set(YJS_KEYS.DEPLOYED_AT, state.deployedAt) for (const key of Array.from(textFields.keys())) { const parsed = parseWorkflowTextFieldKey(key) @@ -545,8 +532,6 @@ export interface PersistedDocState { parallels: Record variables: Record lastSaved: number - isDeployed?: boolean - deployedAt?: string } export function extractPersistedStateFromDoc(doc: Y.Doc): PersistedDocState { @@ -554,7 +539,6 @@ export function extractPersistedStateFromDoc(doc: Y.Doc): PersistedDocState { const metadata = readWorkflowEntityMetadata(doc) const variables = getVariablesSnapshot(doc) const lastSaved = resolveStoredDateValue(snapshot.lastSaved)?.getTime() ?? Date.now() - const deployedAt = resolveStoredDateValue(snapshot.deployedAt)?.toISOString() return { ...metadata, @@ -565,7 +549,5 @@ export function extractPersistedStateFromDoc(doc: Y.Doc): PersistedDocState { parallels: snapshot.parallels || {}, variables: variables || {}, lastSaved, - ...(snapshot.isDeployed !== undefined ? { isDeployed: snapshot.isDeployed } : {}), - ...(deployedAt ? { deployedAt } : {}), } } diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 08479ebab..acc6117ee 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -511,7 +511,6 @@ describe('Socket Server Index Integration', () => { loops: {}, parallels: {}, lastSaved: '2026-04-06T00:00:00.000Z', - isDeployed: false, }, 'test' ) diff --git a/apps/tradinggoose/stores/workflows/index.ts b/apps/tradinggoose/stores/workflows/index.ts index 914a4deb0..bb410565b 100644 --- a/apps/tradinggoose/stores/workflows/index.ts +++ b/apps/tradinggoose/stores/workflows/index.ts @@ -14,8 +14,6 @@ function getYjsWorkflowState(workflowId: string): WorkflowState | null { loops: snapshot.loops ?? {}, parallels: snapshot.parallels ?? {}, lastSaved: snapshot.lastSaved, - isDeployed: snapshot.isDeployed, - deployedAt: snapshot.deployedAt, } as WorkflowState } @@ -154,4 +152,3 @@ export { useWorkflowRegistry } from '@/stores/workflows/registry/store' export type { WorkflowMetadata } from '@/stores/workflows/registry/types' export { mergeSubblockState } from '@/stores/workflows/utils' export type { WorkflowState } from '@/stores/workflows/workflow/types' - From bcb765a11aa8bb073098b17b4827fba53695173c Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 03:18:56 -0600 Subject: [PATCH 167/284] fix(mcp): always authenticate installer sessions Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/mcp/[[...command]]/route.test.ts | 12 ++-- apps/tradinggoose/lib/mcp/install-script.ts | 43 +----------- .../mcp/local-config-writer-script.test.ts | 45 ------------- .../lib/mcp/local-config-writer-script.ts | 66 ------------------- 4 files changed, 9 insertions(+), 157 deletions(-) diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts index cdc31cb4e..d4cda0a66 100644 --- a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts @@ -59,12 +59,12 @@ describe('MCP install route', () => { expect(script).not.toContain('confirmLogin') expect(script).not.toContain('confirm: true') expect(script).toContain("baseUrl + '/api/copilot/mcp'") - expect(script).toContain("method: 'ping'") - expect(script).toContain('async function isTokenValid(token)') - expect(script).toContain('async function resolveAuthToken()') + expect(script).not.toContain("method: 'ping'") + expect(script).not.toContain('async function isTokenValid(token)') + expect(script).not.toContain('async function resolveAuthToken()') expect(script).toContain("Authorization: Bearer ' + token") expect(script).toContain('setup Write MCP config, authenticating when needed.') - expect(script).toContain('read-tokens') + expect(script).not.toContain('read-tokens') expect(script).toContain('node - "$BASE_URL" "$COMMAND" "$TARGETS"') expect(script).toContain('runConfigWriter([target, mcpUrl, token])') expect(script).toContain("const mcpServerName = 'TradingGoose'") @@ -148,8 +148,8 @@ describe('MCP install route', () => { expect(script).toContain('irm /mcp/setup | iex') expect(script).toContain("$NodeScript | & node - $BaseUrl $Command ($Targets -join ' ')") expect(script).toContain("baseUrl + '/api/auth/mcp/start'") - expect(script).toContain("runConfigWriter(['read-tokens'])") - expect(script).toContain("method: 'ping'") + expect(script).not.toContain("runConfigWriter(['read-tokens'])") + expect(script).not.toContain("method: 'ping'") expect(script).toContain("const mcpServerName = 'TradingGoose'") expect(script).toContain("'[mcp_servers.' + mcpServerName + '.http_headers]'") expect(script).toContain("'Authorization = ' + JSON.stringify('Bearer ' + token)") diff --git a/apps/tradinggoose/lib/mcp/install-script.ts b/apps/tradinggoose/lib/mcp/install-script.ts index 77ed4753a..517518c44 100644 --- a/apps/tradinggoose/lib/mcp/install-script.ts +++ b/apps/tradinggoose/lib/mcp/install-script.ts @@ -50,12 +50,11 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } -async function postJson(url, body, token) { +async function postJson(url, body) { const response = await fetch(url, { method: 'POST', headers: { ...(body ? { 'content-type': 'application/json' } : {}), - ...(token ? { authorization: 'Bearer ' + token } : {}), }, ...(body ? { body: JSON.stringify(body) } : {}), }) @@ -82,42 +81,6 @@ function runConfigWriter(args) { return result.stdout.trim() } -function readExistingTokens() { - return runConfigWriter(['read-tokens']).split(/\r?\n/).filter(Boolean) -} - -async function isTokenValid(token) { - const response = await fetch(mcpUrl, { - method: 'POST', - headers: { - 'content-type': 'application/json', - authorization: 'Bearer ' + token, - }, - body: JSON.stringify({ jsonrpc: '2.0', id: 'auth-check', method: 'ping' }), - }) - - return response.ok -} - -async function readValidExistingToken() { - const tokens = readExistingTokens() - for (const token of tokens) { - if (await isTokenValid(token)) { - return token - } - } - return null -} - -async function resolveAuthToken() { - const existingToken = await readValidExistingToken() - if (existingToken) { - return existingToken - } - - return authenticate() -} - async function authenticate() { const startJson = await postJson(baseUrl + '/api/auth/mcp/start') const code = String(startJson?.code || '') @@ -170,7 +133,7 @@ async function main() { requireFetch() if (command === 'login') { - const token = await resolveAuthToken() + const token = await authenticate() console.log('MCP endpoint:') console.log(mcpUrl) console.log('') @@ -187,7 +150,7 @@ async function main() { fail('setup requires a selected target') } - const token = await resolveAuthToken() + const token = await authenticate() console.log('Using MCP endpoint: ' + mcpUrl) for (const target of targets) { const configPath = runConfigWriter([target, mcpUrl, token]) diff --git a/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts b/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts index f53ebf0ae..7cd03bc7f 100644 --- a/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts +++ b/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts @@ -29,31 +29,6 @@ function runWriter(home: string, args: string[]) { return result.stdout } -function shellQuote(value: string) { - return `'${value.replaceAll("'", "'\\''")}'` -} - -function runWriterCapture(home: string, args: string[]) { - const scriptPath = join(home, 'writer.js') - const outputPath = join(home, 'writer.out') - writeFileSync(scriptPath, MCP_LOCAL_CONFIG_WRITER_SCRIPT, 'utf8') - const command = `node ${shellQuote(scriptPath)} ${args.map(shellQuote).join(' ')} > ${shellQuote(outputPath)}` - const result = spawnSync('sh', ['-c', command], { - cwd: home, - encoding: 'utf8', - env: { - ...process.env, - HOME: home, - USERPROFILE: home, - }, - timeout: 5000, - }) - - expect(result.status).toBe(0) - expect(result.stderr).toBe('') - return readFileSync(outputPath, 'utf8') -} - describe('MCP local config writer script', () => { it('writes Codex config with TradingGoose HTTP headers', () => { const home = mkdtempSync(join(tmpdir(), 'tg-mcp-codex-')) @@ -100,26 +75,6 @@ describe('MCP local config writer script', () => { expect(config).not.toContain('bearer_token_env_var') }) - it('reads Codex bearer token from the configured HTTP headers', () => { - const home = mkdtempSync(join(tmpdir(), 'tg-mcp-codex-token-')) - runWriter(home, ['codex', 'http://localhost:3000/api/copilot/mcp', 'existing-token']) - - const stdout = runWriterCapture(home, ['read-tokens']) - - expect(stdout.trim()).toBe('existing-token') - }) - - it('skips malformed JSON client configs while discovering existing tokens', () => { - const home = mkdtempSync(join(tmpdir(), 'tg-mcp-token-malformed-')) - mkdirSync(join(home, '.cursor'), { recursive: true }) - writeFileSync(join(home, '.cursor', 'mcp.json'), '{', 'utf8') - runWriter(home, ['claude', 'http://localhost:3000/api/copilot/mcp', 'valid-token']) - - const stdout = runWriterCapture(home, ['read-tokens']) - - expect(stdout.trim()).toBe('valid-token') - }) - it('writes JSON client configs with the TradingGoose server name', () => { const home = mkdtempSync(join(tmpdir(), 'tg-mcp-cursor-')) const configPath = join(home, '.cursor', 'mcp.json') diff --git a/apps/tradinggoose/lib/mcp/local-config-writer-script.ts b/apps/tradinggoose/lib/mcp/local-config-writer-script.ts index 5ca462af6..a87ebdcdc 100644 --- a/apps/tradinggoose/lib/mcp/local-config-writer-script.ts +++ b/apps/tradinggoose/lib/mcp/local-config-writer-script.ts @@ -5,7 +5,6 @@ const path = require('path') const target = process.argv[2] const mcpUrl = process.argv[3] const token = process.argv[4] -const allTargets = ['codex', 'cursor', 'claude', 'opencode'] const mcpServerName = 'TradingGoose' function resolvePathFor(candidate) { @@ -102,71 +101,6 @@ function writeJsonConfig(filePath, section, entry) { fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n', 'utf8') } -function bearerTokenFromHeader(value) { - if (typeof value !== 'string') { - return null - } - const match = value.match(/^Bearer\s+(.+)$/) - return match ? match[1] : null -} - -function readCodexToken(filePath) { - if (!fs.existsSync(filePath)) { - return null - } - const text = fs.readFileSync(filePath, 'utf8') - const section = findTomlSection(text, '[mcp_servers.' + mcpServerName + '.http_headers]') - const authHeader = section?.match(/(?:^|\n)Authorization\s*=\s*["']([^"']+)["']/) - return authHeader ? bearerTokenFromHeader(authHeader[1]) : null -} - -function findTomlSection(text, sectionHeader) { - const startIndex = text.indexOf(sectionHeader) - if (startIndex === -1) { - return null - } - const rest = text.slice(startIndex + sectionHeader.length) - const nextHeaderIndex = rest.search(/\n\[/) - return nextHeaderIndex === -1 ? rest : rest.slice(0, nextHeaderIndex) -} - -function readJsonToken(filePath, section) { - let config - try { - config = readJson(filePath) - } catch { - return null - } - return bearerTokenFromHeader(config?.[section]?.[mcpServerName]?.headers?.Authorization) -} - -function readTargetToken(candidate) { - const filePath = resolvePathFor(candidate) - switch (candidate) { - case 'codex': - return readCodexToken(filePath) - case 'cursor': - case 'claude': - return readJsonToken(filePath, 'mcpServers') - case 'opencode': - return readJsonToken(filePath, 'mcp') - default: - throw new Error('Unsupported setup target: ' + candidate) - } -} - -if (target === 'read-tokens') { - const seen = new Set() - for (const candidate of allTargets) { - const existingToken = readTargetToken(candidate) - if (existingToken && !seen.has(existingToken)) { - seen.add(existingToken) - console.log(existingToken) - } - } - process.exit(0) -} - const filePath = resolvePath() const authHeaders = { Authorization: 'Bearer ' + token } switch (target) { From 89e56434ec439ea3bec5f7823a2966e24bd3a71e Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 10:58:36 -0600 Subject: [PATCH 168/284] feat(mcp): acknowledge device login token activation Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/auth/mcp/poll/route.test.ts | 35 +++++++- .../app/api/auth/mcp/poll/route.ts | 12 ++- .../app/mcp/[[...command]]/route.test.ts | 17 ++-- apps/tradinggoose/lib/mcp/auth.test.ts | 32 +++++--- apps/tradinggoose/lib/mcp/auth.ts | 81 +++++++++++++++---- apps/tradinggoose/lib/mcp/install-script.ts | 26 ++++-- 6 files changed, 162 insertions(+), 41 deletions(-) diff --git a/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts index 45bf25a3b..23aaed3d0 100644 --- a/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts @@ -5,7 +5,12 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckPublicApiEndpointRateLimit, mockPollMcpDeviceLogin } = vi.hoisted(() => ({ +const { + mockAcknowledgeMcpDeviceLogin, + mockCheckPublicApiEndpointRateLimit, + mockPollMcpDeviceLogin, +} = vi.hoisted(() => ({ + mockAcknowledgeMcpDeviceLogin: vi.fn(), mockCheckPublicApiEndpointRateLimit: vi.fn(), mockPollMcpDeviceLogin: vi.fn(), })) @@ -16,6 +21,7 @@ vi.mock('@/lib/api/rate-limit', () => ({ })) vi.mock('@/lib/mcp/auth', () => ({ + acknowledgeMcpDeviceLogin: (...args: unknown[]) => mockAcknowledgeMcpDeviceLogin(...args), pollMcpDeviceLogin: (...args: unknown[]) => mockPollMcpDeviceLogin(...args), })) @@ -33,6 +39,9 @@ describe('MCP login poll route', () => { apiKey: 'sk-tradinggoose-token', expiresAt: '2026-06-19T12:00:00.000Z', }) + mockAcknowledgeMcpDeviceLogin.mockResolvedValue({ + status: 'acknowledged', + }) }) it('polls the device login by code and verification key', async () => { @@ -52,6 +61,30 @@ describe('MCP login poll route', () => { }) expect(mockCheckPublicApiEndpointRateLimit).toHaveBeenCalledWith(request, 'mcp-auth-poll') expect(mockPollMcpDeviceLogin).toHaveBeenCalledWith('login-code', 'verification-key') + expect(mockAcknowledgeMcpDeviceLogin).not.toHaveBeenCalled() + }) + + it('acknowledges a locally persisted device login token', async () => { + const { POST } = await import('./route') + const request = new NextRequest('https://studio.example.test/api/auth/mcp/poll', { + method: 'POST', + body: JSON.stringify({ + code: 'login-code', + verificationKey: 'verification-key', + ackApiKey: 'sk-tradinggoose-token', + }), + }) + + const response = await POST(request) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ status: 'acknowledged' }) + expect(mockAcknowledgeMcpDeviceLogin).toHaveBeenCalledWith({ + apiKey: 'sk-tradinggoose-token', + code: 'login-code', + verificationKey: 'verification-key', + }) + expect(mockPollMcpDeviceLogin).not.toHaveBeenCalled() }) it('rejects malformed poll requests', async () => { diff --git a/apps/tradinggoose/app/api/auth/mcp/poll/route.ts b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts index aaf6a7fa3..87501360b 100644 --- a/apps/tradinggoose/app/api/auth/mcp/poll/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts @@ -1,7 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkPublicApiEndpointRateLimit } from '@/lib/api/rate-limit' -import { pollMcpDeviceLogin } from '@/lib/mcp/auth' +import { acknowledgeMcpDeviceLogin, pollMcpDeviceLogin } from '@/lib/mcp/auth' export const dynamic = 'force-dynamic' @@ -9,6 +9,7 @@ const PollRequestSchema = z .object({ code: z.string().min(1), verificationKey: z.string().min(1), + ackApiKey: z.string().min(1).optional(), }) .strict() @@ -23,6 +24,13 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid MCP login poll request' }, { status: 400 }) } - const result = await pollMcpDeviceLogin(parsed.data.code, parsed.data.verificationKey) + const result = + parsed.data.ackApiKey !== undefined + ? await acknowledgeMcpDeviceLogin({ + apiKey: parsed.data.ackApiKey, + code: parsed.data.code, + verificationKey: parsed.data.verificationKey, + }) + : await pollMcpDeviceLogin(parsed.data.code, parsed.data.verificationKey) return NextResponse.json(result) } diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts index d4cda0a66..7d0e78e3b 100644 --- a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts @@ -55,18 +55,20 @@ describe('MCP install route', () => { expect(script).toContain("baseUrl + '/api/auth/mcp/start'") expect(script).toContain("baseUrl + '/api/auth/mcp/poll'") expect(script).toContain('const verificationKey = String(startJson?.verificationKey ||') - expect(script).toContain('return token') + expect(script).toContain('return { code, verificationKey, token }') + expect(script).toContain('async function acknowledge(login)') + expect(script).toContain('ackApiKey: login.token') expect(script).not.toContain('confirmLogin') expect(script).not.toContain('confirm: true') expect(script).toContain("baseUrl + '/api/copilot/mcp'") expect(script).not.toContain("method: 'ping'") expect(script).not.toContain('async function isTokenValid(token)') expect(script).not.toContain('async function resolveAuthToken()') - expect(script).toContain("Authorization: Bearer ' + token") + expect(script).toContain("Authorization: Bearer ' + login.token") expect(script).toContain('setup Write MCP config, authenticating when needed.') expect(script).not.toContain('read-tokens') expect(script).toContain('node - "$BASE_URL" "$COMMAND" "$TARGETS"') - expect(script).toContain('runConfigWriter([target, mcpUrl, token])') + expect(script).toContain('runConfigWriter([target, mcpUrl, login.token])') expect(script).toContain("const mcpServerName = 'TradingGoose'") expect(script).toContain("'[mcp_servers.' + mcpServerName + '.http_headers]'") expect(script).toContain("'Authorization = ' + JSON.stringify('Bearer ' + token)") @@ -80,14 +82,16 @@ describe('MCP install route', () => { expect(script).not.toContain('workspaceId') expect(script).not.toContain('entityId') - const printedTokenIndex = script.indexOf("console.log('Authorization: Bearer ' + token)") - const firstReturnTokenIndex = script.indexOf('return token') + const printedTokenIndex = script.indexOf("console.log('Authorization: Bearer ' + login.token)") + const firstReturnTokenIndex = script.indexOf('return { code, verificationKey, token }') const setupIndex = script.indexOf("if (command === 'setup')") const configWriteIndex = script.indexOf( - 'const configPath = runConfigWriter([target, mcpUrl, token])' + 'const configPath = runConfigWriter([target, mcpUrl, login.token])' ) + const acknowledgeIndex = script.indexOf('await acknowledge(login)', configWriteIndex) expect(printedTokenIndex).toBeGreaterThan(firstReturnTokenIndex) expect(configWriteIndex).toBeGreaterThan(setupIndex) + expect(acknowledgeIndex).toBeGreaterThan(configWriteIndex) }) it('serves target-specific setup scripts from the URL path', async () => { @@ -148,6 +152,7 @@ describe('MCP install route', () => { expect(script).toContain('irm /mcp/setup | iex') expect(script).toContain("$NodeScript | & node - $BaseUrl $Command ($Targets -join ' ')") expect(script).toContain("baseUrl + '/api/auth/mcp/start'") + expect(script).toContain('ackApiKey: login.token') expect(script).not.toContain("runConfigWriter(['read-tokens'])") expect(script).not.toContain("method: 'ping'") expect(script).toContain("const mcpServerName = 'TradingGoose'") diff --git a/apps/tradinggoose/lib/mcp/auth.test.ts b/apps/tradinggoose/lib/mcp/auth.test.ts index ee5a98e17..f6624a357 100644 --- a/apps/tradinggoose/lib/mcp/auth.test.ts +++ b/apps/tradinggoose/lib/mcp/auth.test.ts @@ -4,15 +4,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { db } = vi.hoisted(() => ({ - db: { - select: vi.fn(), - insert: vi.fn(), - delete: vi.fn(), - update: vi.fn(), - transaction: vi.fn(), - }, -})) +const { db, mockCreateApiKey, mockEncryptApiKeyForStorage, mockIsApiKeyFormat } = vi.hoisted( + () => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + delete: vi.fn(), + update: vi.fn(), + transaction: vi.fn(), + }, + mockCreateApiKey: vi.fn(), + mockEncryptApiKeyForStorage: vi.fn(), + mockIsApiKeyFormat: vi.fn(), + }) +) const verification = Object.fromEntries( ['id', 'identifier', 'value', 'expiresAt', 'createdAt', 'updatedAt'].map((field) => [ @@ -29,7 +34,11 @@ vi.mock('drizzle-orm', () => ({ like: vi.fn((field, value) => ({ field, value })), lte: vi.fn((field, value) => ({ field, value })), })) -vi.mock('@/lib/api-key/service', () => ({ createApiKey: vi.fn() })) +vi.mock('@/lib/api-key/service', () => ({ + createApiKey: mockCreateApiKey, + encryptApiKeyForStorage: mockEncryptApiKeyForStorage, + isApiKeyFormat: mockIsApiKeyFormat, +})) vi.mock('@/lib/env', () => ({ env: { INTERNAL_API_SECRET: '12345678901234567890123456789012' } })) vi.mock('@/lib/urls/utils', () => ({ getBaseUrl: vi.fn(() => 'https://studio.example.test') })) @@ -67,6 +76,9 @@ describe('MCP device login auth', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-06-19T12:00:00.000Z')) vi.clearAllMocks() + mockCreateApiKey.mockResolvedValue({ key: `sk-tradinggoose-${'a'.repeat(32)}` }) + mockEncryptApiKeyForStorage.mockResolvedValue('encrypted-api-key') + mockIsApiKeyFormat.mockReturnValue(true) mockDelete() selectRows() }) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 7310d1ee9..c8fc6180e 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -3,7 +3,7 @@ import { db } from '@tradinggoose/db' import { apiKey, verification } from '@tradinggoose/db/schema' import { and, eq, like, lte } from 'drizzle-orm' import { nanoid } from 'nanoid' -import { createApiKey } from '@/lib/api-key/service' +import { createApiKey, encryptApiKeyForStorage, isApiKeyFormat } from '@/lib/api-key/service' import { env } from '@/lib/env' import { getBaseUrl } from '@/lib/urls/utils' @@ -23,6 +23,7 @@ type ApprovedDeviceLogin = { verificationKeyHash: string approvedAt: string userId: string + apiKeyHash?: string deliveredAt?: string } @@ -43,6 +44,11 @@ export type McpDeviceLoginPollResult = | { status: 'invalid' } | { status: 'expired' } +export type McpDeviceLoginAckResult = + | { status: 'acknowledged' } + | { status: 'invalid' } + | { status: 'expired' } + export type McpDeviceLoginApprovalResult = | { status: 'approved'; expiresAt: string } | { status: 'expired' } @@ -191,6 +197,7 @@ function parseDeviceLoginState(value: string): DeviceLoginState | null { typeof parsed.verificationKeyHash === 'string' && typeof parsed.approvedAt === 'string' && typeof parsed.userId === 'string' && + (parsed.apiKeyHash === undefined || typeof parsed.apiKeyHash === 'string') && (parsed.deliveredAt === undefined || typeof parsed.deliveredAt === 'string') ) { return parsed as ApprovedDeviceLogin @@ -376,12 +383,62 @@ export async function pollMcpDeviceLogin( return { status: 'expired' } } - const approvedState = login.state - const now = new Date() - const { key, encryptedKey } = await createApiKey(true) - if (!encryptedKey) { - throw new Error('Failed to encrypt API key for storage') + const { key } = await createApiKey(false) + const nextState = { + ...login.state, + apiKeyHash: hashValue(key), + } satisfies ApprovedDeviceLogin + if (!(await updateDeviceLoginState(login, nextState))) { + return { + status: 'pending', + intervalSeconds: POLL_INTERVAL_SECONDS, + expiresAt: login.expiresAt.toISOString(), + } } + + return { + status: 'approved', + apiKey: key, + expiresAt: login.expiresAt.toISOString(), + } +} + +export async function acknowledgeMcpDeviceLogin({ + apiKey: plainApiKey, + code, + verificationKey, +}: { + apiKey: string + code: string + verificationKey: string +}): Promise { + if (!isApiKeyFormat(plainApiKey)) { + return { status: 'invalid' } + } + + const login = await readDeviceLogin(code) + if (!login) { + return { status: 'expired' } + } + + if (login.state.verificationKeyHash !== hashValue(verificationKey)) { + return { status: 'invalid' } + } + + if (login.state.status === 'approved' && login.state.deliveredAt) { + return { status: 'expired' } + } + + if (login.state.status !== 'approved') { + return { status: 'invalid' } + } + if (login.state.apiKeyHash !== hashValue(plainApiKey)) { + return { status: 'invalid' } + } + + const now = new Date() + const { apiKeyHash: _apiKeyHash, ...approvedState } = login.state + const encryptedKey = await encryptApiKeyForStorage(plainApiKey) const delivered = await db.transaction(async (tx) => { const [updated] = await tx .update(verification) @@ -410,18 +467,10 @@ export async function pollMcpDeviceLogin( return true }) if (!delivered) { - return { - status: 'pending', - intervalSeconds: POLL_INTERVAL_SECONDS, - expiresAt: login.expiresAt.toISOString(), - } + return { status: 'invalid' } } - return { - status: 'approved', - apiKey: key, - expiresAt: login.expiresAt.toISOString(), - } + return { status: 'acknowledged' } } export async function approveMcpDeviceLogin({ approvalToken, diff --git a/apps/tradinggoose/lib/mcp/install-script.ts b/apps/tradinggoose/lib/mcp/install-script.ts index 517518c44..70687d5c1 100644 --- a/apps/tradinggoose/lib/mcp/install-script.ts +++ b/apps/tradinggoose/lib/mcp/install-script.ts @@ -112,7 +112,7 @@ async function authenticate() { if (!token) { fail('Studio approved login without returning a token') } - return token + return { code, verificationKey, token } } if (status === 'expired') { @@ -129,19 +129,32 @@ async function authenticate() { fail('Timed out waiting for browser approval') } +async function acknowledge(login) { + const ackJson = await postJson(baseUrl + '/api/auth/mcp/poll', { + code: login.code, + verificationKey: login.verificationKey, + ackApiKey: login.token, + }) + const status = String(ackJson?.status || '') + if (status !== 'acknowledged') { + fail('Studio did not activate the MCP token: ' + (status || 'unknown')) + } +} + async function main() { requireFetch() if (command === 'login') { - const token = await authenticate() + const login = await authenticate() + await acknowledge(login) console.log('MCP endpoint:') console.log(mcpUrl) console.log('') console.log('Bearer token:') - console.log(token) + console.log(login.token) console.log('') console.log('Use this MCP auth header:') - console.log('Authorization: Bearer ' + token) + console.log('Authorization: Bearer ' + login.token) return } @@ -150,12 +163,13 @@ async function main() { fail('setup requires a selected target') } - const token = await authenticate() + const login = await authenticate() console.log('Using MCP endpoint: ' + mcpUrl) for (const target of targets) { - const configPath = runConfigWriter([target, mcpUrl, token]) + const configPath = runConfigWriter([target, mcpUrl, login.token]) console.log('Configured ' + target + ': ' + configPath) } + await acknowledge(login) return } From 72722ac468565c69dc40a3aa7bceaf8bc2ca2cb0 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 10:59:47 -0600 Subject: [PATCH 169/284] refactor(workflows): read editable state from Yjs sessions Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/workflows/[id]/autolayout/route.ts | 4 ++-- .../app/api/workflows/[id]/deploy/route.test.ts | 2 +- .../app/api/workflows/[id]/deploy/route.ts | 4 ++-- .../app/api/workflows/[id]/duplicate/route.test.ts | 2 +- .../app/api/workflows/[id]/duplicate/route.ts | 4 ++-- apps/tradinggoose/app/api/workflows/[id]/route.test.ts | 2 +- apps/tradinggoose/app/api/workflows/[id]/route.ts | 10 ++++------ .../app/api/workflows/[id]/status/route.test.ts | 2 +- .../app/api/workflows/[id]/status/route.ts | 4 ++-- .../app/api/workflows/yaml/export/route.test.ts | 2 +- .../app/api/workflows/yaml/export/route.ts | 4 ++-- apps/tradinggoose/lib/workflows/db-helpers.test.ts | 10 +++++----- apps/tradinggoose/lib/workflows/db-helpers.ts | 10 +++++++--- .../lib/workflows/execution-runner.test.ts | 6 +++--- .../lib/yjs/server/bootstrap-review-target.ts | 4 ++-- 15 files changed, 36 insertions(+), 34 deletions(-) diff --git a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts index 2c681f40f..5cc1c7fd0 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applyAutoLayout } from '@/lib/workflows/autolayout' -import { loadEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { loadWorkflowStateFromYjsSession } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' export const dynamic = 'force-dynamic' @@ -61,7 +61,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } } else { logger.info(`[${requestId}] Loading blocks from current workflow state`) - currentWorkflowData = await loadEditableWorkflowState(workflowId) + currentWorkflowData = await loadWorkflowStateFromYjsSession(workflowId) } if (!currentWorkflowData) { diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts index c7047ec4e..077699666 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts @@ -37,7 +37,7 @@ describe('Workflow Deploy API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ deployWorkflow: vi.fn(), - loadEditableWorkflowState: (...args: unknown[]) => mockLoadWorkflowState(...args), + loadWorkflowStateFromYjsSession: (...args: unknown[]) => mockLoadWorkflowState(...args), })) vi.doMock('@/lib/chat/published-deployment', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts index 551f60109..3d7181af7 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts @@ -7,7 +7,7 @@ import { } from '@/lib/chat/published-deployment' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { deployWorkflow, loadEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { deployWorkflow, loadWorkflowStateFromYjsSession } from '@/lib/workflows/db-helpers' import { hasWorkflowChanged, validateWorkflowPermissions } from '@/lib/workflows/utils' import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' import { pauseMonitorsMissingDeployedTrigger } from '@/app/api/monitors/shared' @@ -99,7 +99,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .limit(1) if (active?.state) { - const currentState = await loadEditableWorkflowState(id) + const currentState = await loadWorkflowStateFromYjsSession(id) if (currentState) { needsRedeployment = hasWorkflowChanged(currentState, active.state as any) } diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts index 13bb006ce..181d74f50 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts @@ -110,7 +110,7 @@ describe('Workflow Duplicate API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadEditableWorkflowState: loadWorkflowStateMock, + loadWorkflowStateFromYjsSession: loadWorkflowStateMock, regenerateWorkflowStateIds: regenerateWorkflowStateIdsMock, saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, })) diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts index 9d34c2ff2..5d38d977e 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -9,7 +9,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' import { - loadEditableWorkflowState, + loadWorkflowStateFromYjsSession, regenerateWorkflowStateIds, saveWorkflowToNormalizedTables, } from '@/lib/workflows/db-helpers' @@ -31,7 +31,7 @@ async function loadSourceWorkflowArtifacts(sourceWorkflowId: string): Promise<{ workflowState: WorkflowState variables: Record }> { - const editableState = await loadEditableWorkflowState(sourceWorkflowId) + const editableState = await loadWorkflowStateFromYjsSession(sourceWorkflowId) if (!editableState) { throw new Error('Failed to load source workflow state') } diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index 1d423083a..ef780d044 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -34,7 +34,7 @@ describe('Workflow By ID API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadEditableWorkflowState: mockLoadWorkflowState, + loadWorkflowStateFromYjsSession: mockLoadWorkflowState, })) vi.doMock('@tradinggoose/db', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index d245f717e..c9c886424 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -9,7 +9,7 @@ import { verifyInternalTokenDetailed } from '@/lib/auth/internal' import { hydrateListingUI } from '@/lib/listing/hydrate-ui' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { loadEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { loadWorkflowStateFromYjsSession } from '@/lib/workflows/db-helpers' import { readWorkflowAccessContext, readWorkflowById } from '@/lib/workflows/utils' import { applyWorkflowMetadata } from '@/lib/yjs/server/apply-workflow-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' @@ -28,7 +28,7 @@ const UpdateWorkflowSchema = z /** * GET /api/workflows/[id] * Fetch a single workflow by ID - * Uses the current workflow state loader. + * Reads through the editable Yjs session; saved DB tables only seed that session. */ export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() @@ -124,10 +124,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } } - logger.debug( - `[${requestId}] Attempting to load workflow ${workflowId} from authoritative state` - ) - const workflowState = await loadEditableWorkflowState(workflowId) + logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from Yjs session`) + const workflowState = await loadWorkflowStateFromYjsSession(workflowId) if (!workflowState) { logger.warn(`[${requestId}] Workflow ${workflowId} is missing saved state`) diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts index 887fc0a6e..1a6f83104 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts @@ -54,7 +54,7 @@ describe('Workflow Status API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadEditableWorkflowState: mockLoadWorkflowState, + loadWorkflowStateFromYjsSession: mockLoadWorkflowState, })) vi.doMock('@/lib/workflows/utils', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts index b8abc92ce..4762c0d86 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts @@ -3,7 +3,7 @@ import { and, desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { loadEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { loadWorkflowStateFromYjsSession } from '@/lib/workflows/db-helpers' import { hasWorkflowChanged } from '@/lib/workflows/utils' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -28,7 +28,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ if (validation.workflow.isDeployed) { // Load current workflow state and the active deployment version in parallel. const [currentState, [active]] = await Promise.all([ - loadEditableWorkflowState(id), + loadWorkflowStateFromYjsSession(id), db .select({ state: workflowDeploymentVersion.state }) .from(workflowDeploymentVersion) diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts index 344cadb01..cbbdb57e1 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts @@ -92,7 +92,7 @@ describe('Workflow YAML Export API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadEditableWorkflowState: loadWorkflowStateMock, + loadWorkflowStateFromYjsSession: loadWorkflowStateMock, })) vi.doMock('@/lib/copilot/workflow/block-output-utils', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts index 11ac15b8d..63aad182f 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts @@ -8,7 +8,7 @@ import { extractSubBlockValuesFromBlocks } from '@/lib/copilot/workflow/block-ou import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' -import { loadEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { loadWorkflowStateFromYjsSession } from '@/lib/workflows/db-helpers' import { getAllBlocks } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' import { resolveOutputType } from '@/blocks/utils' @@ -70,7 +70,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const editableState = await loadEditableWorkflowState(workflowId) + const editableState = await loadWorkflowStateFromYjsSession(workflowId) if (!editableState) { return NextResponse.json( diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index 25ba98a21..22e034c1f 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -1017,8 +1017,8 @@ describe('Database Helpers', () => { }) }) - describe('loadEditableWorkflowState', () => { - it('loads saved workflow state through a bootstrapped Yjs session', async () => { + describe('loadWorkflowStateFromYjsSession', () => { + it('loads workflow state through a bootstrapped Yjs session', async () => { const yjsState = { direction: 'LR' as const, blocks: {}, @@ -1035,7 +1035,7 @@ describe('Database Helpers', () => { buildWorkflowSnapshotResponseFromState(yjsState, yjsVariables) ) - const result = await dbHelpers.loadEditableWorkflowState(mockWorkflowId) + const result = await dbHelpers.loadWorkflowStateFromYjsSession(mockWorkflowId) expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalledWith({ workspaceId: null, @@ -1070,7 +1070,7 @@ describe('Database Helpers', () => { }, }) - const result = await dbHelpers.loadEditableWorkflowState(mockWorkflowId) + const result = await dbHelpers.loadWorkflowStateFromYjsSession(mockWorkflowId) expect(result).toBeNull() expect(mockDb.select).not.toHaveBeenCalled() @@ -1079,7 +1079,7 @@ describe('Database Helpers', () => { it('requires the live Yjs bridge for editable workflow state', async () => { mockReadBootstrappedReviewTargetSnapshot.mockRejectedValue(new Error('bridge unavailable')) - await expect(dbHelpers.loadEditableWorkflowState(mockWorkflowId)).rejects.toThrow( + await expect(dbHelpers.loadWorkflowStateFromYjsSession(mockWorkflowId)).rejects.toThrow( 'bridge unavailable' ) expect(mockDb.select).not.toHaveBeenCalled() diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index e9a17e8da..91b3a0d09 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -137,7 +137,11 @@ function decodeWorkflowSnapshot(snapshotBase64: string): PersistedWorkflowState } } -export async function loadEditableWorkflowState( +/** + * Editable workflow reads must go through the Yjs session. Saved tables are only + * used by the Yjs bootstrap path when a session is not already live. + */ +export async function loadWorkflowStateFromYjsSession( workflowId: string ): Promise { const { readBootstrappedReviewTargetSnapshot } = await import( @@ -159,7 +163,7 @@ export async function loadEditableWorkflowState( return state } -export async function loadWorkflowStateFromSavedTables( +export async function loadWorkflowBootstrapStateFromDb( workflowId: string ): Promise { const [workflowRow, normalizedState] = await Promise.all([ @@ -938,7 +942,7 @@ export async function deployWorkflow(params: { } = params try { - const editableState = await loadEditableWorkflowState(workflowId) + const editableState = await loadWorkflowStateFromYjsSession(workflowId) if (!editableState) { return { success: false, error: 'Failed to load workflow state' } } diff --git a/apps/tradinggoose/lib/workflows/execution-runner.test.ts b/apps/tradinggoose/lib/workflows/execution-runner.test.ts index ff6174aae..39e7abde6 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.test.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.test.ts @@ -66,7 +66,7 @@ vi.mock('@/lib/utils-server', () => ({ vi.mock('@/lib/workflows/db-helpers', () => ({ loadDeployedWorkflowState: vi.fn(), - loadEditableWorkflowState: vi.fn(), + loadWorkflowStateFromYjsSession: vi.fn(), loadWorkflowStateFromYjs: vi.fn(), })) @@ -432,7 +432,7 @@ describe('loadWorkflowExecutionBlueprint', () => { }) it('uses variables from the active deployment for deployed execution', async () => { - const { loadDeployedWorkflowState, loadEditableWorkflowState } = await import( + const { loadDeployedWorkflowState, loadWorkflowStateFromYjsSession } = await import( '@/lib/workflows/db-helpers' ) const deployedVariables = { @@ -475,6 +475,6 @@ describe('loadWorkflowExecutionBlueprint', () => { unknown > expect(Object.keys(selectShape)).toEqual(['workspaceId']) - expect(loadEditableWorkflowState).not.toHaveBeenCalled() + expect(loadWorkflowStateFromYjsSession).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index a0ef7ad2a..db28be212 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -9,7 +9,7 @@ import type { ReviewTargetDescriptor, ReviewTargetRuntimeState, } from '@/lib/copilot/review-sessions/types' -import { loadWorkflowStateFromSavedTables } from '@/lib/workflows/db-helpers' +import { loadWorkflowBootstrapStateFromDb } from '@/lib/workflows/db-helpers' import { getEntityFields, seedEntitySession } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { @@ -93,7 +93,7 @@ export async function createSavedReviewTargetBootstrapUpdate( let workflowDescription: string | null | undefined let workflowFolderId: string | null | undefined if (descriptor.entityKind === 'workflow') { - const workflowState = await loadWorkflowStateFromSavedTables(descriptor.entityId) + const workflowState = await loadWorkflowBootstrapStateFromDb(descriptor.entityId) if (!workflowState) { throw new ReviewTargetBootstrapError(404, 'Workflow not found') } From 47875ed652b8fbdfbadb869f3c483ea3ad0ba04e Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 11:00:47 -0600 Subject: [PATCH 170/284] fix(workflows): validate and persist workflow names Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/workflows/[id]/route.test.ts | 4 ++-- apps/tradinggoose/app/api/workflows/[id]/route.ts | 2 +- apps/tradinggoose/lib/workflows/db-helpers.ts | 2 +- apps/tradinggoose/lib/yjs/workflow-session.test.ts | 9 +++++++++ apps/tradinggoose/lib/yjs/workflow-session.ts | 7 ++++++- 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index ef780d044..ec1313440 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -793,8 +793,7 @@ describe('Workflow By ID API Route', () => { isWorkspaceOwner: false, }) - // Invalid data - empty name - const invalidData = { name: '' } + const invalidData = { name: ' ' } const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', @@ -808,6 +807,7 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(400) const data = await response.json() expect(data.error).toBe('Invalid request data') + expect(mockApplyWorkflowMetadata).not.toHaveBeenCalled() }) it('should reject generated workflow color updates', async () => { diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index c9c886424..eda13664d 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -19,7 +19,7 @@ const logger = createLogger('WorkflowByIdAPI') const UpdateWorkflowSchema = z .object({ - name: z.string().min(1, 'Name is required').optional(), + name: z.string().trim().min(1, 'Name is required').optional(), description: z.string().optional(), folderId: z.string().nullable().optional(), }) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 91b3a0d09..2cdd41dde 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -894,7 +894,7 @@ export async function saveWorkflowYjsDocToDb(workflowId: string, doc: Y.Doc): Pr .set({ lastSynced: syncedAt, updatedAt: syncedAt, - ...(state.name ? { name: state.name } : {}), + ...(state.name !== undefined ? { name: state.name } : {}), ...(state.description !== undefined ? { description: state.description } : {}), ...(state.folderId !== undefined ? { folderId: state.folderId } : {}), variables: state.variables, diff --git a/apps/tradinggoose/lib/yjs/workflow-session.test.ts b/apps/tradinggoose/lib/yjs/workflow-session.test.ts index 828f32a2f..2786f4782 100644 --- a/apps/tradinggoose/lib/yjs/workflow-session.test.ts +++ b/apps/tradinggoose/lib/yjs/workflow-session.test.ts @@ -4,6 +4,7 @@ import { createWorkflowTextFieldKey, readWorkflowSnapshot, readWorkflowTextFieldsMap, + setWorkflowEntityMetadata, setWorkflowState, } from './workflow-session' @@ -78,4 +79,12 @@ describe('workflow session text fields', () => { ) expect(readWorkflowSnapshot(doc).blocks['block-1']?.subBlocks?.code?.value).toBe('fresh') }) + + it('rejects blank workflow names before writing metadata', () => { + const doc = new Y.Doc() + + expect(() => setWorkflowEntityMetadata(doc, { name: ' ' })).toThrow( + 'Workflow name is required' + ) + }) }) diff --git a/apps/tradinggoose/lib/yjs/workflow-session.ts b/apps/tradinggoose/lib/yjs/workflow-session.ts index 8a605dc2f..dcefbafe9 100644 --- a/apps/tradinggoose/lib/yjs/workflow-session.ts +++ b/apps/tradinggoose/lib/yjs/workflow-session.ts @@ -404,10 +404,15 @@ export function readWorkflowEntityMetadata(doc: Y.Doc): WorkflowMetadataSnapshot } export function setWorkflowEntityMetadata(doc: Y.Doc, patch: WorkflowMetadataPatch): void { + const name = patch.name?.trim() + if (patch.name !== undefined && !name) { + throw new Error('Workflow name is required') + } + doc.transact(() => { const metadata = getMetadataMap(doc) metadata.delete('reseededFromCanonical') - if (patch.name !== undefined) metadata.set('entityName', patch.name.trim()) + if (name !== undefined) metadata.set('entityName', name) if (patch.description !== undefined) metadata.set('entityDescription', patch.description) if (patch.folderId !== undefined) metadata.set('folderId', patch.folderId) }, YJS_ORIGINS.SYSTEM) From d5429a3ac7a2011e69ab6ff720094fd13a5b2a55 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 11:01:05 -0600 Subject: [PATCH 171/284] style(yjs): format workflow session tests Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/yjs/workflow-session.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/tradinggoose/lib/yjs/workflow-session.test.ts b/apps/tradinggoose/lib/yjs/workflow-session.test.ts index 2786f4782..0789b0397 100644 --- a/apps/tradinggoose/lib/yjs/workflow-session.test.ts +++ b/apps/tradinggoose/lib/yjs/workflow-session.test.ts @@ -1,5 +1,5 @@ -import * as Y from 'yjs' import { describe, expect, it } from 'vitest' +import * as Y from 'yjs' import { createWorkflowTextFieldKey, readWorkflowSnapshot, @@ -39,7 +39,9 @@ describe('workflow session text fields', () => { sharedText.insert(0, 'live-ytext-value') textFields.set(createWorkflowTextFieldKey('block-1', 'code'), sharedText) - expect(readWorkflowSnapshot(doc).blocks['block-1']?.subBlocks?.code?.value).toBe('live-ytext-value') + expect(readWorkflowSnapshot(doc).blocks['block-1']?.subBlocks?.code?.value).toBe( + 'live-ytext-value' + ) }) it('keeps existing Y.Text entries in sync when workflow state is replaced', () => { @@ -74,9 +76,9 @@ describe('workflow session text fields', () => { }) expect(textFields.get(createWorkflowTextFieldKey('block-1', 'code'))).toBeInstanceOf(Y.Text) - expect((textFields.get(createWorkflowTextFieldKey('block-1', 'code')) as Y.Text).toString()).toBe( - 'fresh' - ) + expect( + (textFields.get(createWorkflowTextFieldKey('block-1', 'code')) as Y.Text).toString() + ).toBe('fresh') expect(readWorkflowSnapshot(doc).blocks['block-1']?.subBlocks?.code?.value).toBe('fresh') }) From 70536059aac7a8d7456ee44fa0f6fe317135771b Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 12:44:53 -0600 Subject: [PATCH 172/284] fix(mcp): acknowledge login before config writes Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/mcp/[[...command]]/route.test.ts | 4 ++-- apps/tradinggoose/lib/mcp/install-script.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts index 7d0e78e3b..04ed42b3a 100644 --- a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts @@ -88,10 +88,10 @@ describe('MCP install route', () => { const configWriteIndex = script.indexOf( 'const configPath = runConfigWriter([target, mcpUrl, login.token])' ) - const acknowledgeIndex = script.indexOf('await acknowledge(login)', configWriteIndex) + const acknowledgeIndex = script.indexOf('await acknowledge(login)', setupIndex) expect(printedTokenIndex).toBeGreaterThan(firstReturnTokenIndex) expect(configWriteIndex).toBeGreaterThan(setupIndex) - expect(acknowledgeIndex).toBeGreaterThan(configWriteIndex) + expect(acknowledgeIndex).toBeLessThan(configWriteIndex) }) it('serves target-specific setup scripts from the URL path', async () => { diff --git a/apps/tradinggoose/lib/mcp/install-script.ts b/apps/tradinggoose/lib/mcp/install-script.ts index 70687d5c1..a4bacb6fe 100644 --- a/apps/tradinggoose/lib/mcp/install-script.ts +++ b/apps/tradinggoose/lib/mcp/install-script.ts @@ -164,12 +164,12 @@ async function main() { } const login = await authenticate() + await acknowledge(login) console.log('Using MCP endpoint: ' + mcpUrl) for (const target of targets) { const configPath = runConfigWriter([target, mcpUrl, login.token]) console.log('Configured ' + target + ': ' + configPath) } - await acknowledge(login) return } From 4e1684c7aa75d35237e2da95a6d441cbf660abcb Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 12:45:11 -0600 Subject: [PATCH 173/284] feat(yjs): support partial workflow patches Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../server/entities/workflow-variable.test.ts | 22 +++-- .../copilot/tools/server/entities/workflow.ts | 5 +- .../yjs/server/apply-workflow-state.test.ts | 53 +++++------ .../lib/yjs/server/apply-workflow-state.ts | 13 +-- .../lib/yjs/server/snapshot-bridge.ts | 28 ++---- apps/tradinggoose/socket-server/index.test.ts | 88 +++++++++---------- .../tradinggoose/socket-server/routes/http.ts | 13 ++- 7 files changed, 100 insertions(+), 122 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts index 2b6d6b9dd..3952ee700 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts @@ -11,6 +11,7 @@ const mockDbLimit = vi.hoisted(() => vi.fn()) const mockReadBootstrappedReviewTargetSnapshot = vi.hoisted(() => vi.fn()) const mockVerifyWorkflowAccess = vi.hoisted(() => vi.fn()) const mockApplyWorkflowState = vi.hoisted(() => vi.fn()) +const mockApplyWorkflowPatchInSocketServer = vi.hoisted(() => vi.fn()) vi.mock('@tradinggoose/db', () => ({ db: { @@ -38,6 +39,11 @@ vi.mock('@/lib/yjs/server/apply-workflow-state', () => ({ applyWorkflowMetadata: vi.fn(), })) +vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ + applyWorkflowPatchInSocketServer: (...args: any[]) => + mockApplyWorkflowPatchInSocketServer(...args), +})) + function workflowSnapshotBase64( variables: Record, workflowState = createWorkflowSnapshot() @@ -58,6 +64,7 @@ describe('workflow variable server tools', () => { mockReadBootstrappedReviewTargetSnapshot.mockReset() mockVerifyWorkflowAccess.mockReset() mockApplyWorkflowState.mockReset() + mockApplyWorkflowPatchInSocketServer.mockReset() mockDbLimit.mockResolvedValue([ { id: 'wf-1', @@ -137,7 +144,7 @@ describe('workflow variable server tools', () => { expect(result.preview.documentDiff.after).toContain('enabled') }) - it('applies full-access workflow variable edits through workflow state persistence', async () => { + it('applies full-access workflow variable edits without replaying workflow topology', async () => { const result = await editWorkflowVariableServerTool.execute( { entityId: 'wf-1', @@ -161,14 +168,10 @@ describe('workflow variable server tools', () => { workspaceId: 'workspace-1', documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, }) - expect(mockApplyWorkflowState).toHaveBeenCalledWith( - 'wf-1', - expect.objectContaining({ - blocks: {}, - edges: [], - }), - result.variables - ) + expect(mockApplyWorkflowPatchInSocketServer).toHaveBeenCalledWith('wf-1', { + variables: result.variables, + }) + expect(mockApplyWorkflowState).not.toHaveBeenCalled() }) it('rejects replacement documents that omit variable ids', async () => { @@ -185,6 +188,7 @@ describe('workflow variable server tools', () => { ) ).rejects.toThrow() + expect(mockApplyWorkflowPatchInSocketServer).not.toHaveBeenCalled() expect(mockApplyWorkflowState).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index 4c06b093d..468c5457c 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -30,6 +30,7 @@ import { import { isWorkflowVariableType, type WorkflowVariableType } from '@/lib/workflows/value-types' import { applyWorkflowMetadata, applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' +import { applyWorkflowPatchInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot, getVariablesSnapshot, @@ -382,7 +383,7 @@ export const editWorkflowVariableServerTool: BaseServerTool< ) } const workflowId = requireCopilotEntityId(args, { toolName: 'edit_workflow_variable' }) - const { workspaceId, workflowState, variables } = await loadWorkflowSnapshotForCopilot( + const { workspaceId, variables } = await loadWorkflowSnapshotForCopilot( workflowId, context, 'write' @@ -416,7 +417,7 @@ export const editWorkflowVariableServerTool: BaseServerTool< } assertAcceptedServerToolReviewBase(context, currentVariablesBaseHash) - await applyWorkflowState(workflowId, workflowState, nextVariables) + await applyWorkflowPatchInSocketServer(workflowId, { variables: nextVariables }) return { success: true, entityKind: ENTITY_KIND_WORKFLOW, diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts index 240c5e418..1c53377fd 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts @@ -5,8 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockApplyWorkflowMetadataInSocketServer, - mockApplyWorkflowStateInSocketServer, + mockApplyWorkflowPatchInSocketServer, mockDbUpdate, mockDbSelect, mockEnsureUniqueBlockIds, @@ -14,13 +13,9 @@ const { mockSelectFrom, mockSelectLimit, mockSelectWhere, - mockUpdateReturning, - mockUpdateSet, - mockUpdateWhere, } = vi.hoisted(() => { return { - mockApplyWorkflowMetadataInSocketServer: vi.fn(), - mockApplyWorkflowStateInSocketServer: vi.fn(), + mockApplyWorkflowPatchInSocketServer: vi.fn(), mockDbUpdate: vi.fn(), mockDbSelect: vi.fn(), mockEnsureUniqueBlockIds: vi.fn(), @@ -28,9 +23,6 @@ const { mockSelectFrom: vi.fn(), mockSelectLimit: vi.fn(), mockSelectWhere: vi.fn(), - mockUpdateReturning: vi.fn(), - mockUpdateSet: vi.fn(), - mockUpdateWhere: vi.fn(), } }) @@ -54,8 +46,7 @@ vi.mock('@/lib/workflows/db-helpers', () => ({ })) vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ - applyWorkflowMetadataInSocketServer: mockApplyWorkflowMetadataInSocketServer, - applyWorkflowStateInSocketServer: mockApplyWorkflowStateInSocketServer, + applyWorkflowPatchInSocketServer: mockApplyWorkflowPatchInSocketServer, })) const emptyWorkflowState = { blocks: {}, edges: [], loops: {}, parallels: {} } @@ -63,14 +54,9 @@ const emptyWorkflowState = { blocks: {}, edges: [], loops: {}, parallels: {} } describe('applyWorkflowState', () => { beforeEach(() => { vi.clearAllMocks() - mockApplyWorkflowMetadataInSocketServer.mockResolvedValue(undefined) - mockApplyWorkflowStateInSocketServer.mockResolvedValue(undefined) + mockApplyWorkflowPatchInSocketServer.mockResolvedValue(undefined) mockEnsureUniqueBlockIds.mockImplementation(async (_workflowId, state) => state) mockEnsureUniqueEdgeIds.mockImplementation(async (_workflowId, state) => state) - mockUpdateReturning.mockResolvedValue([{ id: 'workflow-1' }]) - mockUpdateWhere.mockReturnValue({ returning: mockUpdateReturning }) - mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }) - mockDbUpdate.mockReturnValue({ set: mockUpdateSet }) mockSelectLimit.mockResolvedValue([{ id: 'workflow-1', name: 'Renamed Workflow' }]) mockSelectWhere.mockReturnValue({ limit: mockSelectLimit }) mockSelectFrom.mockReturnValue({ where: mockSelectWhere }) @@ -86,15 +72,16 @@ describe('applyWorkflowState', () => { folderId: 'folder-1', }) - expect(mockApplyWorkflowMetadataInSocketServer).toHaveBeenCalledWith('workflow-1', { - name: 'Renamed Workflow', - description: 'Updated description', - folderId: 'folder-1', + expect(mockApplyWorkflowPatchInSocketServer).toHaveBeenCalledWith('workflow-1', { + metadata: { + name: 'Renamed Workflow', + description: 'Updated description', + folderId: 'folder-1', + }, }) - expect(mockApplyWorkflowStateInSocketServer).not.toHaveBeenCalled() expect(mockDbUpdate).not.toHaveBeenCalled() expect(mockDbSelect.mock.invocationCallOrder[0]).toBeGreaterThan( - mockApplyWorkflowMetadataInSocketServer.mock.invocationCallOrder[0] + mockApplyWorkflowPatchInSocketServer.mock.invocationCallOrder[0] ) expect(updatedWorkflow).toMatchObject({ id: 'workflow-1', name: 'Renamed Workflow' }) }) @@ -141,21 +128,23 @@ describe('applyWorkflowState', () => { { name: 'Workflow Name' } ) - expect(mockApplyWorkflowStateInSocketServer).toHaveBeenCalledWith( + expect(mockApplyWorkflowPatchInSocketServer).toHaveBeenCalledWith( 'workflow-1', expect.objectContaining({ - blocks: { - 'normalized-block': expect.objectContaining({ id: 'normalized-block' }), - }, - }), - {}, - { name: 'Workflow Name' } + workflowState: expect.objectContaining({ + blocks: { + 'normalized-block': expect.objectContaining({ id: 'normalized-block' }), + }, + }), + variables: {}, + metadata: { name: 'Workflow Name' }, + }) ) expect(mockDbUpdate).not.toHaveBeenCalled() }) it('does not commit workflow DB changes when the Yjs socket apply fails', async () => { - mockApplyWorkflowStateInSocketServer.mockRejectedValueOnce(new TypeError('fetch failed')) + mockApplyWorkflowPatchInSocketServer.mockRejectedValueOnce(new TypeError('fetch failed')) const { applyWorkflowState } = await import('./apply-workflow-state') diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index 5afdc92e3..61e0ed5ed 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -1,10 +1,7 @@ import { db, workflow } from '@tradinggoose/db' import { eq } from 'drizzle-orm' import { ensureUniqueBlockIds, ensureUniqueEdgeIds } from '@/lib/workflows/db-helpers' -import { - applyWorkflowMetadataInSocketServer, - applyWorkflowStateInSocketServer, -} from '@/lib/yjs/server/snapshot-bridge' +import { applyWorkflowPatchInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot, type WorkflowMetadataPatch, @@ -32,14 +29,18 @@ export async function applyWorkflowState( lastSaved: syncedAt.toISOString(), }) - await applyWorkflowStateInSocketServer(workflowId, storedWorkflowState, variables, metadata) + await applyWorkflowPatchInSocketServer(workflowId, { + workflowState: storedWorkflowState, + ...(variables === undefined ? {} : { variables }), + ...(metadata ? { metadata } : {}), + }) } export async function applyWorkflowMetadata( workflowId: string, metadata: WorkflowMetadataPatch ): Promise { - await applyWorkflowMetadataInSocketServer(workflowId, metadata) + await applyWorkflowPatchInSocketServer(workflowId, { metadata }) const [updatedWorkflow] = await db .select() diff --git a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts index 3db5f7d3a..35dae7840 100644 --- a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts +++ b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts @@ -12,6 +12,12 @@ export interface YjsSnapshotResponse { touchedAt?: number | null } +type WorkflowPatch = { + workflowState?: WorkflowSnapshot + variables?: Record + metadata?: WorkflowMetadataPatch +} + export class SocketServerBridgeError extends Error { status: number body: string @@ -88,29 +94,13 @@ export async function getYjsSnapshot( return response.json() as Promise } -export async function applyWorkflowStateInSocketServer( - workflowId: string, - workflowState: WorkflowSnapshot, - variables?: Record, - metadata?: WorkflowMetadataPatch -): Promise { - await postJsonToSocketServer( - `/internal/yjs/workflows/${encodeURIComponent(workflowId)}/apply-state`, - { - workflowState, - ...(variables === undefined ? {} : { variables }), - ...(metadata ? { metadata } : {}), - } - ) -} - -export async function applyWorkflowMetadataInSocketServer( +export async function applyWorkflowPatchInSocketServer( workflowId: string, - metadata: WorkflowMetadataPatch + patch: WorkflowPatch ): Promise { await postJsonToSocketServer( `/internal/yjs/workflows/${encodeURIComponent(workflowId)}/apply-state`, - { metadata } + patch ) } diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index acc6117ee..730293bca 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -315,46 +315,45 @@ describe('Socket Server Index Integration', () => { }) it('should apply workflow state through the internal Yjs route', async () => { - const response = await sendHttpRequestWithOptions( - PORT, - '/internal/yjs/workflows/workflow-1/apply-state', - { + const applyWorkflowPatch = (body: unknown) => + sendHttpRequestWithOptions(PORT, '/internal/yjs/workflows/workflow-1/apply-state', { method: 'POST', headers: { 'content-type': 'application/json', 'x-internal-secret': INTERNAL_SECRET, }, - body: JSON.stringify({ - workflowState: { - blocks: { - 'block-1': { - id: 'block-1', - type: 'agent', - name: 'Applied Agent', - position: { x: 10, y: 20 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - lastSaved: '2026-04-06T00:00:00.000Z', - isDeployed: false, - }, - variables: { - var1: { - id: 'var1', - workflowId: 'workflow-1', - name: 'token', - type: 'plain', - value: 'secret', - }, + body: JSON.stringify(body), + }) + + const response = await applyWorkflowPatch({ + workflowState: { + blocks: { + 'block-1': { + id: 'block-1', + type: 'agent', + name: 'Applied Agent', + position: { x: 10, y: 20 }, + subBlocks: {}, + outputs: {}, + enabled: true, }, - }), - } - ) + }, + edges: [], + loops: {}, + parallels: {}, + lastSaved: '2026-04-06T00:00:00.000Z', + isDeployed: false, + }, + variables: { + var1: { + id: 'var1', + workflowId: 'workflow-1', + name: 'token', + type: 'plain', + value: 'secret', + }, + }, + }) expect(response.statusCode).toBe(200) expect(mockSaveWorkflowYjsDocToDb).toHaveBeenCalledWith('workflow-1', expect.any(Y.Doc)) @@ -373,22 +372,19 @@ describe('Socket Server Index Integration', () => { }) ) - const renameResponse = await sendHttpRequestWithOptions( - PORT, - '/internal/yjs/workflows/workflow-1/apply-state', - { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-internal-secret': INTERNAL_SECRET, - }, - body: JSON.stringify({ metadata: { name: 'Renamed Workflow' } }), - } - ) + const renameResponse = await applyWorkflowPatch({ metadata: { name: 'Renamed Workflow' } }) expect(renameResponse.statusCode).toBe(200) expect(mockSaveWorkflowYjsDocToDb).toHaveBeenCalledTimes(2) expect(await getExistingDocument('workflow-1')).toBeNull() + + const variables = { var2: { name: 'riskLimit', value: 25 } } + const variablesResponse = await applyWorkflowPatch({ variables }) + + expect(variablesResponse.statusCode).toBe(200) + expect(mockSaveWorkflowYjsDocToDb).toHaveBeenCalledTimes(3) + expect(savedWorkflowStates[2]?.variables).toEqual(variables) + expect(await getExistingDocument('workflow-1')).toBeNull() }) it('should apply saved entity state through Yjs', async () => { diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index b52ef99e1..dd57cf66a 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -19,6 +19,7 @@ import { import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' import { replaceWorkflowDocumentState, + setVariables, setWorkflowEntityMetadata, type WorkflowMetadataPatch, type WorkflowSnapshot, @@ -179,8 +180,8 @@ function parseApplyWorkflowStateRequest(body: unknown): ApplyWorkflowStateReques const metadata = candidate.metadata !== undefined ? (candidate.metadata as WorkflowMetadataPatch) : undefined - if (workflowState === undefined && metadata === undefined) { - throw new InvalidInternalYjsRequestError('workflowState or metadata is required') + if (workflowState === undefined && metadata === undefined && candidate.variables === undefined) { + throw new InvalidInternalYjsRequestError('workflowState, variables, or metadata is required') } if ( @@ -190,10 +191,6 @@ function parseApplyWorkflowStateRequest(body: unknown): ApplyWorkflowStateReques throw new InvalidInternalYjsRequestError('workflowState must be an object') } - if (workflowState === undefined && candidate.variables !== undefined) { - throw new InvalidInternalYjsRequestError('variables require workflowState') - } - if ( candidate.variables !== undefined && (!candidate.variables || @@ -298,9 +295,9 @@ async function handleInternalYjsWorkflowApplyRequest( try { if (body.workflowState) { replaceWorkflowDocumentState(doc, body.workflowState, body.variables, body.metadata) - } else { - setWorkflowEntityMetadata(doc, body.metadata!) } + if (!body.workflowState && body.variables !== undefined) setVariables(doc, body.variables) + if (!body.workflowState && body.metadata) setWorkflowEntityMetadata(doc, body.metadata) await saveWorkflowYjsDocToDb(workflowId, doc) discardDocumentIfIdle(workflowId) } catch (error) { From 28b95c1ff962bb61ca67086965af815ba46b2065 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 12:45:26 -0600 Subject: [PATCH 174/284] fix(saved-entities): map duplicate names to validation errors Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/custom-tools/operations.ts | 19 ++--- apps/tradinggoose/lib/skills/operations.ts | 17 +---- .../lib/yjs/server/apply-entity-state.test.ts | 36 +++++++-- .../lib/yjs/server/apply-entity-state.ts | 74 +++++++++++++------ 4 files changed, 91 insertions(+), 55 deletions(-) diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index 0b8d091be..a59426c97 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -1,6 +1,6 @@ import { db } from '@tradinggoose/db' import { customTools } from '@tradinggoose/db/schema' -import { desc, eq } from 'drizzle-orm' +import { and, desc, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import { type CustomToolTransferRecord, @@ -104,23 +104,14 @@ export async function saveCustomTool({ workspaceId, requestId = generateRequestId(), }: SaveCustomToolParams) { - const existingTools = await db - .select({ - id: customTools.id, - title: customTools.title, - }) + const [existingTool] = await db + .select({ id: customTools.id }) .from(customTools) - .where(eq(customTools.workspaceId, workspaceId)) - const existingTool = existingTools.find((candidate) => candidate.id === tool.id) + .where(and(eq(customTools.id, tool.id), eq(customTools.workspaceId, workspaceId))) + .limit(1) if (!existingTool) { throw new Error(`Custom tool ${tool.id} was not found`) } - const conflictingTool = existingTools.find( - (candidate) => candidate.title === tool.title && candidate.id !== tool.id - ) - if (conflictingTool) { - throw new Error(`A tool with the title "${tool.title}" already exists in this workspace`) - } await applySavedEntityState('custom_tool', tool.id, { title: tool.title, diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index efb91108b..80e3134a4 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -133,23 +133,14 @@ export async function saveSkill({ workspaceId, requestId = generateRequestId(), }: SaveSkillParams) { - const existingSkills = await db - .select({ - id: skill.id, - name: skill.name, - }) + const [existingSkill] = await db + .select({ id: skill.id }) .from(skill) - .where(eq(skill.workspaceId, workspaceId)) - const existingSkill = existingSkills.find((candidate) => candidate.id === currentSkill.id) + .where(and(eq(skill.id, currentSkill.id), eq(skill.workspaceId, workspaceId))) + .limit(1) if (!existingSkill) { throw new Error(`Skill ${currentSkill.id} was not found`) } - const conflictingSkill = existingSkills.find( - (candidate) => candidate.name === currentSkill.name && candidate.id !== currentSkill.id - ) - if (conflictingSkill) { - throw new Error(`A skill with the name "${currentSkill.name}" already exists in this workspace`) - } await applySavedEntityState('skill', currentSkill.id, { name: currentSkill.name, diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts index d2dcfabda..5333d5e74 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts @@ -51,12 +51,10 @@ vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ applyEntityStateInSocketServer: mockApplyEntityStateInSocketServer, })) -function buildSkillDoc(fields: { name: string; description: string; content: string }) { +function buildDoc(fields: Record) { const doc = new Y.Doc() const map = doc.getMap('fields') - map.set('name', fields.name) - map.set('description', fields.description) - map.set('content', fields.content) + for (const [key, value] of Object.entries(fields)) map.set(key, value) return doc } @@ -96,7 +94,7 @@ describe('applySavedEntityState', () => { it('materializes saved-entity DB state from a provided Yjs document', async () => { const { saveSavedEntityYjsDocToDb } = await import('./apply-entity-state') - const doc = buildSkillDoc({ + const doc = buildDoc({ name: 'Yjs Skill', description: 'Yjs description', content: 'Use the Yjs document.', @@ -119,7 +117,7 @@ describe('applySavedEntityState', () => { it('throws when document materialization cannot find the saved entity row', async () => { const { saveSavedEntityYjsDocToDb } = await import('./apply-entity-state') - const doc = buildSkillDoc({ name: 'Yjs Skill', description: '', content: '' }) + const doc = buildDoc({ name: 'Yjs Skill', description: '', content: '' }) mockUpdateReturning.mockResolvedValueOnce([]) try { @@ -131,6 +129,32 @@ describe('applySavedEntityState', () => { } }) + it('maps saved-entity unique constraint failures to validation errors', async () => { + const { saveSavedEntityYjsDocToDb } = await import('./apply-entity-state') + const duplicate = Object.assign(new Error('duplicate key'), { code: '23505' }) + const skillDoc = buildDoc({ name: 'Yjs Skill', description: '', content: '' }) + const customToolDoc = buildDoc({ title: 'Yjs Tool', schemaText: '{}', codeText: '' }) + + try { + mockUpdateReturning.mockRejectedValueOnce(duplicate) + await expect(saveSavedEntityYjsDocToDb('skill', 'skill-1', skillDoc)).rejects.toMatchObject({ + status: 409, + message: 'A skill with the name "Yjs Skill" already exists in this workspace', + }) + + mockUpdateReturning.mockRejectedValueOnce(duplicate) + await expect( + saveSavedEntityYjsDocToDb('custom_tool', 'tool-1', customToolDoc) + ).rejects.toMatchObject({ + status: 409, + message: 'A tool with the title "Yjs Tool" already exists in this workspace', + }) + } finally { + skillDoc.destroy() + customToolDoc.destroy() + } + }) + it('does not materialize DB state when the saved-entity Yjs apply fails', async () => { const { applySavedEntityState } = await import('./apply-entity-state') mockApplyEntityStateInSocketServer.mockRejectedValueOnce(new TypeError('fetch failed')) diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index c72808a95..fe81236d3 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -30,6 +30,26 @@ function objectField(value: unknown): Record { : {} } +function isUniqueConstraintViolation(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === '23505' + ) +} + +async function mapUniqueConstraint(operation: Promise, message: string): Promise { + try { + return await operation + } catch (error) { + if (isUniqueConstraintViolation(error)) { + throw new SavedEntityPersistenceError(409, message) + } + throw error + } +} + function normalizeSavedEntityFields( entityKind: SavedEntityKind, fields: Record @@ -53,30 +73,40 @@ async function persistSavedEntityState( let persisted: Array<{ id: string }> switch (entityKind) { - case 'skill': - persisted = await db - .update(skill) - .set({ - name: String(fields.name ?? ''), - description: String(fields.description ?? ''), - content: String(fields.content ?? ''), - updatedAt: now, - }) - .where(eq(skill.id, entityId)) - .returning({ id: skill.id }) + case 'skill': { + const name = String(fields.name ?? '') + persisted = await mapUniqueConstraint( + db + .update(skill) + .set({ + name, + description: String(fields.description ?? ''), + content: String(fields.content ?? ''), + updatedAt: now, + }) + .where(eq(skill.id, entityId)) + .returning({ id: skill.id }), + `A skill with the name "${name}" already exists in this workspace` + ) break - case 'custom_tool': - persisted = await db - .update(customTools) - .set({ - title: String(fields.title ?? ''), - schema: parseCustomToolSchemaText(fields.schemaText), - code: String(fields.codeText ?? ''), - updatedAt: now, - }) - .where(eq(customTools.id, entityId)) - .returning({ id: customTools.id }) + } + case 'custom_tool': { + const title = String(fields.title ?? '') + persisted = await mapUniqueConstraint( + db + .update(customTools) + .set({ + title, + schema: parseCustomToolSchemaText(fields.schemaText), + code: String(fields.codeText ?? ''), + updatedAt: now, + }) + .where(eq(customTools.id, entityId)) + .returning({ id: customTools.id }), + `A tool with the title "${title}" already exists in this workspace` + ) break + } case 'indicator': persisted = await db .update(pineIndicators) From 546ba473d5eef4f828cae896b8638bf767c55dab Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 13:27:44 -0600 Subject: [PATCH 175/284] fix(workflows): load live execution state through Yjs sessions Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/workflows/db-helpers.test.ts | 69 ------------------- apps/tradinggoose/lib/workflows/db-helpers.ts | 36 ---------- .../lib/workflows/execution-runner.test.ts | 7 +- .../lib/workflows/execution-runner.ts | 4 +- 4 files changed, 5 insertions(+), 111 deletions(-) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index 22e034c1f..46e77ed08 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -110,23 +110,7 @@ vi.doMock('@/lib/logs/console/logger', () => ({ })) const mockReconcilePublishedChatsForDeploymentTx = vi.fn() -const mockGetYjsSnapshot = vi.fn() const mockReadBootstrappedReviewTargetSnapshot = vi.fn() -class MockSocketServerBridgeError extends Error { - body = '' - - constructor( - public status: number, - message: string - ) { - super(message) - this.name = 'SocketServerBridgeError' - } -} -vi.doMock('@/lib/yjs/server/snapshot-bridge', () => ({ - getYjsSnapshot: mockGetYjsSnapshot, - SocketServerBridgeError: MockSocketServerBridgeError, -})) vi.doMock('@/lib/yjs/server/bootstrap-review-target', () => ({ readBootstrappedReviewTargetSnapshot: mockReadBootstrappedReviewTargetSnapshot, @@ -343,7 +327,6 @@ describe('Database Helpers', () => { beforeEach(() => { vi.clearAllMocks() - mockGetYjsSnapshot.mockRejectedValue(new MockSocketServerBridgeError(404, 'Not found')) mockReconcilePublishedChatsForDeploymentTx.mockResolvedValue(undefined) mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ @@ -965,58 +948,6 @@ describe('Database Helpers', () => { }) }) - describe('loadWorkflowStateFromYjs', () => { - it('should decode the workflow state from an existing Yjs snapshot', async () => { - const yjsState = { - blocks: { - 'block-yjs': { - id: 'block-yjs', - type: 'api', - name: 'Live block', - position: { x: 10, y: 20 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - lastSaved: '2026-04-06T00:10:00.000Z', - } - const yjsVariables = { - 'var-yjs': { - id: 'var-yjs', - name: 'Live variable', - type: 'plain', - value: 'latest', - }, - } - - mockGetYjsSnapshot.mockResolvedValue( - buildWorkflowSnapshotResponseFromState(yjsState, yjsVariables) - ) - - const result = await dbHelpers.loadWorkflowStateFromYjs(mockWorkflowId) - - expect(mockGetYjsSnapshot).toHaveBeenCalledWith( - mockWorkflowId, - expect.objectContaining({ - sessionId: mockWorkflowId, - entityKind: 'workflow', - entityId: mockWorkflowId, - }) - ) - expect(result).toMatchObject({ - blocks: yjsState.blocks, - edges: yjsState.edges, - loops: yjsState.loops, - parallels: yjsState.parallels, - variables: yjsVariables, - }) - }) - }) - describe('loadWorkflowStateFromYjsSession', () => { it('loads workflow state through a bootstrapped Yjs session', async () => { const yjsState = { diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 2cdd41dde..a3e488403 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -12,14 +12,9 @@ import { and, desc, eq, inArray, ne, sql } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' import * as Y from 'yjs' import { reconcilePublishedChatsForDeploymentTx } from '@/lib/chat/published-deployment' -import { - buildYjsTransportEnvelope, - serializeYjsTransportEnvelope, -} from '@/lib/copilot/review-sessions/identity' import { createLogger } from '@/lib/logs/console/logger' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' import { inferWorkflowDirectionFromState } from '@/lib/workflows/workflow-direction' -import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' import { extractPersistedStateFromDoc } from '@/lib/yjs/workflow-session' import type { BlockState, @@ -96,37 +91,6 @@ export type PersistedWorkflowState = { lastSaved: number } -export async function loadWorkflowStateFromYjs( - workflowId: string -): Promise { - const descriptor = { - workspaceId: null, - entityKind: 'workflow' as const, - entityId: workflowId, - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: workflowId, - } - let snapshot: Awaited> - try { - snapshot = await getYjsSnapshot( - workflowId, - serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor)) - ) - } catch (error) { - if (error instanceof SocketServerBridgeError && error.status === 404) { - return null - } - throw error - } - - if (!snapshot.snapshotBase64) { - return null - } - - return decodeWorkflowSnapshot(snapshot.snapshotBase64) -} - function decodeWorkflowSnapshot(snapshotBase64: string): PersistedWorkflowState | null { const doc = new Y.Doc() try { diff --git a/apps/tradinggoose/lib/workflows/execution-runner.test.ts b/apps/tradinggoose/lib/workflows/execution-runner.test.ts index 39e7abde6..0d37148c3 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.test.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.test.ts @@ -67,7 +67,6 @@ vi.mock('@/lib/utils-server', () => ({ vi.mock('@/lib/workflows/db-helpers', () => ({ loadDeployedWorkflowState: vi.fn(), loadWorkflowStateFromYjsSession: vi.fn(), - loadWorkflowStateFromYjs: vi.fn(), })) vi.mock('@/lib/workflows/triggers', () => ({ @@ -404,10 +403,10 @@ describe('loadWorkflowExecutionBlueprint', () => { }) it('loads Yjs workflow state for live execution when no snapshot is supplied', async () => { - const { loadDeployedWorkflowState, loadWorkflowStateFromYjs } = await import( + const { loadDeployedWorkflowState, loadWorkflowStateFromYjsSession } = await import( '@/lib/workflows/db-helpers' ) - vi.mocked(loadWorkflowStateFromYjs).mockResolvedValueOnce({ + vi.mocked(loadWorkflowStateFromYjsSession).mockResolvedValueOnce({ blocks: { trigger: { subBlocks: {} } }, edges: [{ source: 'trigger', target: 'worker' }], loops: {}, @@ -427,7 +426,7 @@ describe('loadWorkflowExecutionBlueprint', () => { expect(result.workflowData.blocks).toEqual({ trigger: { subBlocks: {} } }) expect(result.workflowContext.variables).toEqual({ risk: { value: 1 } }) expect(loadDeployedWorkflowState).not.toHaveBeenCalled() - expect(loadWorkflowStateFromYjs).toHaveBeenCalledWith('workflow-1') + expect(loadWorkflowStateFromYjsSession).toHaveBeenCalledWith('workflow-1') expect(mocks.dbSelect).not.toHaveBeenCalled() }) diff --git a/apps/tradinggoose/lib/workflows/execution-runner.ts b/apps/tradinggoose/lib/workflows/execution-runner.ts index b2f23b4e4..c901da6c3 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.ts @@ -10,7 +10,7 @@ import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { decryptSecret } from '@/lib/utils-server' import { loadDeployedWorkflowState, - loadWorkflowStateFromYjs, + loadWorkflowStateFromYjsSession, } from '@/lib/workflows/db-helpers' import { TriggerUtils } from '@/lib/workflows/triggers' import { updateWorkflowRunCounts } from '@/lib/workflows/utils' @@ -270,7 +270,7 @@ export async function loadWorkflowExecutionBlueprint(params: { const executionTarget = params.executionTarget ?? 'deployed' const liveWorkflowState = executionTarget === 'live' && !params.workflowData - ? await loadWorkflowStateFromYjs(params.workflowId) + ? await loadWorkflowStateFromYjsSession(params.workflowId) : null const workflowContext = await resolveRequiredWorkflowExecutionContext( params.workflowId, From 1736fee414fb181418f6c0098ebe77822ca68dd9 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 13:27:57 -0600 Subject: [PATCH 176/284] fix(yjs): rewrite variable references on replacement Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/yjs/workflow-variables.test.ts | 31 +++++++++++++++++ .../lib/yjs/workflow-variables.ts | 33 +++++++++++++++++- apps/tradinggoose/socket-server/index.test.ts | 34 +++++++++++++++++-- .../tradinggoose/socket-server/routes/http.ts | 6 ++-- 4 files changed, 98 insertions(+), 6 deletions(-) diff --git a/apps/tradinggoose/lib/yjs/workflow-variables.test.ts b/apps/tradinggoose/lib/yjs/workflow-variables.test.ts index 160e6d6c0..41e1b9da9 100644 --- a/apps/tradinggoose/lib/yjs/workflow-variables.test.ts +++ b/apps/tradinggoose/lib/yjs/workflow-variables.test.ts @@ -15,6 +15,7 @@ import { deleteWorkflowVariable, duplicateWorkflowVariable, readWorkflowVariables, + replaceWorkflowVariables, updateWorkflowVariable, } from '@/lib/yjs/workflow-variables' @@ -223,6 +224,36 @@ describe('workflow variable Yjs mutations', () => { }) }) + it('replaces the variable map and rewrites renamed variable references by id', () => { + const doc = createDoc() + + addWorkflowVariable( + doc, + { workflowId: 'wf-1', name: 'Foo Value', type: 'plain', value: 'hello' }, + 'var-1', + 'test' + ) + + replaceWorkflowVariables( + doc, + { + 'var-1': { + id: 'var-1', + workflowId: 'wf-1', + name: 'Bar Value', + type: 'plain', + value: 'hello', + }, + }, + 'test' + ) + + expect(getVariablesSnapshot(doc)['var-1']).toMatchObject({ name: 'Bar Value' }) + expect(readWorkflowSnapshot(doc).blocks.blockA.subBlocks.prompt.value).toBe( + 'Use in this prompt' + ) + }) + it('duplicates and deletes variables through the Yjs map', () => { const doc = createDoc() diff --git a/apps/tradinggoose/lib/yjs/workflow-variables.ts b/apps/tradinggoose/lib/yjs/workflow-variables.ts index 94587707a..3711f3c4c 100644 --- a/apps/tradinggoose/lib/yjs/workflow-variables.ts +++ b/apps/tradinggoose/lib/yjs/workflow-variables.ts @@ -11,7 +11,10 @@ */ import type * as Y from 'yjs' -import { LISTING_IDENTITY_VALUE_TYPE, parseListingIdentityValueStrict } from '@/lib/listing/identity' +import { + LISTING_IDENTITY_VALUE_TYPE, + parseListingIdentityValueStrict, +} from '@/lib/listing/identity' import { escapeRegExp } from '@/lib/utils' import type { Variable } from '@/stores/variables/types' import { rewriteWorkflowContentReferences } from './workflow-reference-rewrite' @@ -289,6 +292,34 @@ export function deleteWorkflowVariable(doc: Y.Doc, id: string, origin?: string): return true } +export function replaceWorkflowVariables( + doc: Y.Doc, + variables: Record, + origin?: string +): void { + const vMap = getVariablesMap(doc) + + doc.transact(() => { + for (const [id, nextVariable] of Object.entries(variables)) { + const current = vMap.get(id) as Variable | undefined + const nextName = typeof nextVariable?.name === 'string' ? nextVariable.name : undefined + if (current && nextName && current.name !== nextName) { + rewriteVariableReferencesInWorkflowContent( + readWorkflowMap(doc), + readWorkflowTextFieldsMap(doc), + current.name, + nextName + ) + } + } + + vMap.clear() + for (const [key, value] of Object.entries(variables)) { + vMap.set(key, value) + } + }, origin ?? 'variable-replace') +} + export function duplicateWorkflowVariable( doc: Y.Doc, id: string, diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 730293bca..f5c73ca07 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -9,7 +9,11 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite import * as Y from 'yjs' import { createLogger } from '@/lib/logs/console/logger' import { getEntityFields, seedEntitySession } from '@/lib/yjs/entity-session' -import { extractPersistedStateFromDoc, setWorkflowState } from '@/lib/yjs/workflow-session' +import { + extractPersistedStateFromDoc, + setVariables, + setWorkflowState, +} from '@/lib/yjs/workflow-session' import { createSocketIOServer } from '@/socket-server/config/socket' import { createHttpHandler } from '@/socket-server/routes/http' import { @@ -333,7 +337,13 @@ describe('Socket Server Index Integration', () => { type: 'agent', name: 'Applied Agent', position: { x: 10, y: 20 }, - subBlocks: {}, + subBlocks: { + prompt: { + id: 'prompt', + type: 'long-input', + value: 'Use in this prompt', + }, + }, outputs: {}, enabled: true, }, @@ -378,12 +388,30 @@ describe('Socket Server Index Integration', () => { expect(mockSaveWorkflowYjsDocToDb).toHaveBeenCalledTimes(2) expect(await getExistingDocument('workflow-1')).toBeNull() - const variables = { var2: { name: 'riskLimit', value: 25 } } + const liveDoc = getDocument('workflow-1', true) as Y.Doc + setWorkflowState( + liveDoc, + { + blocks: savedWorkflowStates[0]?.blocks ?? {}, + edges: savedWorkflowStates[0]?.edges ?? [], + loops: savedWorkflowStates[0]?.loops ?? {}, + parallels: savedWorkflowStates[0]?.parallels ?? {}, + }, + 'test' + ) + setVariables(liveDoc, savedWorkflowStates[0]?.variables ?? {}, 'test') + + const variables = { + var1: { id: 'var1', workflowId: 'workflow-1', name: 'riskLimit', value: 25 }, + } const variablesResponse = await applyWorkflowPatch({ variables }) expect(variablesResponse.statusCode).toBe(200) expect(mockSaveWorkflowYjsDocToDb).toHaveBeenCalledTimes(3) expect(savedWorkflowStates[2]?.variables).toEqual(variables) + expect(savedWorkflowStates[2]?.blocks['block-1'].subBlocks.prompt.value).toBe( + 'Use in this prompt' + ) expect(await getExistingDocument('workflow-1')).toBeNull() }) diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index dd57cf66a..d86bb13c8 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -19,11 +19,11 @@ import { import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' import { replaceWorkflowDocumentState, - setVariables, setWorkflowEntityMetadata, type WorkflowMetadataPatch, type WorkflowSnapshot, } from '@/lib/yjs/workflow-session' +import { replaceWorkflowVariables } from '@/lib/yjs/workflow-variables' import { getMonitorRuntimeLockHealth } from '@/socket-server/monitor-runtime-lock' import { discardDocument, @@ -296,7 +296,9 @@ async function handleInternalYjsWorkflowApplyRequest( if (body.workflowState) { replaceWorkflowDocumentState(doc, body.workflowState, body.variables, body.metadata) } - if (!body.workflowState && body.variables !== undefined) setVariables(doc, body.variables) + if (!body.workflowState && body.variables !== undefined) { + replaceWorkflowVariables(doc, body.variables, YJS_ORIGINS.SYSTEM) + } if (!body.workflowState && body.metadata) setWorkflowEntityMetadata(doc, body.metadata) await saveWorkflowYjsDocToDb(workflowId, doc) discardDocumentIfIdle(workflowId) From 5a7b3c96ce7e2108dd85950973539ea2f2522fe1 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 13:56:02 -0600 Subject: [PATCH 177/284] fix(workflows): export skills from workspace store Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/workflows/db-helpers.ts | 3 +- .../stores/workflows/json/store.test.ts | 71 ++++++------------- .../stores/workflows/json/store.ts | 65 ++--------------- 3 files changed, 31 insertions(+), 108 deletions(-) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index a3e488403..ebf8d9703 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -103,7 +103,8 @@ function decodeWorkflowSnapshot(snapshotBase64: string): PersistedWorkflowState /** * Editable workflow reads must go through the Yjs session. Saved tables are only - * used by the Yjs bootstrap path when a session is not already live. + * used by the Yjs bootstrap path when a session is not already live. Bridge + * failures intentionally surface instead of falling back to stale saved tables. */ export async function loadWorkflowStateFromYjsSession( workflowId: string diff --git a/apps/tradinggoose/stores/workflows/json/store.test.ts b/apps/tradinggoose/stores/workflows/json/store.test.ts index 591c6b9f2..037eefa3d 100644 --- a/apps/tradinggoose/stores/workflows/json/store.test.ts +++ b/apps/tradinggoose/stores/workflows/json/store.test.ts @@ -1,9 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import * as Y from 'yjs' -import { seedEntitySession } from '@/lib/yjs/entity-session' +import { useSkillsStore } from '@/stores/skills/store' const mockGetSnapshotForWorkflow = vi.hoisted(() => vi.fn()) -const mockBootstrapYjsProvider = vi.hoisted(() => vi.fn()) const mockWorkflowRegistryState = vi.hoisted(() => ({ workflows: { 'workflow-1': { @@ -21,10 +19,6 @@ vi.mock('@/lib/yjs/workflow-session-registry', () => ({ getSnapshotForWorkflow: mockGetSnapshotForWorkflow, })) -vi.mock('@/lib/yjs/provider', () => ({ - bootstrapYjsProvider: mockBootstrapYjsProvider, -})) - vi.mock('@/stores/workflows/registry/store', () => ({ useWorkflowRegistry: { getState: () => mockWorkflowRegistryState, @@ -36,7 +30,7 @@ import { useWorkflowJsonStore } from './store' describe('workflow json store', () => { beforeEach(() => { mockGetSnapshotForWorkflow.mockReset() - mockBootstrapYjsProvider.mockReset() + useSkillsStore.getState().resetAll() useWorkflowJsonStore.setState({ json: '', lastGenerated: undefined, @@ -72,40 +66,29 @@ describe('workflow json store', () => { isDeployed: false, deployedAt: undefined, }) - mockBootstrapYjsProvider.mockImplementation(async ({ entityId }) => { - const doc = new Y.Doc() - seedEntitySession(doc, { - entityKind: 'skill', - payload: { - name: ' Market Research ', - description: ' Research the market before execution. ', - content: 'Review catalysts and confirm direction.', - }, - }) - return { - doc, - provider: { - disconnect: vi.fn(), - destroy: vi.fn(), - }, - descriptor: { - workspaceId: 'workspace-1', - entityKind: 'skill', - entityId, - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: entityId, - }, - runtime: { - docState: 'active', - replaySafe: true, - reseededFromCanonical: false, - }, - } - }) + useSkillsStore.getState().setSkills('workspace-1', [ + { + id: 'skill-1', + workspaceId: 'workspace-1', + userId: null, + name: ' Market Research ', + description: ' Research the market before execution. ', + content: 'Review catalysts and confirm direction.', + createdAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'skill-2', + workspaceId: 'workspace-1', + userId: null, + name: 'Unused Skill', + description: 'Not referenced.', + content: 'Do not export this skill.', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ]) }) - it('threads Yjs-backed skills into the workflow export payload', async () => { + it('threads workspace skills into the workflow export payload', async () => { await useWorkflowJsonStore.getState().getJson({ workflowId: 'workflow-1', channelId: 'channel-1', @@ -139,14 +122,6 @@ describe('workflow json store', () => { } expect(payload.resourceTypes).toEqual(['workflows', 'skills']) - expect(mockBootstrapYjsProvider).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - entityKind: 'skill', - entityId: 'skill-1', - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: 'skill-1', - }) expect(payload.skills).toEqual([ { name: 'Market Research', diff --git a/apps/tradinggoose/stores/workflows/json/store.ts b/apps/tradinggoose/stores/workflows/json/store.ts index 2c75aa47a..e7b197b26 100644 --- a/apps/tradinggoose/stores/workflows/json/store.ts +++ b/apps/tradinggoose/stores/workflows/json/store.ts @@ -1,13 +1,9 @@ -import { createWithEqualityFn as create } from 'zustand/traditional' import { devtools } from 'zustand/middleware' +import { createWithEqualityFn as create } from 'zustand/traditional' import { createLogger } from '@/lib/logs/console/logger' -import { - collectWorkflowSkillIds, - createWorkflowExportFile, -} from '@/lib/workflows/import-export' -import { getEntityFields } from '@/lib/yjs/entity-session' -import { bootstrapYjsProvider } from '@/lib/yjs/provider' +import { createWorkflowExportFile } from '@/lib/workflows/import-export' import { getSnapshotForWorkflow } from '@/lib/yjs/workflow-session-registry' +import { useSkillsStore } from '@/stores/skills/store' import { useWorkflowRegistry } from '../registry/store' const logger = createLogger('WorkflowJsonStore') @@ -26,46 +22,6 @@ interface WorkflowJsonStore { refreshJson: (scope?: WorkflowJsonScope) => Promise } -async function readWorkflowSkillExportsFromYjs( - workflowSnapshot: NonNullable>, - workspaceId: string | null | undefined -) { - const skillIds = collectWorkflowSkillIds(workflowSnapshot) - if (skillIds.length === 0) { - return [] - } - if (!workspaceId) { - return null - } - - return Promise.all( - skillIds.map(async (skillId) => { - const session = await bootstrapYjsProvider({ - workspaceId, - entityKind: 'skill', - entityId: skillId, - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: skillId, - }) - - try { - const fields = getEntityFields(session.doc, 'skill') - return { - id: skillId, - name: String(fields.name ?? ''), - description: String(fields.description ?? ''), - content: String(fields.content ?? ''), - } - } finally { - session.provider.disconnect() - session.provider.destroy() - session.doc.destroy() - } - }) - ) -} - export const useWorkflowJsonStore = create()( devtools( (set, get) => ({ @@ -110,24 +66,15 @@ export const useWorkflowJsonStore = create()( return } - const workflowSkills = await readWorkflowSkillExportsFromYjs( - workflowSnapshot, - currentWorkflow.workspaceId - ) - - if (!workflowSkills) { - logger.warn('Workflow workspace missing for skill export:', activeWorkflowId) - clearJson() - return - } - const exportFile = createWorkflowExportFile({ workflow: { name: currentWorkflow.name, description: currentWorkflow.description ?? '', state: workflowSnapshot, }, - skills: workflowSkills, + skills: currentWorkflow.workspaceId + ? useSkillsStore.getState().getAllSkills(currentWorkflow.workspaceId) + : [], }) // Convert to formatted JSON From 11e0a711fe57c4a1651d8ddaba5074bc45ac7582 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 14:17:11 -0600 Subject: [PATCH 178/284] fix(mcp): reuse approved device login api key Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/mcp/auth.test.ts | 60 +++++++++++++++++++------- apps/tradinggoose/lib/mcp/auth.ts | 23 ++++++++-- 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/apps/tradinggoose/lib/mcp/auth.test.ts b/apps/tradinggoose/lib/mcp/auth.test.ts index f6624a357..c54a761f9 100644 --- a/apps/tradinggoose/lib/mcp/auth.test.ts +++ b/apps/tradinggoose/lib/mcp/auth.test.ts @@ -4,20 +4,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { db, mockCreateApiKey, mockEncryptApiKeyForStorage, mockIsApiKeyFormat } = vi.hoisted( - () => ({ - db: { - select: vi.fn(), - insert: vi.fn(), - delete: vi.fn(), - update: vi.fn(), - transaction: vi.fn(), - }, - mockCreateApiKey: vi.fn(), - mockEncryptApiKeyForStorage: vi.fn(), - mockIsApiKeyFormat: vi.fn(), - }) -) +const { db, mockEncryptApiKeyForStorage, mockIsApiKeyFormat } = vi.hoisted(() => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + delete: vi.fn(), + update: vi.fn(), + transaction: vi.fn(), + }, + mockEncryptApiKeyForStorage: vi.fn(), + mockIsApiKeyFormat: vi.fn(), +})) const verification = Object.fromEntries( ['id', 'identifier', 'value', 'expiresAt', 'createdAt', 'updatedAt'].map((field) => [ @@ -35,7 +32,6 @@ vi.mock('drizzle-orm', () => ({ lte: vi.fn((field, value) => ({ field, value })), })) vi.mock('@/lib/api-key/service', () => ({ - createApiKey: mockCreateApiKey, encryptApiKeyForStorage: mockEncryptApiKeyForStorage, isApiKeyFormat: mockIsApiKeyFormat, })) @@ -62,6 +58,14 @@ function mockInsertValues() { return values } +function mockUpdateReturning(result: unknown[] = [{ id: 'device-login-row' }]) { + const returning = vi.fn().mockResolvedValue(result) + const where = vi.fn(() => ({ returning })) + const set = vi.fn(() => ({ where })) + db.update.mockReturnValue({ set }) + return { set, where, returning } +} + function readCodeFields(code: string) { const [, createdAt, expiresAt, , verificationKeyHash] = code.split('.') return { @@ -76,7 +80,6 @@ describe('MCP device login auth', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-06-19T12:00:00.000Z')) vi.clearAllMocks() - mockCreateApiKey.mockResolvedValue({ key: `sk-tradinggoose-${'a'.repeat(32)}` }) mockEncryptApiKeyForStorage.mockResolvedValue('encrypted-api-key') mockIsApiKeyFormat.mockReturnValue(true) mockDelete() @@ -135,4 +138,29 @@ describe('MCP device login auth', () => { }) expect(db.delete).toHaveBeenCalled() }) + + it('returns the same approved API key across repeated polls before acknowledgement', async () => { + const { pollMcpDeviceLogin, startMcpDeviceLogin } = await import('./auth') + const login = await startMcpDeviceLogin() + const fields = readCodeFields(login.code) + const approvedRow = { + id: 'device-login-row', + value: JSON.stringify({ + status: 'approved', + createdAt: fields.createdAt, + verificationKeyHash: fields.verificationKeyHash, + approvedAt: '2026-06-19T12:01:00.000Z', + userId: 'user-1', + }), + expiresAt: fields.expiresAt, + } + selectRows([approvedRow], [approvedRow]) + mockUpdateReturning() + + const firstPoll = await pollMcpDeviceLogin(login.code, login.verificationKey) + const secondPoll = await pollMcpDeviceLogin(login.code, login.verificationKey) + + expect(firstPoll).toEqual(secondPoll) + expect(firstPoll.status).toBe('approved') + }) }) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index c8fc6180e..3d3e643a2 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -3,7 +3,7 @@ import { db } from '@tradinggoose/db' import { apiKey, verification } from '@tradinggoose/db/schema' import { and, eq, like, lte } from 'drizzle-orm' import { nanoid } from 'nanoid' -import { createApiKey, encryptApiKeyForStorage, isApiKeyFormat } from '@/lib/api-key/service' +import { encryptApiKeyForStorage, isApiKeyFormat } from '@/lib/api-key/service' import { env } from '@/lib/env' import { getBaseUrl } from '@/lib/urls/utils' @@ -87,6 +87,14 @@ function createDeviceLoginApprovalToken(code: string, userId: string): string { return signDeviceLoginCode(`mcp-approval.${buildDeviceLoginId(code)}.${userId}`) } +function createDeviceLoginApiKey(code: string, verificationKey: string): string { + const secret = createHmac('sha256', env.INTERNAL_API_SECRET) + .update(`mcp-api-key.${buildDeviceLoginId(code)}.${hashValue(verificationKey)}`) + .digest('base64url') + .slice(0, 32) + return `${env.API_ENCRYPTION_KEY !== undefined ? 'sk-tradinggoose-' : 'tradinggoose_'}${secret}` +} + function approvalTokenMatches(code: string, userId: string, approvalToken: string): boolean { const expectedToken = createDeviceLoginApprovalToken(code, userId) return ( @@ -383,10 +391,19 @@ export async function pollMcpDeviceLogin( return { status: 'expired' } } - const { key } = await createApiKey(false) + const key = createDeviceLoginApiKey(code, verificationKey) + const apiKeyHash = hashValue(key) + if (login.state.apiKeyHash === apiKeyHash) { + return { + status: 'approved', + apiKey: key, + expiresAt: login.expiresAt.toISOString(), + } + } + const nextState = { ...login.state, - apiKeyHash: hashValue(key), + apiKeyHash, } satisfies ApprovedDeviceLogin if (!(await updateDeviceLoginState(login, nextState))) { return { From 0931bbd84ac27d8bf8f4749200365d9f38d1a301 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 14:17:29 -0600 Subject: [PATCH 179/284] fix(yjs): skip idle persistence for clean documents Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/socket-server/index.test.ts | 23 +++++++++++++++++++ .../tradinggoose/socket-server/routes/http.ts | 4 ++++ .../socket-server/yjs/upstream-utils.ts | 12 +++++++++- .../socket-server/yjs/ws-handler.ts | 4 +++- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index f5c73ca07..15b1c282b 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -618,6 +618,27 @@ describe('Socket Server Index Integration', () => { }) describe('Yjs document cleanup', () => { + it('should discard a clean idle document without final persistence', async () => { + const conn = new (await import('node:events')).EventEmitter() as any + conn.readyState = 1 + conn.send = vi.fn((_message, _options, callback) => callback?.()) + conn.ping = vi.fn() + conn.close = vi.fn() + const onDocumentIdle = vi.fn() + + setupWSConnection(conn, {} as any, { + docId: 'idle-clean', + onDocumentIdle, + }) + expect(await getExistingDocument('idle-clean')).not.toBeNull() + + conn.emit('close') + await new Promise((resolve) => setImmediate(resolve)) + + expect(onDocumentIdle).not.toHaveBeenCalled() + expect(await getExistingDocument('idle-clean')).toBeNull() + }) + it('should discard an idle document even when final persistence fails', async () => { const conn = new (await import('node:events')).EventEmitter() as any conn.readyState = 1 @@ -631,6 +652,8 @@ describe('Socket Server Index Integration', () => { onDocumentIdle, }) expect(await getExistingDocument('idle-save-failed')).not.toBeNull() + const doc = await getExistingDocument('idle-save-failed') + doc?.getMap('metadata').set('entityId', 'changed') conn.emit('close') await new Promise((resolve) => setImmediate(resolve)) diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index d86bb13c8..437b956a9 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -30,6 +30,7 @@ import { discardDocumentIfIdle, getDocument, getExistingDocument, + markDocumentPersisted, } from '@/socket-server/yjs/upstream-utils' interface Logger { @@ -301,6 +302,7 @@ async function handleInternalYjsWorkflowApplyRequest( } if (!body.workflowState && body.metadata) setWorkflowEntityMetadata(doc, body.metadata) await saveWorkflowYjsDocToDb(workflowId, doc) + markDocumentPersisted(doc) discardDocumentIfIdle(workflowId) } catch (error) { discardDocumentIfIdle(descriptor.yjsSessionId) @@ -341,6 +343,7 @@ async function handleInternalYjsEntityApplyRequest( }) clearSessionReseededFromCanonical(doc) await saveSavedEntityYjsDocToDb(body.entityKind, entityId, doc) + markDocumentPersisted(doc) discardDocumentIfIdle(entityId) } catch (error) { discardDocumentIfIdle(descriptor.yjsSessionId) @@ -392,6 +395,7 @@ async function handleInternalYjsSessionApplyUpdateRequest( clearSessionReseededFromCanonical(doc) if (descriptor.entityKind !== 'workflow' && descriptor.entityId) { await saveSavedEntityYjsDocToDb(descriptor.entityKind, descriptor.entityId, doc) + markDocumentPersisted(doc) discardDocumentIfIdle(sessionId) } } catch (error) { diff --git a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts index 0be41ee75..0baccfc29 100644 --- a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts +++ b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts @@ -33,12 +33,14 @@ class WSSharedDoc extends Y.Doc { awareness: awarenessProtocol.Awareness whenInitialized: Promise onDocumentIdle?: DocumentIdleHandler + hasUnsavedChanges: boolean constructor(name: string, gc: boolean) { super({ gc }) this.name = name this.conns = new Map() this.awareness = new awarenessProtocol.Awareness(this) + this.hasUnsavedChanges = false this.awareness.setLocalState(null) this.awareness.on( @@ -69,6 +71,7 @@ class WSSharedDoc extends Y.Doc { ) this.on('update', (update: Uint8Array, _origin: unknown) => { + this.hasUnsavedChanges = true const encoder = encoding.createEncoder() encoding.writeVarUint(encoder, messageSync) syncProtocol.writeUpdate(encoder, update) @@ -90,7 +93,7 @@ function cleanupDocument(doc: WSSharedDoc): void { } function finalizeDocumentCleanup(doc: WSSharedDoc): void { - if (!doc.onDocumentIdle) { + if (!doc.onDocumentIdle || !doc.hasUnsavedChanges) { cleanupDocument(doc) return } @@ -175,11 +178,18 @@ export function getDocument(docId: string, gc = true, bootstrapState?: Uint8Arra const doc = new WSSharedDoc(docId, gc) if (bootstrapState) { Y.applyUpdate(doc, bootstrapState) + doc.hasUnsavedChanges = false } return doc }) } +export function markDocumentPersisted(doc: Y.Doc): void { + if (doc instanceof WSSharedDoc) { + doc.hasUnsavedChanges = false + } +} + export function peekDocument(docId: string): Y.Doc | null { return docs.get(docId) ?? null } diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.ts index fa13186ae..a89abfb04 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.ts @@ -13,7 +13,7 @@ import { getRuntimeStateFromDoc, } from '@/lib/yjs/server/bootstrap-review-target' import { authenticateYjsConnection, YjsAuthError } from './auth' -import { getExistingDocument, setupWSConnection } from './upstream-utils' +import { getExistingDocument, markDocumentPersisted, setupWSConnection } from './upstream-utils' const logger = createLogger('YjsWsHandler') const savedEntityKinds = new Set([ @@ -43,10 +43,12 @@ async function persistIdleDocument(docId: string, doc: Y.Doc): Promise { if (entityKind === 'workflow') { await saveWorkflowYjsDocToDb(docId, doc) + markDocumentPersisted(doc) return } if (typeof entityKind === 'string' && savedEntityKinds.has(entityKind as SavedEntityKind)) { await saveSavedEntityYjsDocToDb(entityKind as SavedEntityKind, docId, doc) + markDocumentPersisted(doc) } } From 1bff0e71f570287ce7f60ed37705a160d4eac611 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 15:10:47 -0600 Subject: [PATCH 180/284] fix(copilot): require explicit workflow variable removal intent Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/copilot/registry.ts | 6 +++- .../server/entities/workflow-variable.test.ts | 36 ++++++++++++------- .../copilot/tools/server/entities/workflow.ts | 31 +++++++++++++--- .../lib/yjs/workflow-variables.test.ts | 4 +-- .../lib/yjs/workflow-variables.ts | 2 +- 5 files changed, 58 insertions(+), 21 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/registry.ts b/apps/tradinggoose/lib/copilot/registry.ts index b8ccc1177..15189b2ae 100644 --- a/apps/tradinggoose/lib/copilot/registry.ts +++ b/apps/tradinggoose/lib/copilot/registry.ts @@ -276,8 +276,12 @@ const EditWorkflowVariableArgs = EntityTargetArgs.extend({ .string() .min(1) .describe( - 'Full `tg-workflow-variable-document-v1` JSON document for workflow variables. Preserve existing `variableId` values from `read_workflow`; choose a new unique `variableId` only for a new variable: {"variables":[{"variableId":"var-risk-limit","name":"riskLimit","type":"number","value":100}]}. This is a full replacement document; omit a variable to delete it.' + 'Full `tg-workflow-variable-document-v1` JSON document for workflow variables. Preserve existing `variableId` values from `read_workflow`; choose a new unique `variableId` only for a new variable: {"variables":[{"variableId":"var-risk-limit","name":"riskLimit","type":"number","value":100}]}.' ), + removedVariableIds: z + .array(z.string().trim().min(1)) + .optional() + .describe('Existing variable ids intentionally removed from the workflow.'), documentFormat: z.literal(WORKFLOW_VARIABLE_DOCUMENT_FORMAT).optional(), }).strict() const KnowledgeBaseDocumentMutationShape = { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts index 3952ee700..1063ba81f 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts @@ -79,7 +79,7 @@ describe('workflow variable server tools', () => { mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue({ snapshotBase64: workflowSnapshotBase64({ 'var-1': { - id: 'var-1', + id: 'wrong-id', workflowId: 'wf-1', name: 'riskLimit', type: 'number', @@ -144,17 +144,13 @@ describe('workflow variable server tools', () => { expect(result.preview.documentDiff.after).toContain('enabled') }) - it('applies full-access workflow variable edits without replaying workflow topology', async () => { + it('applies full-access workflow variable deletion without replaying workflow topology', async () => { const result = await editWorkflowVariableServerTool.execute( { entityId: 'wf-1', documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, - entityDocument: JSON.stringify({ - variables: [ - { variableId: 'var-1', name: 'riskLimit', type: 'number', value: 25 }, - { variableId: 'var-2', name: 'enabled', type: 'boolean', value: true }, - ], - }), + entityDocument: JSON.stringify({ variables: [] }), + removedVariableIds: ['var-1'], }, { userId: 'user-1', accessLevel: 'full' } ) @@ -168,13 +164,29 @@ describe('workflow variable server tools', () => { workspaceId: 'workspace-1', documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, }) - expect(mockApplyWorkflowPatchInSocketServer).toHaveBeenCalledWith('wf-1', { - variables: result.variables, - }) + expect(result.variables).toEqual({}) + expect(mockApplyWorkflowPatchInSocketServer).toHaveBeenCalledWith('wf-1', { variables: {} }) expect(mockApplyWorkflowState).not.toHaveBeenCalled() }) - it('rejects replacement documents that omit variable ids', async () => { + it('rejects variable replacements without stable ids or removal intent', async () => { + await expect( + editWorkflowVariableServerTool.execute( + { + entityId: 'wf-1', + documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, + entityDocument: JSON.stringify({ + variables: [ + { variableId: 'var-replacement', name: 'riskLimit', type: 'number', value: 25 }, + ], + }), + }, + { userId: 'user-1', accessLevel: 'full' } + ) + ).rejects.toThrow( + 'Existing variable ids omitted from edit_workflow_variable entityDocument without removedVariableIds: var-1' + ) + await expect( editWorkflowVariableServerTool.execute( { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index 468c5457c..a5025ea41 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -238,10 +238,10 @@ export async function loadWorkflowSnapshotForCopilot( } function serializeWorkflowVariableDocument(variables: Record): string { - const entries = Object.values(variables) - .filter((variable: any) => variable && typeof variable === 'object') - .map((variable: any) => ({ - variableId: String(variable.id ?? ''), + const entries = Object.entries(variables) + .filter(([, variable]) => variable && typeof variable === 'object') + .map(([variableId, variable]: [string, any]) => ({ + variableId, name: String(variable.name ?? ''), type: isWorkflowVariableType(variable.type) ? variable.type : 'plain', value: variable.value ?? '', @@ -372,7 +372,12 @@ export const readWorkflowServerTool: BaseServerTool<{ entityId: string }, any> = } export const editWorkflowVariableServerTool: BaseServerTool< - { entityId: string; entityDocument: string; documentFormat?: string }, + { + entityId: string + entityDocument: string + documentFormat?: string + removedVariableIds?: string[] + }, any > = { name: 'edit_workflow_variable', @@ -392,6 +397,22 @@ export const editWorkflowVariableServerTool: BaseServerTool< workflowId, entityDocument: args.entityDocument, }) + const nextVariableIds = new Set(Object.keys(nextVariables)) + const removedVariableIds = new Set(args.removedVariableIds?.map((id) => id.trim())) + const missingRemovalIntents = Object.keys(variables).filter( + (id) => !nextVariableIds.has(id) && !removedVariableIds.has(id) + ) + if (missingRemovalIntents.length > 0) { + throw new Error( + `Invalid edited workflow variables: Existing variable ids omitted from edit_workflow_variable entityDocument without removedVariableIds: ${missingRemovalIntents.join(', ')}.` + ) + } + const stillPresentRemovedIds = [...removedVariableIds].filter((id) => nextVariableIds.has(id)) + if (stillPresentRemovedIds.length > 0) { + throw new Error( + `Invalid edited workflow variables: removedVariableIds still appear in edit_workflow_variable entityDocument: ${stillPresentRemovedIds.join(', ')}.` + ) + } const nextDocument = serializeWorkflowVariableDocument(nextVariables) const currentVariablesBaseHash = hashServerToolReviewBase(variables) diff --git a/apps/tradinggoose/lib/yjs/workflow-variables.test.ts b/apps/tradinggoose/lib/yjs/workflow-variables.test.ts index 41e1b9da9..3b1df2083 100644 --- a/apps/tradinggoose/lib/yjs/workflow-variables.test.ts +++ b/apps/tradinggoose/lib/yjs/workflow-variables.test.ts @@ -238,7 +238,7 @@ describe('workflow variable Yjs mutations', () => { doc, { 'var-1': { - id: 'var-1', + id: 'wrong-id', workflowId: 'wf-1', name: 'Bar Value', type: 'plain', @@ -248,7 +248,7 @@ describe('workflow variable Yjs mutations', () => { 'test' ) - expect(getVariablesSnapshot(doc)['var-1']).toMatchObject({ name: 'Bar Value' }) + expect(getVariablesSnapshot(doc)['var-1']).toMatchObject({ id: 'var-1', name: 'Bar Value' }) expect(readWorkflowSnapshot(doc).blocks.blockA.subBlocks.prompt.value).toBe( 'Use in this prompt' ) diff --git a/apps/tradinggoose/lib/yjs/workflow-variables.ts b/apps/tradinggoose/lib/yjs/workflow-variables.ts index 3711f3c4c..2c2ce9d20 100644 --- a/apps/tradinggoose/lib/yjs/workflow-variables.ts +++ b/apps/tradinggoose/lib/yjs/workflow-variables.ts @@ -315,7 +315,7 @@ export function replaceWorkflowVariables( vMap.clear() for (const [key, value] of Object.entries(variables)) { - vMap.set(key, value) + vMap.set(key, { ...value, id: key }) } }, origin ?? 'variable-replace') } From 1cf99bd0d165d88817f826a870ee72845e83bdda Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 15:17:40 -0600 Subject: [PATCH 181/284] fix(yjs): retry transient snapshot bridge fetches Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/yjs/server/snapshot-bridge.ts | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts index 35dae7840..4a58e66a0 100644 --- a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts +++ b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts @@ -45,23 +45,36 @@ function getInternalSecret(): string { async function fetchFromSocketServer( url: URL, init: RequestInit, - timeoutMs = 5000 + timeoutMs = 5000, + attempts = 1 ): Promise { const headers = new Headers(init.headers) headers.set('x-internal-secret', getInternalSecret()) - const response = await fetch(url.toString(), { - ...init, - headers, - signal: AbortSignal.timeout(timeoutMs), - }) - - if (!response.ok) { - const body = await response.text().catch(() => '') - throw new SocketServerBridgeError(response.status, body) + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + const response = await fetch(url.toString(), { + ...init, + headers, + signal: AbortSignal.timeout(timeoutMs), + }) + + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new SocketServerBridgeError(response.status, body) + } + + return response + } catch (error) { + const canRetry = + attempt < attempts && !(error instanceof SocketServerBridgeError && error.status < 500) + if (!canRetry) { + throw error + } + } } - return response + throw new Error('Socket server bridge failed') } async function postJsonToSocketServer(path: string, body: unknown): Promise { @@ -90,7 +103,7 @@ export async function getYjsSnapshot( } } - const response = await fetchFromSocketServer(url, { method: 'GET' }) + const response = await fetchFromSocketServer(url, { method: 'GET' }, 5000, 3) return response.json() as Promise } From 17ffeafc6939ffc2708647cc34d3c207d6e9b625 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 15:17:53 -0600 Subject: [PATCH 182/284] fix(yjs): clear reseed metadata after variable replacement Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/yjs/workflow-variables.test.ts | 2 ++ apps/tradinggoose/lib/yjs/workflow-variables.ts | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/tradinggoose/lib/yjs/workflow-variables.test.ts b/apps/tradinggoose/lib/yjs/workflow-variables.test.ts index 3b1df2083..7a8373134 100644 --- a/apps/tradinggoose/lib/yjs/workflow-variables.test.ts +++ b/apps/tradinggoose/lib/yjs/workflow-variables.test.ts @@ -226,6 +226,7 @@ describe('workflow variable Yjs mutations', () => { it('replaces the variable map and rewrites renamed variable references by id', () => { const doc = createDoc() + doc.getMap('metadata').set('reseededFromCanonical', true) addWorkflowVariable( doc, @@ -249,6 +250,7 @@ describe('workflow variable Yjs mutations', () => { ) expect(getVariablesSnapshot(doc)['var-1']).toMatchObject({ id: 'var-1', name: 'Bar Value' }) + expect(doc.getMap('metadata').get('reseededFromCanonical')).toBeUndefined() expect(readWorkflowSnapshot(doc).blocks.blockA.subBlocks.prompt.value).toBe( 'Use in this prompt' ) diff --git a/apps/tradinggoose/lib/yjs/workflow-variables.ts b/apps/tradinggoose/lib/yjs/workflow-variables.ts index 2c2ce9d20..0a2d58052 100644 --- a/apps/tradinggoose/lib/yjs/workflow-variables.ts +++ b/apps/tradinggoose/lib/yjs/workflow-variables.ts @@ -18,7 +18,12 @@ import { import { escapeRegExp } from '@/lib/utils' import type { Variable } from '@/stores/variables/types' import { rewriteWorkflowContentReferences } from './workflow-reference-rewrite' -import { getVariablesMap, readWorkflowMap, readWorkflowTextFieldsMap } from './workflow-session' +import { + getMetadataMap, + getVariablesMap, + readWorkflowMap, + readWorkflowTextFieldsMap, +} from './workflow-session' // --------------------------------------------------------------------------- // Name generation @@ -317,6 +322,7 @@ export function replaceWorkflowVariables( for (const [key, value] of Object.entries(variables)) { vMap.set(key, { ...value, id: key }) } + getMetadataMap(doc).delete('reseededFromCanonical') }, origin ?? 'variable-replace') } From 8628f04a4d3d080ed96a441c6e83102c0ad78d21 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 16:17:56 -0600 Subject: [PATCH 183/284] feat(workflows): persist embedded custom tools Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/workflows/db-helpers.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index ebf8d9703..f4fff0242 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -1,4 +1,5 @@ import { + customTools, db, workflow, workflowBlocks, @@ -12,6 +13,7 @@ import { and, desc, eq, inArray, ne, sql } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' import * as Y from 'yjs' import { reconcilePublishedChatsForDeploymentTx } from '@/lib/chat/published-deployment' +import { getCustomToolEntityIdFromRuntimeId } from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' import { inferWorkflowDirectionFromState } from '@/lib/workflows/workflow-direction' @@ -78,6 +80,75 @@ const sanitizeBlockLayout = (layout: unknown): BlockState['layout'] => { return nextLayout } +const saveEmbeddedCustomTools = async ( + tx: WorkflowDbTransaction, + workflowId: string, + blocks: Iterable +) => { + const embeddedTools = new Map() + for (const block of blocks) { + if (!block || block.type !== 'agent') continue + const tools = block.subBlocks?.tools?.value + if (!Array.isArray(tools)) continue + for (const tool of tools) { + if ( + !tool || + typeof tool !== 'object' || + tool.type !== 'custom-tool' || + typeof tool.title !== 'string' || + typeof tool.toolId !== 'string' || + typeof tool.code !== 'string' || + !tool.schema?.function + ) { + continue + } + const title = tool.title.trim() + if (!title) continue + embeddedTools.set(getCustomToolEntityIdFromRuntimeId(tool.toolId), { + title, + schema: tool.schema, + code: tool.code, + }) + } + } + if (embeddedTools.size === 0) return + + const [owner] = await tx + .select({ workspaceId: workflow.workspaceId, userId: workflow.userId }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + const workspaceId = owner?.workspaceId + if (!workspaceId) return + + const now = new Date() + await tx + .insert(customTools) + .values( + Array.from(embeddedTools, ([id, tool]) => ({ + id, + workspaceId, + userId: owner.userId, + title: tool.title, + schema: tool.schema, + code: tool.code, + createdAt: now, + updatedAt: now, + })) + ) + .onConflictDoUpdate({ + target: customTools.id, + set: { + title: sql.raw('excluded."title"'), + schema: sql.raw('excluded."schema"'), + code: sql.raw('excluded."code"'), + updatedAt: now, + }, + setWhere: eq(customTools.workspaceId, workspaceId), + }) +} + export type PersistedWorkflowState = { name?: string | null description?: string | null @@ -820,6 +891,7 @@ export async function saveWorkflowToNormalizedTables( await tx.insert(workflowSubflows).values(subflowInserts) } + await saveEmbeddedCustomTools(tx, workflowId, sanitizedBlockRecords) await commit?.(tx, normalizedState) }) From c0b5b32f910e49366f46bb4f48a5f7f84b872502 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 16:32:18 -0600 Subject: [PATCH 184/284] fix(workflows): surface realtime orchestration failures Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../api/workflows/[id]/autolayout/route.ts | 4 ++-- .../api/workflows/[id]/deploy/route.test.ts | 2 +- .../app/api/workflows/[id]/deploy/route.ts | 4 ++-- .../workflows/[id]/duplicate/route.test.ts | 2 +- .../app/api/workflows/[id]/duplicate/route.ts | 4 ++-- .../app/api/workflows/[id]/route.test.ts | 4 +++- .../app/api/workflows/[id]/route.ts | 17 +++++++++++++-- .../api/workflows/[id]/status/route.test.ts | 4 +++- .../app/api/workflows/[id]/status/route.ts | 15 +++++++++++-- .../api/workflows/yaml/export/route.test.ts | 4 +++- .../app/api/workflows/yaml/export/route.ts | 18 ++++++++++++++-- .../lib/workflows/db-helpers.test.ts | 8 +++---- apps/tradinggoose/lib/workflows/db-helpers.ts | 21 +++++++++++++++++-- .../lib/workflows/execution-runner.test.ts | 12 +++++------ .../lib/workflows/execution-runner.ts | 7 ++----- 15 files changed, 92 insertions(+), 34 deletions(-) diff --git a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts index 5cc1c7fd0..2c681f40f 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applyAutoLayout } from '@/lib/workflows/autolayout' -import { loadWorkflowStateFromYjsSession } from '@/lib/workflows/db-helpers' +import { loadEditableWorkflowState } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' export const dynamic = 'force-dynamic' @@ -61,7 +61,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } } else { logger.info(`[${requestId}] Loading blocks from current workflow state`) - currentWorkflowData = await loadWorkflowStateFromYjsSession(workflowId) + currentWorkflowData = await loadEditableWorkflowState(workflowId) } if (!currentWorkflowData) { diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts index 077699666..c7047ec4e 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts @@ -37,7 +37,7 @@ describe('Workflow Deploy API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ deployWorkflow: vi.fn(), - loadWorkflowStateFromYjsSession: (...args: unknown[]) => mockLoadWorkflowState(...args), + loadEditableWorkflowState: (...args: unknown[]) => mockLoadWorkflowState(...args), })) vi.doMock('@/lib/chat/published-deployment', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts index 3d7181af7..551f60109 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts @@ -7,7 +7,7 @@ import { } from '@/lib/chat/published-deployment' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { deployWorkflow, loadWorkflowStateFromYjsSession } from '@/lib/workflows/db-helpers' +import { deployWorkflow, loadEditableWorkflowState } from '@/lib/workflows/db-helpers' import { hasWorkflowChanged, validateWorkflowPermissions } from '@/lib/workflows/utils' import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' import { pauseMonitorsMissingDeployedTrigger } from '@/app/api/monitors/shared' @@ -99,7 +99,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .limit(1) if (active?.state) { - const currentState = await loadWorkflowStateFromYjsSession(id) + const currentState = await loadEditableWorkflowState(id) if (currentState) { needsRedeployment = hasWorkflowChanged(currentState, active.state as any) } diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts index 181d74f50..13bb006ce 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts @@ -110,7 +110,7 @@ describe('Workflow Duplicate API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadWorkflowStateFromYjsSession: loadWorkflowStateMock, + loadEditableWorkflowState: loadWorkflowStateMock, regenerateWorkflowStateIds: regenerateWorkflowStateIdsMock, saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, })) diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts index 5d38d977e..9d34c2ff2 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -9,7 +9,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' import { - loadWorkflowStateFromYjsSession, + loadEditableWorkflowState, regenerateWorkflowStateIds, saveWorkflowToNormalizedTables, } from '@/lib/workflows/db-helpers' @@ -31,7 +31,7 @@ async function loadSourceWorkflowArtifacts(sourceWorkflowId: string): Promise<{ workflowState: WorkflowState variables: Record }> { - const editableState = await loadWorkflowStateFromYjsSession(sourceWorkflowId) + const editableState = await loadEditableWorkflowState(sourceWorkflowId) if (!editableState) { throw new Error('Failed to load source workflow state') } diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index ec1313440..eb93c07c9 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -34,7 +34,9 @@ describe('Workflow By ID API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadWorkflowStateFromYjsSession: mockLoadWorkflowState, + WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', + isWorkflowRealtimeRequiredError: vi.fn(() => false), + loadEditableWorkflowState: mockLoadWorkflowState, })) vi.doMock('@tradinggoose/db', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index eda13664d..c0f95a817 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -9,7 +9,11 @@ import { verifyInternalTokenDetailed } from '@/lib/auth/internal' import { hydrateListingUI } from '@/lib/listing/hydrate-ui' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { loadWorkflowStateFromYjsSession } from '@/lib/workflows/db-helpers' +import { + isWorkflowRealtimeRequiredError, + loadEditableWorkflowState, + WORKFLOW_REALTIME_REQUIRED_CODE, +} from '@/lib/workflows/db-helpers' import { readWorkflowAccessContext, readWorkflowById } from '@/lib/workflows/utils' import { applyWorkflowMetadata } from '@/lib/yjs/server/apply-workflow-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' @@ -125,7 +129,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from Yjs session`) - const workflowState = await loadWorkflowStateFromYjsSession(workflowId) + const workflowState = await loadEditableWorkflowState(workflowId) if (!workflowState) { logger.warn(`[${requestId}] Workflow ${workflowId} is missing saved state`) @@ -188,6 +192,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } catch (error: any) { const elapsed = Date.now() - startTime logger.error(`[${requestId}] Error fetching workflow ${workflowId} after ${elapsed}ms`, error) + if (isWorkflowRealtimeRequiredError(error)) { + return NextResponse.json( + { + error: 'Workflow realtime orchestration is required', + code: WORKFLOW_REALTIME_REQUIRED_CODE, + }, + { status: 503 } + ) + } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts index 1a6f83104..7f0044f38 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts @@ -54,7 +54,9 @@ describe('Workflow Status API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadWorkflowStateFromYjsSession: mockLoadWorkflowState, + WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', + isWorkflowRealtimeRequiredError: vi.fn(() => false), + loadEditableWorkflowState: mockLoadWorkflowState, })) vi.doMock('@/lib/workflows/utils', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts index 4762c0d86..0f2d7324a 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts @@ -3,7 +3,11 @@ import { and, desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { loadWorkflowStateFromYjsSession } from '@/lib/workflows/db-helpers' +import { + isWorkflowRealtimeRequiredError, + loadEditableWorkflowState, + WORKFLOW_REALTIME_REQUIRED_CODE, +} from '@/lib/workflows/db-helpers' import { hasWorkflowChanged } from '@/lib/workflows/utils' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -28,7 +32,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ if (validation.workflow.isDeployed) { // Load current workflow state and the active deployment version in parallel. const [currentState, [active]] = await Promise.all([ - loadWorkflowStateFromYjsSession(id), + loadEditableWorkflowState(id), db .select({ state: workflowDeploymentVersion.state }) .from(workflowDeploymentVersion) @@ -59,6 +63,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ }) } catch (error) { logger.error(`[${requestId}] Error getting status for workflow: ${(await params).id}`, error) + if (isWorkflowRealtimeRequiredError(error)) { + return createErrorResponse( + 'Workflow realtime orchestration is required', + 503, + WORKFLOW_REALTIME_REQUIRED_CODE + ) + } return createErrorResponse('Failed to get status', 500) } } diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts index cbbdb57e1..53b842eb4 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts @@ -92,7 +92,9 @@ describe('Workflow YAML Export API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadWorkflowStateFromYjsSession: loadWorkflowStateMock, + WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', + isWorkflowRealtimeRequiredError: vi.fn(() => false), + loadEditableWorkflowState: loadWorkflowStateMock, })) vi.doMock('@/lib/copilot/workflow/block-output-utils', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts index 63aad182f..7a798a9f6 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts @@ -8,7 +8,11 @@ import { extractSubBlockValuesFromBlocks } from '@/lib/copilot/workflow/block-ou import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' -import { loadWorkflowStateFromYjsSession } from '@/lib/workflows/db-helpers' +import { + isWorkflowRealtimeRequiredError, + loadEditableWorkflowState, + WORKFLOW_REALTIME_REQUIRED_CODE, +} from '@/lib/workflows/db-helpers' import { getAllBlocks } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' import { resolveOutputType } from '@/blocks/utils' @@ -70,7 +74,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const editableState = await loadWorkflowStateFromYjsSession(workflowId) + const editableState = await loadEditableWorkflowState(workflowId) if (!editableState) { return NextResponse.json( @@ -181,6 +185,16 @@ export async function GET(request: NextRequest) { }) } catch (error) { logger.error(`[${requestId}] YAML export failed`, error) + if (isWorkflowRealtimeRequiredError(error)) { + return NextResponse.json( + { + success: false, + error: 'Workflow realtime orchestration is required', + code: WORKFLOW_REALTIME_REQUIRED_CODE, + }, + { status: 503 } + ) + } return NextResponse.json( { success: false, diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index 46e77ed08..b9021c093 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -948,7 +948,7 @@ describe('Database Helpers', () => { }) }) - describe('loadWorkflowStateFromYjsSession', () => { + describe('loadEditableWorkflowState', () => { it('loads workflow state through a bootstrapped Yjs session', async () => { const yjsState = { direction: 'LR' as const, @@ -966,7 +966,7 @@ describe('Database Helpers', () => { buildWorkflowSnapshotResponseFromState(yjsState, yjsVariables) ) - const result = await dbHelpers.loadWorkflowStateFromYjsSession(mockWorkflowId) + const result = await dbHelpers.loadEditableWorkflowState(mockWorkflowId) expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalledWith({ workspaceId: null, @@ -1001,7 +1001,7 @@ describe('Database Helpers', () => { }, }) - const result = await dbHelpers.loadWorkflowStateFromYjsSession(mockWorkflowId) + const result = await dbHelpers.loadEditableWorkflowState(mockWorkflowId) expect(result).toBeNull() expect(mockDb.select).not.toHaveBeenCalled() @@ -1010,7 +1010,7 @@ describe('Database Helpers', () => { it('requires the live Yjs bridge for editable workflow state', async () => { mockReadBootstrappedReviewTargetSnapshot.mockRejectedValue(new Error('bridge unavailable')) - await expect(dbHelpers.loadWorkflowStateFromYjsSession(mockWorkflowId)).rejects.toThrow( + await expect(dbHelpers.loadEditableWorkflowState(mockWorkflowId)).rejects.toThrow( 'bridge unavailable' ) expect(mockDb.select).not.toHaveBeenCalled() diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index f4fff0242..785e1abe3 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -162,6 +162,21 @@ export type PersistedWorkflowState = { lastSaved: number } +export const WORKFLOW_REALTIME_REQUIRED_CODE = 'WORKFLOW_REALTIME_REQUIRED' + +export class WorkflowRealtimeRequiredError extends Error { + readonly code = WORKFLOW_REALTIME_REQUIRED_CODE + + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : 'Workflow realtime orchestration is required') + this.name = 'WorkflowRealtimeRequiredError' + } +} + +export const isWorkflowRealtimeRequiredError = ( + error: unknown +): error is WorkflowRealtimeRequiredError => error instanceof WorkflowRealtimeRequiredError + function decodeWorkflowSnapshot(snapshotBase64: string): PersistedWorkflowState | null { const doc = new Y.Doc() try { @@ -177,7 +192,7 @@ function decodeWorkflowSnapshot(snapshotBase64: string): PersistedWorkflowState * used by the Yjs bootstrap path when a session is not already live. Bridge * failures intentionally surface instead of falling back to stale saved tables. */ -export async function loadWorkflowStateFromYjsSession( +export async function loadEditableWorkflowState( workflowId: string ): Promise { const { readBootstrappedReviewTargetSnapshot } = await import( @@ -190,6 +205,8 @@ export async function loadWorkflowStateFromYjsSession( draftSessionId: null, reviewSessionId: null, yjsSessionId: workflowId, + }).catch((error) => { + throw new WorkflowRealtimeRequiredError(error) }) if (!snapshot.snapshotBase64) { return null @@ -979,7 +996,7 @@ export async function deployWorkflow(params: { } = params try { - const editableState = await loadWorkflowStateFromYjsSession(workflowId) + const editableState = await loadEditableWorkflowState(workflowId) if (!editableState) { return { success: false, error: 'Failed to load workflow state' } } diff --git a/apps/tradinggoose/lib/workflows/execution-runner.test.ts b/apps/tradinggoose/lib/workflows/execution-runner.test.ts index 0d37148c3..3cc4859a2 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.test.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.test.ts @@ -66,7 +66,7 @@ vi.mock('@/lib/utils-server', () => ({ vi.mock('@/lib/workflows/db-helpers', () => ({ loadDeployedWorkflowState: vi.fn(), - loadWorkflowStateFromYjsSession: vi.fn(), + loadEditableWorkflowState: vi.fn(), })) vi.mock('@/lib/workflows/triggers', () => ({ @@ -403,10 +403,10 @@ describe('loadWorkflowExecutionBlueprint', () => { }) it('loads Yjs workflow state for live execution when no snapshot is supplied', async () => { - const { loadDeployedWorkflowState, loadWorkflowStateFromYjsSession } = await import( + const { loadDeployedWorkflowState, loadEditableWorkflowState } = await import( '@/lib/workflows/db-helpers' ) - vi.mocked(loadWorkflowStateFromYjsSession).mockResolvedValueOnce({ + vi.mocked(loadEditableWorkflowState).mockResolvedValueOnce({ blocks: { trigger: { subBlocks: {} } }, edges: [{ source: 'trigger', target: 'worker' }], loops: {}, @@ -426,12 +426,12 @@ describe('loadWorkflowExecutionBlueprint', () => { expect(result.workflowData.blocks).toEqual({ trigger: { subBlocks: {} } }) expect(result.workflowContext.variables).toEqual({ risk: { value: 1 } }) expect(loadDeployedWorkflowState).not.toHaveBeenCalled() - expect(loadWorkflowStateFromYjsSession).toHaveBeenCalledWith('workflow-1') + expect(loadEditableWorkflowState).toHaveBeenCalledWith('workflow-1') expect(mocks.dbSelect).not.toHaveBeenCalled() }) it('uses variables from the active deployment for deployed execution', async () => { - const { loadDeployedWorkflowState, loadWorkflowStateFromYjsSession } = await import( + const { loadDeployedWorkflowState, loadEditableWorkflowState } = await import( '@/lib/workflows/db-helpers' ) const deployedVariables = { @@ -474,6 +474,6 @@ describe('loadWorkflowExecutionBlueprint', () => { unknown > expect(Object.keys(selectShape)).toEqual(['workspaceId']) - expect(loadWorkflowStateFromYjsSession).not.toHaveBeenCalled() + expect(loadEditableWorkflowState).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/lib/workflows/execution-runner.ts b/apps/tradinggoose/lib/workflows/execution-runner.ts index c901da6c3..21f8f57e3 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.ts @@ -8,10 +8,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { decryptSecret } from '@/lib/utils-server' -import { - loadDeployedWorkflowState, - loadWorkflowStateFromYjsSession, -} from '@/lib/workflows/db-helpers' +import { loadDeployedWorkflowState, loadEditableWorkflowState } from '@/lib/workflows/db-helpers' import { TriggerUtils } from '@/lib/workflows/triggers' import { updateWorkflowRunCounts } from '@/lib/workflows/utils' import { normalizeVariables } from '@/lib/workflows/variable-utils' @@ -270,7 +267,7 @@ export async function loadWorkflowExecutionBlueprint(params: { const executionTarget = params.executionTarget ?? 'deployed' const liveWorkflowState = executionTarget === 'live' && !params.workflowData - ? await loadWorkflowStateFromYjsSession(params.workflowId) + ? await loadEditableWorkflowState(params.workflowId) : null const workflowContext = await resolveRequiredWorkflowExecutionContext( params.workflowId, From c8f6ac49ed83b4715521b91d0d09c573ca1c2c8a Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 16:49:00 -0600 Subject: [PATCH 185/284] fix(rate-limit): fail closed for public MCP auth limits Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/auth/mcp/poll/route.ts | 3 ++- .../app/api/auth/mcp/start/route.ts | 3 ++- apps/tradinggoose/lib/api/rate-limit.ts | 2 +- .../services/queue/ExecutionLimiter.test.ts | 22 +++++++++++++++++++ .../services/queue/ExecutionLimiter.ts | 20 +++++++++++++++-- 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/apps/tradinggoose/app/api/auth/mcp/poll/route.ts b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts index 87501360b..c9cd92661 100644 --- a/apps/tradinggoose/app/api/auth/mcp/poll/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts @@ -16,7 +16,8 @@ const PollRequestSchema = z export async function POST(request: NextRequest) { const rateLimit = await checkPublicApiEndpointRateLimit(request, 'mcp-auth-poll') if (!rateLimit.allowed) { - return NextResponse.json({ error: rateLimit.error || 'Rate limit exceeded' }, { status: 429 }) + const status = rateLimit.failureKind === 'dependency' ? 503 : 429 + return NextResponse.json({ error: rateLimit.error || 'Rate limit exceeded' }, { status }) } const parsed = PollRequestSchema.safeParse(await request.json().catch(() => null)) diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.ts index 3ae560c4b..d66003040 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.ts @@ -8,7 +8,8 @@ export const dynamic = 'force-dynamic' export async function POST(request: NextRequest) { const rateLimit = await checkPublicApiEndpointRateLimit(request, 'mcp-auth-start') if (!rateLimit.allowed) { - return NextResponse.json({ error: rateLimit.error || 'Rate limit exceeded' }, { status: 429 }) + const status = rateLimit.failureKind === 'dependency' ? 503 : 429 + return NextResponse.json({ error: rateLimit.error || 'Rate limit exceeded' }, { status }) } const baseUrl = getBaseUrl() diff --git a/apps/tradinggoose/lib/api/rate-limit.ts b/apps/tradinggoose/lib/api/rate-limit.ts index 989ed24c6..670dcf9c5 100644 --- a/apps/tradinggoose/lib/api/rate-limit.ts +++ b/apps/tradinggoose/lib/api/rate-limit.ts @@ -156,7 +156,7 @@ export async function checkPublicApiEndpointRateLimit( organizationId: null, userId: null, }, - { enforceWithoutBilling: true } + { enforceWithoutBilling: true, failClosedOnError: true } ) return { diff --git a/apps/tradinggoose/services/queue/ExecutionLimiter.test.ts b/apps/tradinggoose/services/queue/ExecutionLimiter.test.ts index 687d78887..d3e3c0f2a 100644 --- a/apps/tradinggoose/services/queue/ExecutionLimiter.test.ts +++ b/apps/tradinggoose/services/queue/ExecutionLimiter.test.ts @@ -270,6 +270,28 @@ describe('ExecutionLimiter', () => { expect(result.allowed).toBe(true) expect(result.remaining).toBe(Number.MAX_SAFE_INTEGER) }) + + it('denies requests when rate limit storage throws in fail-closed mode', async () => { + vi.mocked(db.select).mockImplementationOnce(() => { + throw new Error('rate limit storage unavailable') + }) + + const result = await rateLimiter.checkRateLimitWithSubscription( + testUserId, + activeSubscription, + 'api', + false, + null, + { failClosedOnError: true } + ) + + expect(result).toMatchObject({ + allowed: false, + remaining: 0, + error: 'Rate limit service unavailable', + failureKind: 'dependency', + }) + }) }) describe('getRateLimitStatusWithSubscription', () => { diff --git a/apps/tradinggoose/services/queue/ExecutionLimiter.ts b/apps/tradinggoose/services/queue/ExecutionLimiter.ts index 73b1a89b7..0128f1d75 100644 --- a/apps/tradinggoose/services/queue/ExecutionLimiter.ts +++ b/apps/tradinggoose/services/queue/ExecutionLimiter.ts @@ -113,8 +113,14 @@ export class ExecutionLimiter { triggerType: TriggerType = 'manual', isAsync = false, billingScope?: BillingScope | null, - options: { enforceWithoutBilling?: boolean } = {} - ): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> { + options: { enforceWithoutBilling?: boolean; failClosedOnError?: boolean } = {} + ): Promise<{ + allowed: boolean + remaining: number + resetAt: Date + error?: string + failureKind?: 'dependency' + }> { try { if (!options.enforceWithoutBilling && !(await isBillingEnabledForRuntime())) { return createPermissiveRateLimitResult() @@ -299,6 +305,16 @@ export class ExecutionLimiter { resetAt: new Date(new Date(rateLimitRecord.windowStart).getTime() + RATE_LIMIT_WINDOW_MS), } } catch (error) { + if (options.failClosedOnError) { + logger.error('Error checking rate limit; denying request', error) + return { + allowed: false, + remaining: 0, + resetAt: new Date(Date.now() + RATE_LIMIT_WINDOW_MS), + error: 'Rate limit service unavailable', + failureKind: 'dependency', + } + } logger.error('Error checking rate limit; allowing request', error) return createPermissiveRateLimitResult() } From c4760d85202605d757a7e0a51dadc27004bcce96 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 17:27:51 -0600 Subject: [PATCH 186/284] fix(copilot): rate limit public MCP requests before auth Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/copilot/mcp/route.test.ts | 26 +++++++++++++++++++ .../tradinggoose/app/api/copilot/mcp/route.ts | 11 +++++++- apps/tradinggoose/lib/api/rate-limit.ts | 4 ++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index 9a0ad0db7..196de6e14 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockAuthenticateApiKeyFromHeader, mockCheckApiEndpointRateLimit, + mockCheckPublicApiEndpointRateLimit, mockGetCopilotRuntimeToolManifest, mockGetMcpServerToolIds, mockGetUserWorkspaces, @@ -16,6 +17,7 @@ const { } = vi.hoisted(() => ({ mockAuthenticateApiKeyFromHeader: vi.fn(), mockCheckApiEndpointRateLimit: vi.fn(), + mockCheckPublicApiEndpointRateLimit: vi.fn(), mockGetCopilotRuntimeToolManifest: vi.fn(), mockGetMcpServerToolIds: vi.fn(), mockGetUserWorkspaces: vi.fn(), @@ -25,6 +27,8 @@ const { vi.mock('@/lib/api/rate-limit', () => ({ checkApiEndpointRateLimit: (...args: unknown[]) => mockCheckApiEndpointRateLimit(...args), + checkPublicApiEndpointRateLimit: (...args: unknown[]) => + mockCheckPublicApiEndpointRateLimit(...args), })) vi.mock('@/lib/api-key/service', () => ({ @@ -71,6 +75,12 @@ describe('Copilot MCP route', () => { resetAt: new Date('2026-06-24T12:01:00.000Z'), userId: 'user-1', }) + mockCheckPublicApiEndpointRateLimit.mockResolvedValue({ + allowed: true, + remaining: 299, + limit: 300, + resetAt: new Date('2026-06-24T12:01:00.000Z'), + }) mockGetUserWorkspaces.mockResolvedValue([ { id: 'workspace-1', name: 'Research', permissions: 'admin' }, { id: 'workspace-2', name: 'Ops', permissions: 'read' }, @@ -184,6 +194,22 @@ describe('Copilot MCP route', () => { expect(mockGetCopilotRuntimeToolManifest).not.toHaveBeenCalled() }) + it('applies the public MCP rate limit before API-key authentication', async () => { + const { POST } = await import('./route') + mockCheckPublicApiEndpointRateLimit.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + limit: 300, + resetAt: new Date('2026-06-24T12:01:00.000Z'), + }) + + const response = await POST(createMcpRequest({ jsonrpc: '2.0', id: 2, method: 'tools/list' })) + + expect(response.status).toBe(429) + expect(mockAuthenticateApiKeyFromHeader).not.toHaveBeenCalled() + expect(mockCheckApiEndpointRateLimit).not.toHaveBeenCalled() + }) + it('rejects tools outside the external MCP allow-list', async () => { const { POST } = await import('./route') diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index 17a18b9c3..61b161999 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -1,5 +1,9 @@ import { type NextRequest, NextResponse } from 'next/server' -import { checkApiEndpointRateLimit, type RateLimitResult } from '@/lib/api/rate-limit' +import { + checkApiEndpointRateLimit, + checkPublicApiEndpointRateLimit, + type RateLimitResult, +} from '@/lib/api/rate-limit' import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' import { getCopilotRuntimeToolManifest } from '@/lib/copilot/runtime-tool-manifest' import { getMcpServerToolIds, routeExecution } from '@/lib/copilot/tools/server/router' @@ -242,6 +246,11 @@ async function handleJsonRpcRequest(entry: unknown, auth: AuthenticatedMcpUser) } export async function POST(request: NextRequest) { + const publicRateLimit = await checkPublicApiEndpointRateLimit(request, 'copilot-mcp-public') + if (!publicRateLimit.allowed) { + return mcpRateLimitResponse(publicRateLimit) + } + const auth = await authenticateCopilotMcpRequest(request) if ('error' in auth) { return mcpJsonResponse(jsonRpcError(null, -32001, auth.error), { status: 401 }) diff --git a/apps/tradinggoose/lib/api/rate-limit.ts b/apps/tradinggoose/lib/api/rate-limit.ts index 670dcf9c5..3e84549c8 100644 --- a/apps/tradinggoose/lib/api/rate-limit.ts +++ b/apps/tradinggoose/lib/api/rate-limit.ts @@ -21,12 +21,14 @@ export interface RateLimitResult { export type ApiRateLimitEndpoint = | 'api-endpoint' | 'copilot-mcp' + | 'copilot-mcp-public' | 'logs' | 'logs-detail' | 'mcp-auth-start' | 'mcp-auth-poll' const PUBLIC_API_ENDPOINT_LIMITS: Partial> = { + 'copilot-mcp-public': 300, 'mcp-auth-start': 20, 'mcp-auth-poll': 120, } @@ -131,7 +133,7 @@ function getRequesterKey(request: Request): string { export async function checkPublicApiEndpointRateLimit( request: Request, - endpoint: Extract + endpoint: Extract ): Promise { const limit = PUBLIC_API_ENDPOINT_LIMITS[endpoint] ?? 0 const scopeId = `public:${endpoint}:${getRequesterKey(request)}` From 7d94f6015ca29c73110d152d17efc64768ce337b Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 17:28:07 -0600 Subject: [PATCH 187/284] fix(workflows): standardize realtime-required API responses Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../api/workflows/[id]/autolayout/route.ts | 3 +++ .../api/workflows/[id]/deploy/route.test.ts | 1 + .../app/api/workflows/[id]/deploy/route.ts | 8 ++++++- .../workflows/[id]/duplicate/route.test.ts | 2 ++ .../app/api/workflows/[id]/duplicate/route.ts | 4 ++++ .../app/api/workflows/[id]/route.ts | 18 ++++------------ .../app/api/workflows/[id]/status/route.ts | 21 +++++++------------ apps/tradinggoose/app/api/workflows/utils.ts | 13 ++++++++++++ .../app/api/workflows/yaml/export/route.ts | 19 ++++------------- 9 files changed, 46 insertions(+), 43 deletions(-) diff --git a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts index 2c681f40f..991eb6c02 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts @@ -5,6 +5,7 @@ import { generateRequestId } from '@/lib/utils' import { applyAutoLayout } from '@/lib/workflows/autolayout' import { loadEditableWorkflowState } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' +import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' export const dynamic = 'force-dynamic' @@ -117,6 +118,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ }) } catch (error) { const elapsed = Date.now() - startTime + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse if (error instanceof z.ZodError) { logger.warn(`[${requestId}] Invalid autolayout request data`, { errors: error.errors }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts index c7047ec4e..b0c689b6b 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts @@ -58,6 +58,7 @@ describe('Workflow Deploy API Route', () => { Response.json({ error }, { status }) ), createSuccessResponse: vi.fn((data: unknown) => Response.json(data, { status: 200 })), + createWorkflowRealtimeRequiredResponse: vi.fn(() => null), })) vi.doMock('drizzle-orm', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts index 551f60109..8c7a28ded 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts @@ -11,7 +11,11 @@ import { deployWorkflow, loadEditableWorkflowState } from '@/lib/workflows/db-he import { hasWorkflowChanged, validateWorkflowPermissions } from '@/lib/workflows/utils' import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' import { pauseMonitorsMissingDeployedTrigger } from '@/app/api/monitors/shared' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { + createErrorResponse, + createSuccessResponse, + createWorkflowRealtimeRequiredResponse, +} from '@/app/api/workflows/utils' const logger = createLogger('WorkflowDeployAPI') @@ -119,6 +123,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ }) } catch (error: any) { logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error) + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return createErrorResponse(error.message || 'Failed to fetch deployment information', 500) } } diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts index 13bb006ce..043c33fdc 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts @@ -110,9 +110,11 @@ describe('Workflow Duplicate API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ + isWorkflowRealtimeRequiredError: vi.fn(() => false), loadEditableWorkflowState: loadWorkflowStateMock, regenerateWorkflowStateIds: regenerateWorkflowStateIdsMock, saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, + WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', })) }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts index 9d34c2ff2..4e36df666 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -15,6 +15,7 @@ import { } from '@/lib/workflows/db-helpers' import { remapVariableIds } from '@/lib/workflows/import-export' import { normalizeVariables } from '@/lib/workflows/variable-utils' +import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' import type { Variable } from '@/stores/variables/types' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -170,6 +171,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: { status: 201 } ) } catch (error) { + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse + if (error instanceof Error) { if (error.message === 'Source workflow not found') { logger.warn(`[${requestId}] Source workflow ${sourceWorkflowId} not found`) diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index c0f95a817..08b8b6ca8 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -9,15 +9,12 @@ import { verifyInternalTokenDetailed } from '@/lib/auth/internal' import { hydrateListingUI } from '@/lib/listing/hydrate-ui' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { - isWorkflowRealtimeRequiredError, - loadEditableWorkflowState, - WORKFLOW_REALTIME_REQUIRED_CODE, -} from '@/lib/workflows/db-helpers' +import { loadEditableWorkflowState } from '@/lib/workflows/db-helpers' import { readWorkflowAccessContext, readWorkflowById } from '@/lib/workflows/utils' import { applyWorkflowMetadata } from '@/lib/yjs/server/apply-workflow-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' const logger = createLogger('WorkflowByIdAPI') @@ -192,15 +189,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } catch (error: any) { const elapsed = Date.now() - startTime logger.error(`[${requestId}] Error fetching workflow ${workflowId} after ${elapsed}ms`, error) - if (isWorkflowRealtimeRequiredError(error)) { - return NextResponse.json( - { - error: 'Workflow realtime orchestration is required', - code: WORKFLOW_REALTIME_REQUIRED_CODE, - }, - { status: 503 } - ) - } + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts index 0f2d7324a..e2a0ccd39 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts @@ -3,14 +3,14 @@ import { and, desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { - isWorkflowRealtimeRequiredError, - loadEditableWorkflowState, - WORKFLOW_REALTIME_REQUIRED_CODE, -} from '@/lib/workflows/db-helpers' +import { loadEditableWorkflowState } from '@/lib/workflows/db-helpers' import { hasWorkflowChanged } from '@/lib/workflows/utils' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { + createErrorResponse, + createSuccessResponse, + createWorkflowRealtimeRequiredResponse, +} from '@/app/api/workflows/utils' const logger = createLogger('WorkflowStatusAPI') @@ -63,13 +63,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ }) } catch (error) { logger.error(`[${requestId}] Error getting status for workflow: ${(await params).id}`, error) - if (isWorkflowRealtimeRequiredError(error)) { - return createErrorResponse( - 'Workflow realtime orchestration is required', - 503, - WORKFLOW_REALTIME_REQUIRED_CODE - ) - } + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return createErrorResponse('Failed to get status', 500) } } diff --git a/apps/tradinggoose/app/api/workflows/utils.ts b/apps/tradinggoose/app/api/workflows/utils.ts index 75ee1ab97..cb2bae1a2 100644 --- a/apps/tradinggoose/app/api/workflows/utils.ts +++ b/apps/tradinggoose/app/api/workflows/utils.ts @@ -1,4 +1,8 @@ import { NextResponse } from 'next/server' +import { + isWorkflowRealtimeRequiredError, + WORKFLOW_REALTIME_REQUIRED_CODE, +} from '@/lib/workflows/db-helpers' export function createErrorResponse(error: string, status: number, code?: string) { return NextResponse.json( @@ -13,3 +17,12 @@ export function createErrorResponse(error: string, status: number, code?: string export function createSuccessResponse(data: any) { return NextResponse.json(data) } + +export function createWorkflowRealtimeRequiredResponse(error: unknown) { + if (!isWorkflowRealtimeRequiredError(error)) return null + return createErrorResponse( + 'Workflow realtime orchestration is required', + 503, + WORKFLOW_REALTIME_REQUIRED_CODE + ) +} diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts index 7a798a9f6..c1e6f084e 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts @@ -8,11 +8,8 @@ import { extractSubBlockValuesFromBlocks } from '@/lib/copilot/workflow/block-ou import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' -import { - isWorkflowRealtimeRequiredError, - loadEditableWorkflowState, - WORKFLOW_REALTIME_REQUIRED_CODE, -} from '@/lib/workflows/db-helpers' +import { loadEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' import { getAllBlocks } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' import { resolveOutputType } from '@/blocks/utils' @@ -185,16 +182,8 @@ export async function GET(request: NextRequest) { }) } catch (error) { logger.error(`[${requestId}] YAML export failed`, error) - if (isWorkflowRealtimeRequiredError(error)) { - return NextResponse.json( - { - success: false, - error: 'Workflow realtime orchestration is required', - code: WORKFLOW_REALTIME_REQUIRED_CODE, - }, - { status: 503 } - ) - } + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return NextResponse.json( { success: false, From e51cf12b84267e83a4671a79f63771251d9ae60a Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 17:28:21 -0600 Subject: [PATCH 188/284] fix(copilot): normalize MCP server header records Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/copilot/entity-documents.ts | 47 ++++++++++++------- .../tools/server/entities/mcp-server.ts | 19 ++------ .../tools/server/entities/shared.test.ts | 31 ++++++++++++ 3 files changed, 65 insertions(+), 32 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/entity-documents.ts b/apps/tradinggoose/lib/copilot/entity-documents.ts index 0e0749554..f323d6d8a 100644 --- a/apps/tradinggoose/lib/copilot/entity-documents.ts +++ b/apps/tradinggoose/lib/copilot/entity-documents.ts @@ -95,6 +95,7 @@ export type EntityDocumentFields = z.infer< > export const ENTITY_SECRET_PLACEHOLDER = '[redacted]' +const HTTP_HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/ function redactStringRecordValues(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -106,6 +107,32 @@ function redactStringRecordValues(value: unknown): Record { ) } +export function normalizeStringRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [ + key, + typeof item === 'string' ? item : String(item ?? ''), + ]) + ) +} + +function normalizeHttpHeaderRecord(value: unknown): Record { + const entries = Object.entries(normalizeStringRecord(value)) + .map(([key, item]) => [key.trim(), item.trim()] as const) + .filter(([key, item]) => key.length > 0 && item.length > 0) + + const invalidKey = entries.find(([key]) => !HTTP_HEADER_NAME_PATTERN.test(key))?.[0] + if (invalidKey) { + throw new Error(`Invalid MCP server header "${invalidKey}"`) + } + + return Object.fromEntries(entries) +} + export function normalizeEntityFields( kind: EntityDocumentKind, fields: Record | null | undefined @@ -153,28 +180,12 @@ export function normalizeEntityFields( ? source.transport : 'http', url: validation.normalizedUrl ?? rawUrl, - headers: - source.headers && typeof source.headers === 'object' && !Array.isArray(source.headers) - ? Object.fromEntries( - Object.entries(source.headers as Record).map(([key, value]) => [ - key, - typeof value === 'string' ? value : String(value ?? ''), - ]) - ) - : {}, + headers: normalizeHttpHeaderRecord(source.headers), command: typeof source.command === 'string' ? source.command.trim() : '', args: Array.isArray(source.args) ? source.args.map((value) => (typeof value === 'string' ? value : String(value ?? ''))) : [], - env: - source.env && typeof source.env === 'object' && !Array.isArray(source.env) - ? Object.fromEntries( - Object.entries(source.env as Record).map(([key, value]) => [ - key, - typeof value === 'string' ? value : String(value ?? ''), - ]) - ) - : {}, + env: normalizeStringRecord(source.env), timeout: typeof source.timeout === 'number' ? source.timeout : 30000, retries: typeof source.retries === 'number' ? source.retries : 3, enabled: typeof source.enabled === 'boolean' ? source.enabled : true, diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts index 7dfb53f38..e40e1eb1f 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -1,7 +1,11 @@ import { db } from '@tradinggoose/db' import { mcpServers } from '@tradinggoose/db/schema' import { and, eq, isNull } from 'drizzle-orm' -import { ENTITY_SECRET_PLACEHOLDER, normalizeEntityFields } from '@/lib/copilot/entity-documents' +import { + ENTITY_SECRET_PLACEHOLDER, + normalizeEntityFields, + normalizeStringRecord, +} from '@/lib/copilot/entity-documents' import { ENTITY_KIND_MCP_SERVER } from '@/lib/copilot/review-sessions/types' import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' import { mcpService } from '@/lib/mcp/service' @@ -21,19 +25,6 @@ import { verifyWorkspaceContext, } from './shared' -function normalizeStringRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return {} - } - - return Object.fromEntries( - Object.entries(value as Record).map(([key, item]) => [ - key, - typeof item === 'string' ? item : String(item ?? ''), - ]) - ) -} - function normalizeMcpServerDocumentFields( fields: Record ): Record { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts index 2606aa48b..a0b8e8aaf 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts @@ -210,6 +210,37 @@ describe('entity document mutation helpers', () => { expect(mockApplySavedEntityState).not.toHaveBeenCalled() }) + it('drops blank MCP server header rows before persisting state', async () => { + await executeUpdateEntityDocumentMutation( + 'mcp_server', + 'edit_mcp_server', + { + entityId: 'mcp-1', + documentFormat: MCP_SERVER_DOCUMENT_FORMAT, + entityDocument: JSON.stringify({ + name: 'Header MCP', + description: '', + transport: 'streamable-http', + url: 'https://mcp.example.test', + headers: { '': '', Authorization: ' Bearer token ' }, + command: '', + args: [], + env: {}, + timeout: 30000, + retries: 3, + enabled: true, + }), + }, + { userId: 'user-1', accessLevel: 'full' } + ) + + expect(mockApplySavedEntityState).toHaveBeenCalledWith( + 'mcp_server', + 'mcp-1', + expect.objectContaining({ headers: { Authorization: 'Bearer token' } }) + ) + }) + it('keeps Studio create mutations in review mode', async () => { const result = await executeCreateEntityDocumentMutation( 'skill', From 6208435bb5cfd325f69d6777d0c2fed977e46f74 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 18:07:54 -0600 Subject: [PATCH 189/284] fix(workflows): sanitize agent tools during normalized saves Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/workflows/db-helpers.test.ts | 67 ++++++++++++++++ apps/tradinggoose/lib/workflows/db-helpers.ts | 76 +------------------ 2 files changed, 70 insertions(+), 73 deletions(-) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index b9021c093..887fe4674 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -694,6 +694,73 @@ describe('Database Helpers', () => { }) }) + it('should sanitize invalid embedded custom tools while saving workflow blocks', async () => { + let capturedBlockInserts: any[] = [] + const workflowState = { + blocks: { + agent: { + id: 'agent', + type: 'agent', + name: 'Agent', + position: { x: 0, y: 0 }, + subBlocks: { + tools: { + id: 'tools', + type: 'tool-input', + value: [ + { + type: 'custom-tool', + title: 'Valid tool', + toolId: 'custom_valid-tool', + schema: { + function: { + parameters: { type: 'object', properties: {} }, + }, + }, + }, + { type: 'custom-tool', title: 'Invalid tool', toolId: 'not-custom' }, + ], + }, + }, + outputs: {}, + enabled: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + lastSaved: Date.now(), + } as unknown as WorkflowState + + mockDb.transaction = vi.fn().mockImplementation(async (callback) => { + const tx = createMockTx({ + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockImplementation((data) => { + if (Array.isArray(data) && data[0]?.positionX !== undefined) { + capturedBlockInserts = data + } + return Promise.resolve([]) + }), + }), + }) + return callback(tx) + }) + + const result = await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, workflowState) + const savedTools = capturedBlockInserts[0].subBlocks.tools.value + + expect(result.success).toBe(true) + expect(savedTools).toEqual([ + expect.objectContaining({ + title: 'Valid tool', + toolId: 'custom_valid-tool', + code: '', + usageControl: 'auto', + }), + ]) + expect(result.normalizedState?.blocks.agent.subBlocks?.tools.value).toEqual(savedTools) + }) + it('should regenerate edge ids that conflict with another workflow', async () => { let capturedEdgeInserts: any[] = [] diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 785e1abe3..20d55368a 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -1,5 +1,4 @@ import { - customTools, db, workflow, workflowBlocks, @@ -13,7 +12,6 @@ import { and, desc, eq, inArray, ne, sql } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' import * as Y from 'yjs' import { reconcilePublishedChatsForDeploymentTx } from '@/lib/chat/published-deployment' -import { getCustomToolEntityIdFromRuntimeId } from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' import { inferWorkflowDirectionFromState } from '@/lib/workflows/workflow-direction' @@ -80,75 +78,6 @@ const sanitizeBlockLayout = (layout: unknown): BlockState['layout'] => { return nextLayout } -const saveEmbeddedCustomTools = async ( - tx: WorkflowDbTransaction, - workflowId: string, - blocks: Iterable -) => { - const embeddedTools = new Map() - for (const block of blocks) { - if (!block || block.type !== 'agent') continue - const tools = block.subBlocks?.tools?.value - if (!Array.isArray(tools)) continue - for (const tool of tools) { - if ( - !tool || - typeof tool !== 'object' || - tool.type !== 'custom-tool' || - typeof tool.title !== 'string' || - typeof tool.toolId !== 'string' || - typeof tool.code !== 'string' || - !tool.schema?.function - ) { - continue - } - const title = tool.title.trim() - if (!title) continue - embeddedTools.set(getCustomToolEntityIdFromRuntimeId(tool.toolId), { - title, - schema: tool.schema, - code: tool.code, - }) - } - } - if (embeddedTools.size === 0) return - - const [owner] = await tx - .select({ workspaceId: workflow.workspaceId, userId: workflow.userId }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - - const workspaceId = owner?.workspaceId - if (!workspaceId) return - - const now = new Date() - await tx - .insert(customTools) - .values( - Array.from(embeddedTools, ([id, tool]) => ({ - id, - workspaceId, - userId: owner.userId, - title: tool.title, - schema: tool.schema, - code: tool.code, - createdAt: now, - updatedAt: now, - })) - ) - .onConflictDoUpdate({ - target: customTools.id, - set: { - title: sql.raw('excluded."title"'), - schema: sql.raw('excluded."schema"'), - code: sql.raw('excluded."code"'), - updatedAt: now, - }, - setWhere: eq(customTools.workspaceId, workspaceId), - }) -} - export type PersistedWorkflowState = { name?: string | null description?: string | null @@ -755,7 +684,9 @@ export async function saveWorkflowToNormalizedTables( ): Promise<{ success: boolean; error?: string; normalizedState?: WorkflowState }> { try { const stateWithUniqueBlockIds = await ensureUniqueBlockIds(workflowId, state) - const normalizedState = await ensureUniqueEdgeIds(workflowId, stateWithUniqueBlockIds) + const stateWithUniqueEdgeIds = await ensureUniqueEdgeIds(workflowId, stateWithUniqueBlockIds) + const { blocks } = sanitizeAgentToolsInBlocks(stateWithUniqueEdgeIds.blocks || {}) + const normalizedState = { ...stateWithUniqueEdgeIds, blocks } const sanitizeNumberForDecimal = (value: unknown): string => { if (typeof value !== 'number' || !Number.isFinite(value)) { @@ -908,7 +839,6 @@ export async function saveWorkflowToNormalizedTables( await tx.insert(workflowSubflows).values(subflowInserts) } - await saveEmbeddedCustomTools(tx, workflowId, sanitizedBlockRecords) await commit?.(tx, normalizedState) }) From 1f3ece9103632250fab1c4dc83c9a152ac62d92a Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 19:50:15 -0600 Subject: [PATCH 190/284] fix(workflows): require realtime state for editable workflows Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/workflows/[id]/autolayout/route.ts | 4 ++-- .../app/api/workflows/[id]/deploy/route.test.ts | 2 +- .../app/api/workflows/[id]/deploy/route.ts | 6 ++++-- .../app/api/workflows/[id]/duplicate/route.test.ts | 2 +- .../app/api/workflows/[id]/duplicate/route.ts | 4 ++-- .../app/api/workflows/[id]/route.test.ts | 2 +- apps/tradinggoose/app/api/workflows/[id]/route.ts | 4 ++-- .../app/api/workflows/[id]/status/route.test.ts | 2 +- .../app/api/workflows/[id]/status/route.ts | 4 ++-- .../app/api/workflows/yaml/export/route.test.ts | 2 +- .../app/api/workflows/yaml/export/route.ts | 4 ++-- apps/tradinggoose/lib/workflows/db-helpers.test.ts | 8 ++++---- apps/tradinggoose/lib/workflows/db-helpers.ts | 7 +++++-- .../lib/workflows/execution-runner.test.ts | 12 ++++++------ apps/tradinggoose/lib/workflows/execution-runner.ts | 4 ++-- 15 files changed, 36 insertions(+), 31 deletions(-) diff --git a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts index 991eb6c02..209efb57d 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applyAutoLayout } from '@/lib/workflows/autolayout' -import { loadEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { requireEditableWorkflowState } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' @@ -62,7 +62,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } } else { logger.info(`[${requestId}] Loading blocks from current workflow state`) - currentWorkflowData = await loadEditableWorkflowState(workflowId) + currentWorkflowData = await requireEditableWorkflowState(workflowId) } if (!currentWorkflowData) { diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts index b0c689b6b..bb2a4abd6 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts @@ -37,7 +37,7 @@ describe('Workflow Deploy API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ deployWorkflow: vi.fn(), - loadEditableWorkflowState: (...args: unknown[]) => mockLoadWorkflowState(...args), + requireEditableWorkflowState: (...args: unknown[]) => mockLoadWorkflowState(...args), })) vi.doMock('@/lib/chat/published-deployment', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts index 8c7a28ded..286d3dcd9 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts @@ -7,7 +7,7 @@ import { } from '@/lib/chat/published-deployment' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { deployWorkflow, loadEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { deployWorkflow, requireEditableWorkflowState } from '@/lib/workflows/db-helpers' import { hasWorkflowChanged, validateWorkflowPermissions } from '@/lib/workflows/utils' import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' import { pauseMonitorsMissingDeployedTrigger } from '@/app/api/monitors/shared' @@ -103,7 +103,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .limit(1) if (active?.state) { - const currentState = await loadEditableWorkflowState(id) + const currentState = await requireEditableWorkflowState(id) if (currentState) { needsRedeployment = hasWorkflowChanged(currentState, active.state as any) } @@ -296,6 +296,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ cause: error.cause, fullError: error, }) + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return createErrorResponse(error.message || 'Failed to deploy workflow', 500) } } diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts index 043c33fdc..8fcbe5a7b 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts @@ -111,7 +111,7 @@ describe('Workflow Duplicate API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ isWorkflowRealtimeRequiredError: vi.fn(() => false), - loadEditableWorkflowState: loadWorkflowStateMock, + requireEditableWorkflowState: loadWorkflowStateMock, regenerateWorkflowStateIds: regenerateWorkflowStateIdsMock, saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts index 4e36df666..8464434d1 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -9,8 +9,8 @@ import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' import { - loadEditableWorkflowState, regenerateWorkflowStateIds, + requireEditableWorkflowState, saveWorkflowToNormalizedTables, } from '@/lib/workflows/db-helpers' import { remapVariableIds } from '@/lib/workflows/import-export' @@ -32,7 +32,7 @@ async function loadSourceWorkflowArtifacts(sourceWorkflowId: string): Promise<{ workflowState: WorkflowState variables: Record }> { - const editableState = await loadEditableWorkflowState(sourceWorkflowId) + const editableState = await requireEditableWorkflowState(sourceWorkflowId) if (!editableState) { throw new Error('Failed to load source workflow state') } diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index eb93c07c9..3c1f9349c 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -36,7 +36,7 @@ describe('Workflow By ID API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', isWorkflowRealtimeRequiredError: vi.fn(() => false), - loadEditableWorkflowState: mockLoadWorkflowState, + requireEditableWorkflowState: mockLoadWorkflowState, })) vi.doMock('@tradinggoose/db', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index 08b8b6ca8..a01d0dacf 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -9,7 +9,7 @@ import { verifyInternalTokenDetailed } from '@/lib/auth/internal' import { hydrateListingUI } from '@/lib/listing/hydrate-ui' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { loadEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { requireEditableWorkflowState } from '@/lib/workflows/db-helpers' import { readWorkflowAccessContext, readWorkflowById } from '@/lib/workflows/utils' import { applyWorkflowMetadata } from '@/lib/yjs/server/apply-workflow-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' @@ -126,7 +126,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from Yjs session`) - const workflowState = await loadEditableWorkflowState(workflowId) + const workflowState = await requireEditableWorkflowState(workflowId) if (!workflowState) { logger.warn(`[${requestId}] Workflow ${workflowId} is missing saved state`) diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts index 7f0044f38..a9ed4937e 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts @@ -56,7 +56,7 @@ describe('Workflow Status API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', isWorkflowRealtimeRequiredError: vi.fn(() => false), - loadEditableWorkflowState: mockLoadWorkflowState, + requireEditableWorkflowState: mockLoadWorkflowState, })) vi.doMock('@/lib/workflows/utils', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts index e2a0ccd39..65621d813 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts @@ -3,7 +3,7 @@ import { and, desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { loadEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { requireEditableWorkflowState } from '@/lib/workflows/db-helpers' import { hasWorkflowChanged } from '@/lib/workflows/utils' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' import { @@ -32,7 +32,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ if (validation.workflow.isDeployed) { // Load current workflow state and the active deployment version in parallel. const [currentState, [active]] = await Promise.all([ - loadEditableWorkflowState(id), + requireEditableWorkflowState(id), db .select({ state: workflowDeploymentVersion.state }) .from(workflowDeploymentVersion) diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts index 53b842eb4..40b52a24a 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts @@ -94,7 +94,7 @@ describe('Workflow YAML Export API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', isWorkflowRealtimeRequiredError: vi.fn(() => false), - loadEditableWorkflowState: loadWorkflowStateMock, + requireEditableWorkflowState: loadWorkflowStateMock, })) vi.doMock('@/lib/copilot/workflow/block-output-utils', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts index c1e6f084e..5030b0e46 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts @@ -8,7 +8,7 @@ import { extractSubBlockValuesFromBlocks } from '@/lib/copilot/workflow/block-ou import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' -import { loadEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { requireEditableWorkflowState } from '@/lib/workflows/db-helpers' import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' import { getAllBlocks } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' @@ -71,7 +71,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const editableState = await loadEditableWorkflowState(workflowId) + const editableState = await requireEditableWorkflowState(workflowId) if (!editableState) { return NextResponse.json( diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index 887fe4674..23ffb659c 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -1015,7 +1015,7 @@ describe('Database Helpers', () => { }) }) - describe('loadEditableWorkflowState', () => { + describe('requireEditableWorkflowState', () => { it('loads workflow state through a bootstrapped Yjs session', async () => { const yjsState = { direction: 'LR' as const, @@ -1033,7 +1033,7 @@ describe('Database Helpers', () => { buildWorkflowSnapshotResponseFromState(yjsState, yjsVariables) ) - const result = await dbHelpers.loadEditableWorkflowState(mockWorkflowId) + const result = await dbHelpers.requireEditableWorkflowState(mockWorkflowId) expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalledWith({ workspaceId: null, @@ -1068,7 +1068,7 @@ describe('Database Helpers', () => { }, }) - const result = await dbHelpers.loadEditableWorkflowState(mockWorkflowId) + const result = await dbHelpers.requireEditableWorkflowState(mockWorkflowId) expect(result).toBeNull() expect(mockDb.select).not.toHaveBeenCalled() @@ -1077,7 +1077,7 @@ describe('Database Helpers', () => { it('requires the live Yjs bridge for editable workflow state', async () => { mockReadBootstrappedReviewTargetSnapshot.mockRejectedValue(new Error('bridge unavailable')) - await expect(dbHelpers.loadEditableWorkflowState(mockWorkflowId)).rejects.toThrow( + await expect(dbHelpers.requireEditableWorkflowState(mockWorkflowId)).rejects.toThrow( 'bridge unavailable' ) expect(mockDb.select).not.toHaveBeenCalled() diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 20d55368a..157cfea4f 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -121,7 +121,7 @@ function decodeWorkflowSnapshot(snapshotBase64: string): PersistedWorkflowState * used by the Yjs bootstrap path when a session is not already live. Bridge * failures intentionally surface instead of falling back to stale saved tables. */ -export async function loadEditableWorkflowState( +export async function requireEditableWorkflowState( workflowId: string ): Promise { const { readBootstrappedReviewTargetSnapshot } = await import( @@ -926,7 +926,7 @@ export async function deployWorkflow(params: { } = params try { - const editableState = await loadEditableWorkflowState(workflowId) + const editableState = await requireEditableWorkflowState(workflowId) if (!editableState) { return { success: false, error: 'Failed to load workflow state' } } @@ -1045,6 +1045,9 @@ export async function deployWorkflow(params: { } } catch (error) { logger.error(`Error deploying workflow ${workflowId}:`, error) + if (isWorkflowRealtimeRequiredError(error)) { + throw error + } return { success: false, error: error instanceof Error ? error.message : 'Unknown error', diff --git a/apps/tradinggoose/lib/workflows/execution-runner.test.ts b/apps/tradinggoose/lib/workflows/execution-runner.test.ts index 3cc4859a2..ceaf63ded 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.test.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.test.ts @@ -66,7 +66,7 @@ vi.mock('@/lib/utils-server', () => ({ vi.mock('@/lib/workflows/db-helpers', () => ({ loadDeployedWorkflowState: vi.fn(), - loadEditableWorkflowState: vi.fn(), + requireEditableWorkflowState: vi.fn(), })) vi.mock('@/lib/workflows/triggers', () => ({ @@ -403,10 +403,10 @@ describe('loadWorkflowExecutionBlueprint', () => { }) it('loads Yjs workflow state for live execution when no snapshot is supplied', async () => { - const { loadDeployedWorkflowState, loadEditableWorkflowState } = await import( + const { loadDeployedWorkflowState, requireEditableWorkflowState } = await import( '@/lib/workflows/db-helpers' ) - vi.mocked(loadEditableWorkflowState).mockResolvedValueOnce({ + vi.mocked(requireEditableWorkflowState).mockResolvedValueOnce({ blocks: { trigger: { subBlocks: {} } }, edges: [{ source: 'trigger', target: 'worker' }], loops: {}, @@ -426,12 +426,12 @@ describe('loadWorkflowExecutionBlueprint', () => { expect(result.workflowData.blocks).toEqual({ trigger: { subBlocks: {} } }) expect(result.workflowContext.variables).toEqual({ risk: { value: 1 } }) expect(loadDeployedWorkflowState).not.toHaveBeenCalled() - expect(loadEditableWorkflowState).toHaveBeenCalledWith('workflow-1') + expect(requireEditableWorkflowState).toHaveBeenCalledWith('workflow-1') expect(mocks.dbSelect).not.toHaveBeenCalled() }) it('uses variables from the active deployment for deployed execution', async () => { - const { loadDeployedWorkflowState, loadEditableWorkflowState } = await import( + const { loadDeployedWorkflowState, requireEditableWorkflowState } = await import( '@/lib/workflows/db-helpers' ) const deployedVariables = { @@ -474,6 +474,6 @@ describe('loadWorkflowExecutionBlueprint', () => { unknown > expect(Object.keys(selectShape)).toEqual(['workspaceId']) - expect(loadEditableWorkflowState).not.toHaveBeenCalled() + expect(requireEditableWorkflowState).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/lib/workflows/execution-runner.ts b/apps/tradinggoose/lib/workflows/execution-runner.ts index 21f8f57e3..4af4d8302 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.ts @@ -8,7 +8,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { decryptSecret } from '@/lib/utils-server' -import { loadDeployedWorkflowState, loadEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { loadDeployedWorkflowState, requireEditableWorkflowState } from '@/lib/workflows/db-helpers' import { TriggerUtils } from '@/lib/workflows/triggers' import { updateWorkflowRunCounts } from '@/lib/workflows/utils' import { normalizeVariables } from '@/lib/workflows/variable-utils' @@ -267,7 +267,7 @@ export async function loadWorkflowExecutionBlueprint(params: { const executionTarget = params.executionTarget ?? 'deployed' const liveWorkflowState = executionTarget === 'live' && !params.workflowData - ? await loadEditableWorkflowState(params.workflowId) + ? await requireEditableWorkflowState(params.workflowId) : null const workflowContext = await resolveRequiredWorkflowExecutionContext( params.workflowId, From d77f6176a15b32d4fb800aa11bd0de6b4b7bb0a1 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 20:21:21 -0600 Subject: [PATCH 191/284] fix(knowledge): surface saved entity persistence errors Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/knowledge/[id]/route.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/tradinggoose/app/api/knowledge/[id]/route.ts b/apps/tradinggoose/app/api/knowledge/[id]/route.ts index 3e946fafe..ba2d63b52 100644 --- a/apps/tradinggoose/app/api/knowledge/[id]/route.ts +++ b/apps/tradinggoose/app/api/knowledge/[id]/route.ts @@ -8,6 +8,7 @@ import { } from '@/lib/knowledge/service' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' +import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('KnowledgeBaseByIdAPI') @@ -133,6 +134,9 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: } } catch (error) { logger.error(`[${requestId}] Error updating knowledge base`, error) + if (error instanceof SavedEntityPersistenceError) { + return NextResponse.json({ error: error.message }, { status: error.status }) + } return NextResponse.json({ error: 'Failed to update knowledge base' }, { status: 500 }) } } From 84882b2e72366465b8d7da009d088987201f2637 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 20:21:47 -0600 Subject: [PATCH 192/284] fix(workflows): surface realtime orchestration failures Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../[version]/revert/route.test.ts | 1 + .../deployments/[version]/revert/route.ts | 8 +++++- .../app/api/workflows/[id]/route.ts | 2 ++ .../yjs/server/apply-workflow-state.test.ts | 8 ++++++ .../lib/yjs/server/apply-workflow-state.ts | 26 ++++++++++++++----- 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts index ab649f62f..0cdf8cd22 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts @@ -104,6 +104,7 @@ describe('Revert To Deployment Version API Route', () => { vi.doMock('@/app/api/workflows/utils', () => ({ createErrorResponse: vi.fn((error, status) => Response.json({ error }, { status })), createSuccessResponse: vi.fn((data) => Response.json({ data }, { status: 200 })), + createWorkflowRealtimeRequiredResponse: vi.fn(() => null), })) vi.doMock('@/app/api/monitors/reconcile', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts index 9d09833f7..888cfeaa3 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -9,7 +9,11 @@ import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' import { pauseMonitorsMissingDeployedTrigger } from '@/app/api/monitors/shared' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { + createErrorResponse, + createSuccessResponse, + createWorkflowRealtimeRequiredResponse, +} from '@/app/api/workflows/utils' const logger = createLogger('RevertToDeploymentVersionAPI') @@ -107,6 +111,8 @@ export async function POST( }) } catch (error: any) { logger.error('Error reverting to deployment version', error) + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return createErrorResponse(error.message || 'Failed to revert', 500) } } diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index a01d0dacf..2e43c9f07 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -391,6 +391,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } logger.error(`[${requestId}] Error updating workflow ${workflowId} after ${elapsed}ms`, error) + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts index 1c53377fd..bcebe8100 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts @@ -43,6 +43,14 @@ vi.mock('drizzle-orm', () => ({ vi.mock('@/lib/workflows/db-helpers', () => ({ ensureUniqueBlockIds: mockEnsureUniqueBlockIds, ensureUniqueEdgeIds: mockEnsureUniqueEdgeIds, + WorkflowRealtimeRequiredError: class WorkflowRealtimeRequiredError extends Error { + readonly code = 'WORKFLOW_REALTIME_REQUIRED' + + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : 'Workflow realtime orchestration is required') + this.name = 'WorkflowRealtimeRequiredError' + } + }, })) vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts index 61e0ed5ed..bcc6a3c87 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -1,6 +1,10 @@ import { db, workflow } from '@tradinggoose/db' import { eq } from 'drizzle-orm' -import { ensureUniqueBlockIds, ensureUniqueEdgeIds } from '@/lib/workflows/db-helpers' +import { + ensureUniqueBlockIds, + ensureUniqueEdgeIds, + WorkflowRealtimeRequiredError, +} from '@/lib/workflows/db-helpers' import { applyWorkflowPatchInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot, @@ -29,18 +33,26 @@ export async function applyWorkflowState( lastSaved: syncedAt.toISOString(), }) - await applyWorkflowPatchInSocketServer(workflowId, { - workflowState: storedWorkflowState, - ...(variables === undefined ? {} : { variables }), - ...(metadata ? { metadata } : {}), - }) + try { + await applyWorkflowPatchInSocketServer(workflowId, { + workflowState: storedWorkflowState, + ...(variables === undefined ? {} : { variables }), + ...(metadata ? { metadata } : {}), + }) + } catch (error) { + throw new WorkflowRealtimeRequiredError(error) + } } export async function applyWorkflowMetadata( workflowId: string, metadata: WorkflowMetadataPatch ): Promise { - await applyWorkflowPatchInSocketServer(workflowId, { metadata }) + try { + await applyWorkflowPatchInSocketServer(workflowId, { metadata }) + } catch (error) { + throw new WorkflowRealtimeRequiredError(error) + } const [updatedWorkflow] = await db .select() From 5506de4c3fe3c65dc1700c67d6d3655e32bc8f89 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 20:59:18 -0600 Subject: [PATCH 193/284] feat(indicators): infer input metadata from pine code Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../indicators/custom/import/route.test.ts | 5 +- .../app/api/indicators/custom/route.ts | 2 - .../management/indicator-input-fields.tsx | 4 +- apps/tradinggoose/hooks/queries/indicators.ts | 11 +-- .../lib/copilot/entity-documents.ts | 5 +- .../lib/copilot/process-contents.ts | 1 - .../tools/server/entities/indicator.ts | 7 -- .../tools/server/entities/shared.test.ts | 24 ++--- .../lib/indicators/custom/operations.ts | 9 +- .../generated/copilot-indicator-reference.ts | 95 ++++++++----------- .../lib/indicators/import-export.test.ts | 24 +---- .../lib/indicators/import-export.ts | 26 ++--- .../tradinggoose/lib/indicators/input-meta.ts | 40 +++++--- .../lib/indicators/monitor-config.ts | 2 +- apps/tradinggoose/lib/indicators/types.ts | 1 - .../components/pine-indicator-code-panel.tsx | 1 - .../indicator-list/indicator-list.tsx | 14 +-- 17 files changed, 106 insertions(+), 165 deletions(-) diff --git a/apps/tradinggoose/app/api/indicators/custom/import/route.test.ts b/apps/tradinggoose/app/api/indicators/custom/import/route.test.ts index fdfe1d765..b80cfb58e 100644 --- a/apps/tradinggoose/app/api/indicators/custom/import/route.test.ts +++ b/apps/tradinggoose/app/api/indicators/custom/import/route.test.ts @@ -73,7 +73,9 @@ describe('Indicators import route', () => { { name: 'RSI Export Example', pineCode: "indicator('RSI Export Example')", - inputMeta: {}, + inputMeta: { + Stale: { title: 'Stale', type: 'string', defval: 'old' }, + }, }, ], }, @@ -93,7 +95,6 @@ describe('Indicators import route', () => { { name: 'RSI Export Example', pineCode: "indicator('RSI Export Example')", - inputMeta: {}, }, ], }) diff --git a/apps/tradinggoose/app/api/indicators/custom/route.ts b/apps/tradinggoose/app/api/indicators/custom/route.ts index de46dc0c3..88dc5bec1 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -38,7 +38,6 @@ const IndicatorSchema = z.object({ id: z.string().optional(), name: z.string().min(1, 'Indicator name is required'), pineCode: z.string().default(''), - inputMeta: z.record(z.any()).optional(), }) ), }) @@ -167,7 +166,6 @@ export async function POST(request: NextRequest) { id: indicatorsToSave[0].id!, name: indicatorsToSave[0].name, pineCode: indicatorsToSave[0].pineCode, - inputMeta: indicatorsToSave[0].inputMeta, }, workspaceId, requestId, diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/indicator-input-fields.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/indicator-input-fields.tsx index fe3b20202..4de406c34 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/indicator-input-fields.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/management/indicator-input-fields.tsx @@ -4,7 +4,6 @@ import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' import { Select, SelectContent, @@ -14,6 +13,7 @@ import { } from '@/components/ui/select' import { buildInputsMapFromMeta } from '@/lib/indicators/input-meta' import type { InputMeta, InputMetaMap } from '@/lib/indicators/types' +import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' type IndicatorInputFieldsProps = { inputMeta: InputMetaMap | undefined @@ -91,7 +91,7 @@ const patchSparseInput = ( const next = { ...sparseInputs } const coerced = coerceDraftValue(meta, rawValue) - const defaultValue = coerceDraftValue(meta, meta.value ?? meta.defval) + const defaultValue = coerceDraftValue(meta, meta.defval) if (typeof coerced === 'undefined' || valuesEqual(coerced, defaultValue)) { delete next[title] diff --git a/apps/tradinggoose/hooks/queries/indicators.ts b/apps/tradinggoose/hooks/queries/indicators.ts index bd4515979..df3d9ec12 100644 --- a/apps/tradinggoose/hooks/queries/indicators.ts +++ b/apps/tradinggoose/hooks/queries/indicators.ts @@ -142,7 +142,7 @@ export function useIndicators(workspaceId: string) { interface CreateIndicatorParams { workspaceId: string - indicator: Pick + indicator: Pick } export function useCreateIndicator() { @@ -183,9 +183,7 @@ export function useCreateIndicator() { interface UpdateIndicatorParams { workspaceId: string indicatorId: string - updates: Partial< - Omit - > + updates: Partial> } interface ImportIndicatorsParams { @@ -209,10 +207,6 @@ export function useUpdateIndicator() { throw new Error('Indicator not found') } - const resolvedInputMeta = Object.hasOwn(updates, 'inputMeta') - ? updates.inputMeta - : currentIndicator.inputMeta - const response = await fetch(API_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -222,7 +216,6 @@ export function useUpdateIndicator() { id: indicatorId, name: updates.name ?? currentIndicator.name, pineCode: updates.pineCode ?? currentIndicator.pineCode, - inputMeta: resolvedInputMeta, }, ], workspaceId, diff --git a/apps/tradinggoose/lib/copilot/entity-documents.ts b/apps/tradinggoose/lib/copilot/entity-documents.ts index f323d6d8a..cb4c5f934 100644 --- a/apps/tradinggoose/lib/copilot/entity-documents.ts +++ b/apps/tradinggoose/lib/copilot/entity-documents.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' -import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' +import { inferInputMetaFromPineCode } from '@/lib/indicators/input-meta' import { validateMcpServerUrl } from '@/lib/mcp/url-validator' export const SKILL_DOCUMENT_FORMAT = 'tg-skill-document-v1' as const export const CUSTOM_TOOL_DOCUMENT_FORMAT = 'tg-custom-tool-document-v1' as const @@ -43,7 +43,6 @@ const IndicatorDocumentSchema = z.object({ name: z.string(), color: z.string(), pineCode: z.string(), - inputMeta: z.record(z.unknown()).nullable(), }) const McpServerDocumentSchema = z.object({ @@ -160,7 +159,7 @@ export function normalizeEntityFields( name: typeof source.name === 'string' ? source.name.trim() : '', color: typeof source.color === 'string' ? source.color.trim() : '', pineCode, - inputMeta: normalizeInputMetaMap(source.inputMeta) ?? null, + inputMeta: inferInputMetaFromPineCode(pineCode) ?? null, } } case 'mcp_server': { diff --git a/apps/tradinggoose/lib/copilot/process-contents.ts b/apps/tradinggoose/lib/copilot/process-contents.ts index 949e0b1b3..93e119c4d 100644 --- a/apps/tradinggoose/lib/copilot/process-contents.ts +++ b/apps/tradinggoose/lib/copilot/process-contents.ts @@ -273,7 +273,6 @@ function serializeEntityContext( name: row.name ?? null, color: row.color ?? null, pineCode: row.pineCode ?? null, - inputMeta: row.inputMeta ?? null, } case 'custom_tool': return { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts index bb87cd2d1..dfe3c19c7 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts @@ -79,12 +79,6 @@ async function createIndicatorEntity( name: String(fields.name ?? ''), color: String(fields.color ?? ''), pineCode: String(fields.pineCode ?? ''), - inputMeta: - fields.inputMeta && - typeof fields.inputMeta === 'object' && - !Array.isArray(fields.inputMeta) - ? (fields.inputMeta as Record) - : undefined, }, ], }) @@ -128,7 +122,6 @@ export const readIndicatorServerTool: EntityServerTool = { name: defaultIndicator.name, color: '', pineCode: defaultIndicator.pineCode, - inputMeta: defaultIndicator.inputMeta ?? null, }) } diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts index a0b8e8aaf..afb6521b4 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts @@ -115,16 +115,11 @@ describe('entity document mutation helpers', () => { expect(mockApplySavedEntityState).toHaveBeenCalledWith('skill', 'skill-1', nextFields) }) - it('preserves indicator input metadata when applying document updates', async () => { - const inputMeta = { - Mode: { - title: 'Mode', - type: 'string', - defval: 'fast', - options: ['fast', 'slow'], - value: 'slow', - }, - } + it('keeps indicator input metadata out of Copilot document updates', async () => { + const pineCode = ` +const mode = input.enum('fast', 'Mode', ['fast', 'slow']) +const length = input.int(14, 'Length', 1, 50, 1) +` await executeUpdateEntityDocumentMutation( 'indicator', @@ -135,8 +130,10 @@ describe('entity document mutation helpers', () => { entityDocument: JSON.stringify({ name: 'Updated Indicator', color: '#10b981', - pineCode: "const mode = input.string('fast', 'Mode')", - inputMeta, + pineCode, + inputMeta: { + Stale: { title: 'Stale', type: 'string', defval: 'old' }, + }, }), }, { userId: 'user-1', accessLevel: 'full' } @@ -145,8 +142,7 @@ describe('entity document mutation helpers', () => { expect(mockApplySavedEntityState).toHaveBeenCalledWith('indicator', 'indicator-1', { name: 'Updated Indicator', color: '#10b981', - pineCode: "const mode = input.string('fast', 'Mode')", - inputMeta, + pineCode, }) }) diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index 342e54774..e15e569f3 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -6,7 +6,7 @@ import { type IndicatorTransferRecord, resolveImportedIndicatorName, } from '@/lib/indicators/import-export' -import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' +import { inferInputMetaFromPineCode, normalizeInputMetaMap } from '@/lib/indicators/input-meta' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' @@ -31,7 +31,6 @@ interface CreateIndicatorsParams { name: string color?: string pineCode: string - inputMeta?: Record }> workspaceId: string userId: string @@ -43,7 +42,6 @@ interface SaveIndicatorParams { id: string name: string pineCode: string - inputMeta?: Record } workspaceId: string requestId?: string @@ -79,7 +77,7 @@ export async function createIndicators({ name: indicator.name, color: indicator.color?.trim() || getStableVibrantColor(indicatorId), pineCode: indicator.pineCode, - inputMeta: indicator.inputMeta ?? null, + inputMeta: inferInputMetaFromPineCode(indicator.pineCode) ?? null, createdAt: nowTime, updatedAt: nowTime, }) @@ -113,7 +111,6 @@ export async function saveIndicator({ name: indicator.name, color: existing.color ?? getStableVibrantColor(indicator.id), pineCode: indicator.pineCode, - inputMeta: indicator.inputMeta ?? null, }) logger.info(`[${requestId}] Saved Indicator ${indicator.id}`) return db @@ -156,7 +153,7 @@ export async function importIndicators({ name: nextName, color: getStableVibrantColor(indicatorId), pineCode: indicator.pineCode, - inputMeta: indicator.inputMeta ?? null, + inputMeta: inferInputMetaFromPineCode(indicator.pineCode) ?? null, createdAt: nowTime, updatedAt: nowTime, } diff --git a/apps/tradinggoose/lib/indicators/generated/copilot-indicator-reference.ts b/apps/tradinggoose/lib/indicators/generated/copilot-indicator-reference.ts index fd2b502bc..3ced2127f 100644 --- a/apps/tradinggoose/lib/indicators/generated/copilot-indicator-reference.ts +++ b/apps/tradinggoose/lib/indicators/generated/copilot-indicator-reference.ts @@ -127,7 +127,7 @@ export const INDICATOR_REFERENCE_SECTION_RECORDS = [ detail: 'TradingGoose saves indicators as JSON documents using `tg-indicator-document-v1`. The canonical field set is derived from the live indicator document schema.', support: 'curated', - relatedIds: ['document.format', 'document.name', 'document.pineCode', 'document.inputMeta'], + relatedIds: ['document.format', 'document.name', 'document.pineCode'], sourceReferences: [ { label: 'Indicator document schema', @@ -135,7 +135,7 @@ export const INDICATOR_REFERENCE_SECTION_RECORDS = [ }, ], queryText: - 'section:document indicator document saved indicator document format and field-level requirements. tradinggoose saves indicators as json documents using `tg-indicator-document-v1`. the canonical field set is derived from the live indicator document schema. document.format document.name document.pinecode document.inputmeta', + 'section:document indicator document saved indicator document format and field-level requirements. tradinggoose saves indicators as json documents using `tg-indicator-document-v1`. the canonical field set is derived from the live indicator document schema. document.format document.name document.pinecode', }, { id: 'section:runtime', @@ -299,10 +299,10 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ title: 'Document Format', summary: 'Canonical indicator document format id and top-level field set.', detail: - 'TradingGoose indicator editing tools expect `tg-indicator-document-v1` JSON with the live field set `name, pineCode, inputMeta`.', + 'TradingGoose indicator editing tools expect `tg-indicator-document-v1` JSON with `name`, `color`, and `pineCode`.', support: 'curated', - signature: 'tg-indicator-document-v1 = { name, pineCode, inputMeta }', - relatedIds: ['document.name', 'document.pineCode', 'document.inputMeta'], + signature: 'tg-indicator-document-v1 = { name, color, pineCode }', + relatedIds: ['document.name', 'document.pineCode'], sourceReferences: [ { label: 'Indicator document schema', @@ -310,7 +310,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ }, ], queryText: - 'document.format section:document document format canonical indicator document format id and top-level field set. tradinggoose indicator editing tools expect `tg-indicator-document-v1` json with the live field set `name, pinecode, inputmeta`. tg-indicator-document-v1 = { name, pinecode, inputmeta } document.name document.pinecode document.inputmeta', + 'document.format section:document document format canonical indicator document format id and top-level field set. tradinggoose indicator editing tools expect `tg-indicator-document-v1` json with `name`, `color`, and `pinecode`. tg-indicator-document-v1 = { name, color, pinecode } document.name document.pinecode', }, { id: 'document.name', @@ -356,25 +356,6 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ queryText: 'document.pinecode section:document document field: pinecode pinets authoring source in the canonical document. the `pinecode` field stores the complete pinets-compatible indicator source executed by the tradinggoose runtime.', }, - { - id: 'document.inputMeta', - sectionId: 'section:document', - type: 'document_field', - title: 'Document Field: inputMeta', - summary: 'Saved input-definition map in the canonical document.', - detail: - 'The `inputMeta` field stores the saved input metadata map used by the editor and runtime override flow. TradingGoose can infer common metadata from `input.*(...)` calls, but the saved document remains the canonical state.', - support: 'curated', - relatedIds: ['section:inputs'], - sourceReferences: [ - { - label: 'Indicator document schema', - path: 'apps/tradinggoose/lib/copilot/entity-documents.ts', - }, - ], - queryText: - 'document.inputmeta section:document document field: inputmeta saved input-definition map in the canonical document. the `inputmeta` field stores the saved input metadata map used by the editor and runtime override flow. tradinggoose can infer common metadata from `input.*(...)` calls, but the saved document remains the canonical state. section:inputs', - }, { id: 'runtime.execution', sectionId: 'section:runtime', @@ -427,9 +408,9 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ title: 'Input Metadata Inference', summary: 'How TradingGoose derives editable input metadata from indicator code.', detail: - 'TradingGoose scans `input.*(...)` calls, derives the saved input title and common metadata fields, and uses that map as the stable input override contract.', + 'TradingGoose scans `input.*(...)` calls, derives input titles, defaults, numeric constraints, and enum options, then uses that map as the stable input override contract.', support: 'curated', - relatedIds: ['section:inputs', 'document.inputMeta'], + relatedIds: ['section:inputs'], sourceReferences: [ { label: 'Input metadata inference', @@ -437,7 +418,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ }, ], queryText: - 'runtime.input_meta_inference section:runtime input metadata inference how tradinggoose derives editable input metadata from indicator code. tradinggoose scans `input.*(...)` calls, derives the saved input title and common metadata fields, and uses that map as the stable input override contract. section:inputs document.inputmeta', + 'runtime.input_meta_inference section:runtime input metadata inference how tradinggoose derives editable input metadata from indicator code. tradinggoose scans `input.*(...)` calls, derives input titles, defaults, numeric constraints, and enum options, then uses that map as the stable input override contract. section:inputs runtime.input_meta_inference', }, { id: 'context.series', @@ -567,7 +548,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ 'TradingGoose supports `input.any` and preserves the saved title as the stable runtime override key in `inputMeta`.', support: 'supported', signature: 'input.any(defval, title)', - relatedIds: ['document.inputMeta'], + relatedIds: ['runtime.input_meta_inference'], examples: [ { title: 'Minimal any input', @@ -585,7 +566,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ }, ], queryText: - 'input.any section:inputs input.any supported `input.any` helper. tradinggoose supports `input.any` and preserves the saved title as the stable runtime override key in `inputmeta`. input.any(defval, title) document.inputmeta', + 'input.any section:inputs input.any supported `input.any` helper. tradinggoose supports `input.any` and preserves the saved title as the stable runtime override key in `inputmeta`. input.any(defval, title) runtime.input_meta_inference', }, { id: 'input.int', @@ -597,7 +578,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ 'TradingGoose supports `input.int` and infers the declared title, default value, and positional numeric constraints into `inputMeta`. The saved title becomes the stable runtime override key.', support: 'supported', signature: 'input.int(defval, title, minval?, maxval?, step?)', - relatedIds: ['document.inputMeta'], + relatedIds: ['runtime.input_meta_inference'], examples: [ { title: 'Minimal integer input', @@ -615,7 +596,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ }, ], queryText: - 'input.int section:inputs input.int supported `input.int` helper. tradinggoose supports `input.int` and infers the declared title, default value, and positional numeric constraints into `inputmeta`. the saved title becomes the stable runtime override key. input.int(defval, title, minval?, maxval?, step?) document.inputmeta', + 'input.int section:inputs input.int supported `input.int` helper. tradinggoose supports `input.int` and infers the declared title, default value, and positional numeric constraints into `inputmeta`. the saved title becomes the stable runtime override key. input.int(defval, title, minval?, maxval?, step?) runtime.input_meta_inference', }, { id: 'input.float', @@ -627,7 +608,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ 'TradingGoose supports `input.float` and infers the declared title, default value, and positional numeric constraints into `inputMeta`. The saved title becomes the stable runtime override key.', support: 'supported', signature: 'input.float(defval, title, minval?, maxval?, step?)', - relatedIds: ['document.inputMeta'], + relatedIds: ['runtime.input_meta_inference'], examples: [ { title: 'Minimal float input', @@ -645,7 +626,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ }, ], queryText: - 'input.float section:inputs input.float supported `input.float` helper. tradinggoose supports `input.float` and infers the declared title, default value, and positional numeric constraints into `inputmeta`. the saved title becomes the stable runtime override key. input.float(defval, title, minval?, maxval?, step?) document.inputmeta', + 'input.float section:inputs input.float supported `input.float` helper. tradinggoose supports `input.float` and infers the declared title, default value, and positional numeric constraints into `inputmeta`. the saved title becomes the stable runtime override key. input.float(defval, title, minval?, maxval?, step?) runtime.input_meta_inference', }, { id: 'input.bool', @@ -657,7 +638,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ 'TradingGoose supports `input.bool` and preserves the saved title as the stable runtime override key in `inputMeta`.', support: 'supported', signature: 'input.bool(defval, title)', - relatedIds: ['document.inputMeta'], + relatedIds: ['runtime.input_meta_inference'], examples: [ { title: 'Minimal bool input', @@ -675,7 +656,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ }, ], queryText: - 'input.bool section:inputs input.bool supported `input.bool` helper. tradinggoose supports `input.bool` and preserves the saved title as the stable runtime override key in `inputmeta`. input.bool(defval, title) document.inputmeta', + 'input.bool section:inputs input.bool supported `input.bool` helper. tradinggoose supports `input.bool` and preserves the saved title as the stable runtime override key in `inputmeta`. input.bool(defval, title) runtime.input_meta_inference', }, { id: 'input.string', @@ -687,7 +668,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ 'TradingGoose supports `input.string` and preserves the saved title as the stable runtime override key in `inputMeta`.', support: 'supported', signature: 'input.string(defval, title)', - relatedIds: ['document.inputMeta'], + relatedIds: ['runtime.input_meta_inference'], examples: [ { title: 'Minimal string input', @@ -705,7 +686,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ }, ], queryText: - 'input.string section:inputs input.string supported `input.string` helper. tradinggoose supports `input.string` and preserves the saved title as the stable runtime override key in `inputmeta`. input.string(defval, title) document.inputmeta', + 'input.string section:inputs input.string supported `input.string` helper. tradinggoose supports `input.string` and preserves the saved title as the stable runtime override key in `inputmeta`. input.string(defval, title) runtime.input_meta_inference', }, { id: 'input.timeframe', @@ -717,7 +698,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ 'TradingGoose supports `input.timeframe` and preserves the saved title as the stable runtime override key in `inputMeta`.', support: 'supported', signature: 'input.timeframe(defval, title)', - relatedIds: ['document.inputMeta'], + relatedIds: ['runtime.input_meta_inference'], examples: [ { title: 'Minimal timeframe input', @@ -735,7 +716,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ }, ], queryText: - 'input.timeframe section:inputs input.timeframe supported `input.timeframe` helper. tradinggoose supports `input.timeframe` and preserves the saved title as the stable runtime override key in `inputmeta`. input.timeframe(defval, title) document.inputmeta', + 'input.timeframe section:inputs input.timeframe supported `input.timeframe` helper. tradinggoose supports `input.timeframe` and preserves the saved title as the stable runtime override key in `inputmeta`. input.timeframe(defval, title) runtime.input_meta_inference', }, { id: 'input.time', @@ -747,7 +728,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ 'TradingGoose supports `input.time` and preserves the saved title as the stable runtime override key in `inputMeta`.', support: 'supported', signature: 'input.time(defval, title)', - relatedIds: ['document.inputMeta'], + relatedIds: ['runtime.input_meta_inference'], examples: [ { title: 'Minimal time input', @@ -765,7 +746,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ }, ], queryText: - 'input.time section:inputs input.time supported `input.time` helper. tradinggoose supports `input.time` and preserves the saved title as the stable runtime override key in `inputmeta`. input.time(defval, title) document.inputmeta', + 'input.time section:inputs input.time supported `input.time` helper. tradinggoose supports `input.time` and preserves the saved title as the stable runtime override key in `inputmeta`. input.time(defval, title) runtime.input_meta_inference', }, { id: 'input.price', @@ -777,7 +758,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ 'TradingGoose supports `input.price` and preserves the saved title as the stable runtime override key in `inputMeta`.', support: 'supported', signature: 'input.price(defval, title)', - relatedIds: ['document.inputMeta'], + relatedIds: ['runtime.input_meta_inference'], examples: [ { title: 'Minimal price input', @@ -795,7 +776,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ }, ], queryText: - 'input.price section:inputs input.price supported `input.price` helper. tradinggoose supports `input.price` and preserves the saved title as the stable runtime override key in `inputmeta`. input.price(defval, title) document.inputmeta', + 'input.price section:inputs input.price supported `input.price` helper. tradinggoose supports `input.price` and preserves the saved title as the stable runtime override key in `inputmeta`. input.price(defval, title) runtime.input_meta_inference', }, { id: 'input.session', @@ -807,7 +788,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ 'TradingGoose supports `input.session` and preserves the saved title as the stable runtime override key in `inputMeta`.', support: 'supported', signature: 'input.session(defval, title)', - relatedIds: ['document.inputMeta'], + relatedIds: ['runtime.input_meta_inference'], examples: [ { title: 'Minimal session input', @@ -825,7 +806,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ }, ], queryText: - 'input.session section:inputs input.session supported `input.session` helper. tradinggoose supports `input.session` and preserves the saved title as the stable runtime override key in `inputmeta`. input.session(defval, title) document.inputmeta', + 'input.session section:inputs input.session supported `input.session` helper. tradinggoose supports `input.session` and preserves the saved title as the stable runtime override key in `inputmeta`. input.session(defval, title) runtime.input_meta_inference', }, { id: 'input.source', @@ -837,7 +818,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ 'TradingGoose supports `input.source` and preserves the saved title as the stable runtime override key in `inputMeta`.', support: 'supported', signature: 'input.source(defval, title)', - relatedIds: ['document.inputMeta'], + relatedIds: ['runtime.input_meta_inference'], examples: [ { title: 'Minimal source input', @@ -855,7 +836,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ }, ], queryText: - 'input.source section:inputs input.source supported `input.source` helper. tradinggoose supports `input.source` and preserves the saved title as the stable runtime override key in `inputmeta`. input.source(defval, title) document.inputmeta', + 'input.source section:inputs input.source supported `input.source` helper. tradinggoose supports `input.source` and preserves the saved title as the stable runtime override key in `inputmeta`. input.source(defval, title) runtime.input_meta_inference', }, { id: 'input.symbol', @@ -867,7 +848,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ 'TradingGoose supports `input.symbol` and preserves the saved title as the stable runtime override key in `inputMeta`.', support: 'supported', signature: 'input.symbol(defval, title)', - relatedIds: ['document.inputMeta'], + relatedIds: ['runtime.input_meta_inference'], examples: [ { title: 'Minimal symbol input', @@ -885,7 +866,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ }, ], queryText: - 'input.symbol section:inputs input.symbol supported `input.symbol` helper. tradinggoose supports `input.symbol` and preserves the saved title as the stable runtime override key in `inputmeta`. input.symbol(defval, title) document.inputmeta', + 'input.symbol section:inputs input.symbol supported `input.symbol` helper. tradinggoose supports `input.symbol` and preserves the saved title as the stable runtime override key in `inputmeta`. input.symbol(defval, title) runtime.input_meta_inference', }, { id: 'input.text_area', @@ -897,7 +878,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ 'TradingGoose supports `input.text_area` and preserves the saved title as the stable runtime override key in `inputMeta`.', support: 'supported', signature: 'input.text_area(defval, title)', - relatedIds: ['document.inputMeta'], + relatedIds: ['runtime.input_meta_inference'], examples: [ { title: 'Minimal text_area input', @@ -915,7 +896,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ }, ], queryText: - 'input.text_area section:inputs input.text_area supported `input.text_area` helper. tradinggoose supports `input.text_area` and preserves the saved title as the stable runtime override key in `inputmeta`. input.text_area(defval, title) document.inputmeta', + 'input.text_area section:inputs input.text_area supported `input.text_area` helper. tradinggoose supports `input.text_area` and preserves the saved title as the stable runtime override key in `inputmeta`. input.text_area(defval, title) runtime.input_meta_inference', }, { id: 'input.enum', @@ -924,10 +905,10 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ title: 'input.enum', summary: 'Supported `input.enum` helper.', detail: - 'TradingGoose supports `input.enum` and keeps the saved title as the stable runtime override key. Helper-specific option lists may still need explicit review in the saved document.', + 'TradingGoose supports `input.enum`, derives literal option lists, and keeps the saved title as the stable runtime override key.', support: 'supported', signature: 'input.enum(defval, title, options?)', - relatedIds: ['document.inputMeta'], + relatedIds: ['runtime.input_meta_inference'], examples: [ { title: 'Minimal enum input', @@ -945,7 +926,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ }, ], queryText: - 'input.enum section:inputs input.enum supported `input.enum` helper. tradinggoose supports `input.enum` and keeps the saved title as the stable runtime override key. helper-specific option lists may still need explicit review in the saved document. input.enum(defval, title, options?) document.inputmeta', + 'input.enum section:inputs input.enum supported `input.enum` helper. tradinggoose supports `input.enum`, derives literal option lists, and keeps the saved title as the stable runtime override key. input.enum(defval, title, options?) runtime.input_meta_inference', }, { id: 'input.color', @@ -957,7 +938,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ 'TradingGoose supports `input.color` and preserves the saved title as the stable runtime override key in `inputMeta`.', support: 'supported', signature: 'input.color(defval, title)', - relatedIds: ['document.inputMeta'], + relatedIds: ['runtime.input_meta_inference'], examples: [ { title: 'Minimal color input', @@ -975,7 +956,7 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ }, ], queryText: - 'input.color section:inputs input.color supported `input.color` helper. tradinggoose supports `input.color` and preserves the saved title as the stable runtime override key in `inputmeta`. input.color(defval, title) document.inputmeta', + 'input.color section:inputs input.color supported `input.color` helper. tradinggoose supports `input.color` and preserves the saved title as the stable runtime override key in `inputmeta`. input.color(defval, title) runtime.input_meta_inference', }, { id: 'indicator.overlay', diff --git a/apps/tradinggoose/lib/indicators/import-export.test.ts b/apps/tradinggoose/lib/indicators/import-export.test.ts index 3b8225f21..ceef228e5 100644 --- a/apps/tradinggoose/lib/indicators/import-export.test.ts +++ b/apps/tradinggoose/lib/indicators/import-export.test.ts @@ -14,14 +14,6 @@ describe('indicator import/export helpers', () => { { name: 'RSI Export Example', pineCode: "indicator('RSI Export Example')", - inputMeta: { - Length: { - title: 'Length', - type: 'int', - defval: 14, - minval: 1, - }, - }, }, ], }) @@ -40,14 +32,6 @@ describe('indicator import/export helpers', () => { { name: 'RSI Export Example', pineCode: "indicator('RSI Export Example')", - inputMeta: { - Length: { - title: 'Length', - type: 'int', - defval: 14, - minval: 1, - }, - }, }, ], }) @@ -60,7 +44,6 @@ describe('indicator import/export helpers', () => { { name: 'RSI Export Example', pineCode: "indicator('RSI Export Example')", - inputMeta: undefined, }, ], }) @@ -83,7 +66,7 @@ describe('indicator import/export helpers', () => { }) }) - it('parses mixed unified import files and returns the indicators section', () => { + it('parses mixed unified import files and ignores supplied input metadata', () => { const parsed = parseImportedIndicatorsFile({ version: '1', fileType: 'tradingGooseExport', @@ -98,7 +81,9 @@ describe('indicator import/export helpers', () => { { name: ' RSI Export Example ', pineCode: "indicator('RSI Export Example')", - inputMeta: {}, + inputMeta: { + Stale: { title: 'Stale', type: 'string', defval: 'old' }, + }, }, ], }) @@ -107,7 +92,6 @@ describe('indicator import/export helpers', () => { { name: 'RSI Export Example', pineCode: "indicator('RSI Export Example')", - inputMeta: {}, }, ]) }) diff --git a/apps/tradinggoose/lib/indicators/import-export.ts b/apps/tradinggoose/lib/indicators/import-export.ts index 464788ee6..aa49510f9 100644 --- a/apps/tradinggoose/lib/indicators/import-export.ts +++ b/apps/tradinggoose/lib/indicators/import-export.ts @@ -9,15 +9,13 @@ const IMPORTED_INDICATOR_MARKER = '(imported)' const normalizeInlineWhitespace = (value: string) => value.trim().replace(/\s+/g, ' ') -export const IndicatorTransferSchema = z - .object({ - name: z - .string() - .transform(normalizeInlineWhitespace) - .pipe(z.string().min(1, 'Indicator name is required')), - pineCode: z.string(), - inputMeta: z.record(z.any()).optional(), - }) +export const IndicatorTransferSchema = z.object({ + name: z + .string() + .transform(normalizeInlineWhitespace) + .pipe(z.string().min(1, 'Indicator name is required')), + pineCode: z.string(), +}) export const IndicatorsTransferListSchema = z .array(IndicatorTransferSchema) @@ -39,15 +37,11 @@ export type IndicatorTransferRecord = z.infer export type IndicatorsImportFile = z.infer function normalizeIndicatorForTransfer( - indicator: Pick + indicator: Pick ): IndicatorTransferRecord { return { name: normalizeInlineWhitespace(indicator.name), pineCode: indicator.pineCode ?? '', - inputMeta: - indicator.inputMeta && typeof indicator.inputMeta === 'object' - ? indicator.inputMeta - : undefined, } } @@ -59,7 +53,7 @@ export function createIndicatorsExportFile({ indicators, exportedFrom, }: { - indicators: Array> + indicators: Array> exportedFrom: string }): IndicatorsImportFile { return createTradingGooseExportFile({ @@ -75,7 +69,7 @@ export function exportIndicatorsAsJson({ indicators, exportedFrom, }: { - indicators: Array> + indicators: Array> exportedFrom: string }): string { return JSON.stringify(createIndicatorsExportFile({ indicators, exportedFrom }), null, 2) diff --git a/apps/tradinggoose/lib/indicators/input-meta.ts b/apps/tradinggoose/lib/indicators/input-meta.ts index 3d27f9cb6..dbc933489 100644 --- a/apps/tradinggoose/lib/indicators/input-meta.ts +++ b/apps/tradinggoose/lib/indicators/input-meta.ts @@ -260,6 +260,17 @@ const parseInputArgs = (argsRaw: string): string[] => { return args } +const parseLiteralArray = (raw: string): unknown[] | undefined => { + const trimmed = raw.trim() + if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return undefined + + const values = parseInputArgs(trimmed.slice(1, -1)) + .map(parseLiteral) + .filter((value) => typeof value !== 'undefined') + + return values.length > 0 ? values : undefined +} + const resolveTitle = (args: string[], argsRaw: string): string | undefined => { const titleValue = parseLiteral(args[1] ?? '') if (typeof titleValue === 'string' && titleValue.trim()) { @@ -297,17 +308,24 @@ export const inferInputMetaFromPineCode = (code: string): InputMetaMap | undefin meta.defval = defval } - const minval = parseLiteral(args[2] ?? '') - if (typeof minval === 'number' && Number.isFinite(minval)) { - meta.minval = minval - } - const maxval = parseLiteral(args[3] ?? '') - if (typeof maxval === 'number' && Number.isFinite(maxval)) { - meta.maxval = maxval + if (type === 'int' || type === 'float') { + const minval = parseLiteral(args[2] ?? '') + if (typeof minval === 'number' && Number.isFinite(minval)) { + meta.minval = minval + } + const maxval = parseLiteral(args[3] ?? '') + if (typeof maxval === 'number' && Number.isFinite(maxval)) { + meta.maxval = maxval + } + const step = parseLiteral(args[4] ?? '') + if (typeof step === 'number' && Number.isFinite(step)) { + meta.step = step + } } - const step = parseLiteral(args[4] ?? '') - if (typeof step === 'number' && Number.isFinite(step)) { - meta.step = step + + const options = type === 'enum' ? parseLiteralArray(args[2] ?? '') : undefined + if (options) { + meta.options = options } inputMeta[meta.title] = meta @@ -369,7 +387,7 @@ export const buildInputsMapFromMeta = ( entries.forEach(([title, meta]) => { if (!meta || !title.trim()) return const overrideValue = overrides ? overrides[title] : undefined - const resolved = coerceValue(meta, overrideValue ?? meta.value ?? meta.defval) + const resolved = coerceValue(meta, overrideValue ?? meta.defval) if (typeof resolved !== 'undefined') { result[title] = resolved } diff --git a/apps/tradinggoose/lib/indicators/monitor-config.ts b/apps/tradinggoose/lib/indicators/monitor-config.ts index bdc0d842f..dd50b8d45 100644 --- a/apps/tradinggoose/lib/indicators/monitor-config.ts +++ b/apps/tradinggoose/lib/indicators/monitor-config.ts @@ -165,7 +165,7 @@ export const normalizeIndicatorInputOverrides = ( const coerced = normalizeIndicatorInputValue(meta, value) if (typeof coerced === 'undefined') return - const defaultValue = normalizeIndicatorInputValue(meta, meta.value ?? meta.defval) + const defaultValue = normalizeIndicatorInputValue(meta, meta.defval) if (inputValuesEqual(coerced, defaultValue)) return normalized[title] = coerced diff --git a/apps/tradinggoose/lib/indicators/types.ts b/apps/tradinggoose/lib/indicators/types.ts index 4b2ef5fc1..bb8d2ff79 100644 --- a/apps/tradinggoose/lib/indicators/types.ts +++ b/apps/tradinggoose/lib/indicators/types.ts @@ -6,7 +6,6 @@ export type InputMeta = { maxval?: number step?: number options?: unknown[] - value?: unknown } export type InputMetaMap = Record diff --git a/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx b/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx index cb58de24f..b786a5432 100644 --- a/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx @@ -291,7 +291,6 @@ export function IndicatorCodePanel({ { name: indicatorName, pineCode, - inputMeta: inferInputMetaFromPineCode(pineCode) ?? undefined, }, ], }) diff --git a/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/indicator-list.tsx b/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/indicator-list.tsx index 3118e358e..3bcd48007 100644 --- a/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/indicator-list.tsx +++ b/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/indicator-list.tsx @@ -1,10 +1,9 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { useLocale } from 'next-intl' +import { useLocale, useMessages } from 'next-intl' import { LoadingAgent } from '@/components/ui/loading-agent' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { useMessages } from 'next-intl' import { useCreateIndicator, useDeleteIndicator, @@ -160,10 +159,6 @@ export function IndicatorList({ indicator: { name: copiedName, pineCode: indicator.pineCode ?? '', - inputMeta: - indicator.inputMeta && typeof indicator.inputMeta === 'object' - ? indicator.inputMeta - : null, }, }) const copiedIndicatorId = @@ -184,12 +179,7 @@ export function IndicatorList({ }) } }, - [ - createMutation, - handleSelect, - permissions.canEdit, - workspaceId, - ] + [createMutation, handleSelect, permissions.canEdit, workspaceId] ) if (isLoading) { From 2dda6588e84d22a7f03f9144ab0b976b376d86dc Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 22:52:27 -0600 Subject: [PATCH 194/284] fix(auth): preserve locale in MCP authorize callbacks Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/[locale]/(auth)/mcp/authorize/page.tsx | 2 +- apps/tradinggoose/app/api/auth/mcp/authorize/route.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx index bf561c7f7..0bd3f2437 100644 --- a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx +++ b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx @@ -63,7 +63,7 @@ export default async function McpAuthorizePage({ pathname: '/login', query: { ...(getSessionCookie(requestHeaders) ? { reauth: '1' } : {}), - callbackUrl: `/mcp/authorize?code=${encodeURIComponent(code)}`, + callbackUrl: `/${locale}/mcp/authorize?code=${encodeURIComponent(code)}`, }, }, locale, diff --git a/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts b/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts index 2552fa1cb..f1d715cce 100644 --- a/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts @@ -14,11 +14,15 @@ function redirectToAuthorizeStatus(locale: string, status: string) { } function redirectToLogin(request: NextRequest, locale: string, code: string) { - const url = new URL(`/${normalizeLocaleCode(locale)}/login`, getBaseUrl()) + const normalizedLocale = normalizeLocaleCode(locale) + const url = new URL(`/${normalizedLocale}/login`, getBaseUrl()) if (getSessionCookie(request.headers)) { url.searchParams.set('reauth', '1') } - url.searchParams.set('callbackUrl', `/mcp/authorize?code=${encodeURIComponent(code)}`) + url.searchParams.set( + 'callbackUrl', + `/${normalizedLocale}/mcp/authorize?code=${encodeURIComponent(code)}` + ) return NextResponse.redirect(url) } From a84981323ab40ea0a87b61a68edac36641bf1458 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 22:52:45 -0600 Subject: [PATCH 195/284] fix(yjs): surface socket bridge snapshot errors Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../sessions/[sessionId]/snapshot/route.ts | 22 ++++++++----------- .../lib/yjs/server/snapshot-bridge.ts | 12 +++++++++- .../tradinggoose/socket-server/routes/http.ts | 17 ++++++++++++-- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts b/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts index cbfa365f9..264f7f870 100644 --- a/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts +++ b/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts @@ -6,10 +6,7 @@ import { } from '@/lib/copilot/review-sessions/identity' import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' import { mcpService } from '@/lib/mcp/service' -import { - ReviewTargetBootstrapError, - readBootstrappedReviewTargetSnapshot, -} from '@/lib/yjs/server/bootstrap-review-target' +import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' import { applyYjsUpdateInSocketServer, SocketServerBridgeError, @@ -17,6 +14,11 @@ import { export const dynamic = 'force-dynamic' +function getPublicBridgeStatus(error: SocketServerBridgeError) { + const { status } = error + return status === 400 || status === 404 || status === 409 || status === 410 ? status : 503 +} + async function authorizeYjsSnapshotRequest( request: NextRequest, sessionId: string, @@ -88,8 +90,8 @@ export async function GET( status: snapshot.runtime.docState === 'expired' ? 410 : 200, }) } catch (error) { - if (error instanceof ReviewTargetBootstrapError) { - return NextResponse.json({ error: error.message }, { status: error.status }) + if (error instanceof SocketServerBridgeError) { + return NextResponse.json({ error: error.message }, { status: getPublicBridgeStatus(error) }) } return NextResponse.json({ error: 'Failed to load snapshot' }, { status: 500 }) @@ -129,14 +131,8 @@ export async function POST( return NextResponse.json({ success: true }) } catch (error) { - if (error instanceof ReviewTargetBootstrapError) { - return NextResponse.json({ error: error.message }, { status: error.status }) - } if (error instanceof SocketServerBridgeError) { - return NextResponse.json( - { error: error.body || 'Failed to save Yjs session' }, - { status: error.status } - ) + return NextResponse.json({ error: error.message }, { status: getPublicBridgeStatus(error) }) } return NextResponse.json({ error: 'Failed to save Yjs session' }, { status: 500 }) diff --git a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts index 4a58e66a0..36bd484fe 100644 --- a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts +++ b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts @@ -23,13 +23,23 @@ export class SocketServerBridgeError extends Error { body: string constructor(status: number, body: string) { - super(`Socket server bridge failed: ${status}${body ? ` ${body}` : ''}`) + super(readSocketServerErrorMessage(status, body)) this.name = 'SocketServerBridgeError' this.status = status this.body = body } } +function readSocketServerErrorMessage(status: number, body: string): string { + if (!body) return `Socket server bridge failed: ${status}` + try { + const error = (JSON.parse(body) as { error?: unknown }).error + return typeof error === 'string' && error ? error : body + } catch { + return body + } +} + function getSocketServerUrl(): string { return getInternalRealtimeUrl() } diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index 437b956a9..4a01cf722 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -424,6 +424,7 @@ async function handleInternalYjsSnapshotRequest( logger: Logger, sessionId: string ): Promise { + let descriptor: ReturnType try { const envelope = parseYjsTransportEnvelope(Object.fromEntries(parsedUrl.searchParams)) if (envelope.sessionId !== sessionId) { @@ -431,7 +432,16 @@ async function handleInternalYjsSnapshotRequest( return } - const descriptor = buildReviewTargetDescriptorFromEnvelope(envelope) + descriptor = buildReviewTargetDescriptorFromEnvelope(envelope) + } catch (error) { + logger.error('Invalid Yjs snapshot request', { error, path: parsedUrl.pathname }) + sendJson(res, 400, { + error: error instanceof Error ? error.message : 'Invalid Yjs snapshot request', + }) + return + } + + try { let liveDoc = await getExistingDocument(sessionId) let bootstrappedForRequest = false if (!liveDoc && descriptor.entityId) { @@ -462,7 +472,10 @@ async function handleInternalYjsSnapshotRequest( } } catch (error) { logger.error('Error getting Yjs snapshot', { error, path: parsedUrl.pathname }) - sendJson(res, 400, { error: 'Failed to get snapshot' }) + const status = Number((error as { status?: unknown }).status) || 500 + sendJson(res, status, { + error: error instanceof Error ? error.message : 'Failed to get snapshot', + }) } } From ed756aa81bae195af5240956bf2456e4f7568be2 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 22:53:10 -0600 Subject: [PATCH 196/284] fix(yjs): require realtime persistence for saved entities Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/indicators/custom/route.ts | 4 +++ .../app/api/knowledge/[id]/route.ts | 2 +- apps/tradinggoose/app/api/skills/route.ts | 4 +++ .../app/api/tools/custom/route.ts | 4 +++ apps/tradinggoose/lib/mcp/types.ts | 1 + apps/tradinggoose/lib/mcp/utils.ts | 3 +++ .../lib/yjs/server/apply-entity-state.test.ts | 4 +-- .../lib/yjs/server/apply-entity-state.ts | 26 +++++++++++++++++-- 8 files changed, 43 insertions(+), 5 deletions(-) diff --git a/apps/tradinggoose/app/api/indicators/custom/route.ts b/apps/tradinggoose/app/api/indicators/custom/route.ts index 88dc5bec1..4cf81be3b 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -6,6 +6,7 @@ import { z } from 'zod' import { createIndicators, saveIndicator } from '@/lib/indicators/custom/operations' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' +import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { authenticateIndicatorRequest, checkWorkspacePermission } from '../utils' @@ -196,6 +197,9 @@ export async function POST(request: NextRequest) { { status: 400 } ) } + if (validationError instanceof SavedEntityPersistenceError) { + return NextResponse.json(validationError.responseBody(), { status: validationError.status }) + } if (validationError instanceof Error && validationError.message.includes('was not found')) { return NextResponse.json({ error: validationError.message }, { status: 404 }) } diff --git a/apps/tradinggoose/app/api/knowledge/[id]/route.ts b/apps/tradinggoose/app/api/knowledge/[id]/route.ts index ba2d63b52..eaf305b1c 100644 --- a/apps/tradinggoose/app/api/knowledge/[id]/route.ts +++ b/apps/tradinggoose/app/api/knowledge/[id]/route.ts @@ -135,7 +135,7 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: } catch (error) { logger.error(`[${requestId}] Error updating knowledge base`, error) if (error instanceof SavedEntityPersistenceError) { - return NextResponse.json({ error: error.message }, { status: error.status }) + return NextResponse.json(error.responseBody(), { status: error.status }) } return NextResponse.json({ error: 'Failed to update knowledge base' }, { status: 500 }) } diff --git a/apps/tradinggoose/app/api/skills/route.ts b/apps/tradinggoose/app/api/skills/route.ts index c343a76f0..36a737d70 100644 --- a/apps/tradinggoose/app/api/skills/route.ts +++ b/apps/tradinggoose/app/api/skills/route.ts @@ -10,6 +10,7 @@ import { } from '@/lib/skills/import-export' import { createSkills, deleteSkill, listSkills, saveSkill } from '@/lib/skills/operations' import { generateRequestId } from '@/lib/utils' +import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' const logger = createLogger('SkillsAPI') @@ -143,6 +144,9 @@ export async function POST(request: NextRequest) { ) } + if (validationError instanceof SavedEntityPersistenceError) { + return NextResponse.json(validationError.responseBody(), { status: validationError.status }) + } if (validationError instanceof Error && validationError.message.includes('already exists')) { return NextResponse.json({ error: validationError.message }, { status: 409 }) } diff --git a/apps/tradinggoose/app/api/tools/custom/route.ts b/apps/tradinggoose/app/api/tools/custom/route.ts index 39b98d349..cf1d6398a 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.ts @@ -9,6 +9,7 @@ import { CustomToolWriteRequestSchema } from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' +import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('CustomToolsAPI') @@ -155,6 +156,9 @@ export async function POST(req: NextRequest) { { status: 400 } ) } + if (validationError instanceof SavedEntityPersistenceError) { + return NextResponse.json(validationError.responseBody(), { status: validationError.status }) + } if (validationError instanceof Error && validationError.message.includes('already exists')) { return NextResponse.json({ error: validationError.message }, { status: 409 }) } diff --git a/apps/tradinggoose/lib/mcp/types.ts b/apps/tradinggoose/lib/mcp/types.ts index 87ab11437..b5e1320be 100644 --- a/apps/tradinggoose/lib/mcp/types.ts +++ b/apps/tradinggoose/lib/mcp/types.ts @@ -262,6 +262,7 @@ export interface McpApiResponse { success: boolean data?: T error?: string + code?: string } export interface McpToolDiscoveryResponse { diff --git a/apps/tradinggoose/lib/mcp/utils.ts b/apps/tradinggoose/lib/mcp/utils.ts index 55ef118ae..c1ff4f7dc 100644 --- a/apps/tradinggoose/lib/mcp/utils.ts +++ b/apps/tradinggoose/lib/mcp/utils.ts @@ -32,6 +32,9 @@ export function createMcpErrorResponse( const response: McpApiResponse = { success: false, error: errorMessage, + ...((error as { code?: unknown })?.code + ? { code: String((error as { code: unknown }).code) } + : {}), } return NextResponse.json(response, { status }) diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts index 5333d5e74..7667288ec 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts @@ -155,7 +155,7 @@ describe('applySavedEntityState', () => { } }) - it('does not materialize DB state when the saved-entity Yjs apply fails', async () => { + it('returns the saved-entity realtime contract when the Yjs bridge is unavailable', async () => { const { applySavedEntityState } = await import('./apply-entity-state') mockApplyEntityStateInSocketServer.mockRejectedValueOnce(new TypeError('fetch failed')) @@ -165,7 +165,7 @@ describe('applySavedEntityState', () => { description: 'Copilot description', content: 'Use the Copilot input.', }) - ).rejects.toThrow('fetch failed') + ).rejects.toMatchObject({ status: 503 }) expect(mockDbUpdate).not.toHaveBeenCalled() expect(events).toEqual([]) diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index fe81236d3..fbae3f810 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -14,14 +14,21 @@ import { getEntityFields } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { applyEntityStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +const SAVED_ENTITY_REALTIME_REQUIRED_CODE = 'SAVED_ENTITY_REALTIME_REQUIRED' + export class SavedEntityPersistenceError extends Error { constructor( public status: number, - message: string + message: string, + public code?: string ) { super(message) this.name = 'SavedEntityPersistenceError' } + + responseBody() { + return { error: this.message, ...(this.code ? { code: this.code } : {}) } + } } function objectField(value: unknown): Record { @@ -168,7 +175,22 @@ export async function applySavedEntityState( fields: Record ): Promise { const normalizedFields = normalizeSavedEntityFields(entityKind, fields) - await applyEntityStateInSocketServer(entityId, entityKind, normalizedFields) + try { + await applyEntityStateInSocketServer(entityId, entityKind, normalizedFields) + } catch (error) { + const status = Number((error as { status?: unknown }).status) + if (status === 400 || status === 404 || status === 409) { + throw new SavedEntityPersistenceError( + status, + error instanceof Error ? error.message : 'Saved entity persistence failed' + ) + } + throw new SavedEntityPersistenceError( + 503, + 'Saved entity realtime orchestration is required', + SAVED_ENTITY_REALTIME_REQUIRED_CODE + ) + } } export async function saveSavedEntityYjsDocToDb( From 9a10cd9844a9c75fe1001d85fd185dc92beff7ad Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 23:27:04 -0600 Subject: [PATCH 197/284] feat(monitor): refresh pages after server tool updates Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../tradinggoose/app/api/copilot/mcp/route.ts | 2 +- .../monitor/components/data/api.ts | 1 + .../[workspaceId]/monitor/monitor.tsx | 11 ++++++++ .../tools/server/monitor/edit-monitor.ts | 6 ++++- .../tools/server/monitor/list-monitors.ts | 1 + .../tools/server/monitor/read-monitor.ts | 2 +- .../stores/copilot/tool-registry.test.ts | 26 +++++++++++++++++++ .../stores/copilot/tool-registry.ts | 7 +++++ 8 files changed, 53 insertions(+), 3 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index 61b161999..e374211a5 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -122,7 +122,7 @@ async function buildInstructions(userId: string) { return [ 'TradingGoose Copilot MCP exposes server-side Copilot tools for trusted personal coding agents, including direct mutation tools.', 'Local MCP config stores only this user auth token. Do not store workspaceId, entityId, or entity targets in the local MCP config.', - 'Use entityId for read/edit/rename tools that target an existing entity. Mutating tools execute directly for the authenticated personal API key; Studio review tokens are not part of the external MCP protocol. Credential, OAuth, and environment reads require scope="personal" for the authenticated user or scope="workspace" with workspaceId. Workspace-scoped tools, including list/create, Google Drive, and workspace account reads, require workspaceId. Environment writes use the same personal/workspace scope rule.', + 'Use tools/list as the source of truth for each tool input schema; target identifiers are tool-specific and come from list/read tool results. Mutating tools execute directly for the authenticated personal API key; Studio review tokens are not part of the external MCP protocol. Credential, OAuth, and environment reads require scope="personal" for the authenticated user or scope="workspace" with workspaceId. Workspace-scoped tools, including list/create, Google Drive, and workspace account reads, require workspaceId. Environment writes use the same personal/workspace scope rule.', 'MCP server documents redact header/env secret values as [redacted]. Keep [redacted] to preserve an existing secret, send a concrete value to replace it, or omit the key to delete it.', 'Accessible workspaces for the authenticated user:', ...workspaceLines, diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/api.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/api.ts index 5968c8cbf..95b63a7bf 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/api.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/api.ts @@ -23,6 +23,7 @@ import type { import { parseMonitorSavedViewConfig } from '../view/view-config' const FALLBACK_INDICATOR_COLOR = '#3972F6' +export const MONITOR_DATA_CHANGED_EVENT = 'tradinggoose:monitor-data-changed' type WorkflowTargetFallbackCopy = { workflowName: string diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.tsx index fbea62d2d..a3822e820 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.tsx @@ -23,6 +23,7 @@ import { deleteMonitorRecord, listMonitorViews, loadMonitors, + MONITOR_DATA_CHANGED_EVENT, removeMonitorView, reorderMonitorViews, setActiveMonitorView, @@ -579,6 +580,16 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) { void loadMonitorData() }, [loadMonitorData]) + useEffect(() => { + const handleMonitorDataChanged = (event: Event) => { + const detail = (event as CustomEvent<{ workspaceId?: string }>).detail + if (detail?.workspaceId === workspaceId) void loadMonitorData() + } + + window.addEventListener(MONITOR_DATA_CHANGED_EVENT, handleMonitorDataChanged) + return () => window.removeEventListener(MONITOR_DATA_CHANGED_EVENT, handleMonitorDataChanged) + }, [loadMonitorData, workspaceId]) + const { executionItems, orderedVisibleLogIds, isSelectionResolved, isLoading, error, refresh } = useMonitorWorkspaceLogs({ workspaceId, diff --git a/apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts b/apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts index 135fdbaf5..7608d16d1 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts @@ -64,6 +64,7 @@ export const editMonitorServerTool: BaseServerTool = { requiresReview: true, success: true, surfaceKind: 'monitor' as const, + workspaceId: row.workflow.workspaceId, monitorId: args.monitorId, monitorName: readMonitorDocumentName(nextFields), documentFormat: MONITOR_DOCUMENT_FORMAT, @@ -90,6 +91,9 @@ export const editMonitorServerTool: BaseServerTool = { logger, })) as MonitorRecord - return buildMonitorDocumentEnvelope(updatedMonitor, true) + return { + ...buildMonitorDocumentEnvelope(updatedMonitor, true), + workspaceId: row.workflow.workspaceId, + } }, } diff --git a/apps/tradinggoose/lib/copilot/tools/server/monitor/list-monitors.ts b/apps/tradinggoose/lib/copilot/tools/server/monitor/list-monitors.ts index 6aa22354c..f00b5211c 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/monitor/list-monitors.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/monitor/list-monitors.ts @@ -35,6 +35,7 @@ export const listMonitorsServerTool: BaseServerTool = { return { surfaceKind: 'monitor' as const, + workspaceId, monitors: monitorEntries, count: monitorEntries.length, } diff --git a/apps/tradinggoose/lib/copilot/tools/server/monitor/read-monitor.ts b/apps/tradinggoose/lib/copilot/tools/server/monitor/read-monitor.ts index 628a7d7bb..60126d441 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/monitor/read-monitor.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/monitor/read-monitor.ts @@ -33,6 +33,6 @@ export const readMonitorServerTool: BaseServerTool = { } const monitor = (await toMonitorRecord(row.webhook)) as MonitorRecord - return buildMonitorDocumentEnvelope(monitor) + return { ...buildMonitorDocumentEnvelope(monitor), workspaceId } }, } diff --git a/apps/tradinggoose/stores/copilot/tool-registry.test.ts b/apps/tradinggoose/stores/copilot/tool-registry.test.ts index 332f91185..36618e613 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.test.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { getQueryClient } from '@/app/query-provider' +import { MONITOR_DATA_CHANGED_EVENT } from '@/app/workspace/[workspaceId]/monitor/components/data/api' import { environmentKeys } from '@/hooks/queries/environment' import { knowledgeKeys } from '@/hooks/queries/knowledge' import { skillsKeys } from '@/hooks/queries/skills' @@ -287,6 +288,31 @@ describe('tool-registry', () => { expect(invalidateQueries).toHaveBeenCalledTimes(2) }) + it('notifies monitor pages after server-managed monitor mutations', async () => { + class TestCustomEvent { + type: string + detail: T | undefined + + constructor(type: string, init?: CustomEventInit) { + this.type = type + this.detail = init?.detail + } + } + const dispatchEvent = vi.fn() + vi.stubGlobal('CustomEvent', TestCustomEvent) + vi.stubGlobal('window', { dispatchEvent }) + + try { + await handleCopilotServerToolSuccess('edit_monitor', { workspaceId: 'workspace-1' }) + + const event = dispatchEvent.mock.calls[0]?.[0] as TestCustomEvent<{ workspaceId: string }> + expect(event.type).toBe(MONITOR_DATA_CHANGED_EVENT) + expect(event.detail).toEqual({ workspaceId: 'workspace-1' }) + } finally { + vi.unstubAllGlobals() + } + }) + it('invalidates the matching environment query after server-managed environment mutations', async () => { const invalidateQueries = vi .spyOn(getQueryClient(), 'invalidateQueries') diff --git a/apps/tradinggoose/stores/copilot/tool-registry.ts b/apps/tradinggoose/stores/copilot/tool-registry.ts index 5e99fd806..54cb4476e 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.ts @@ -18,6 +18,7 @@ import { DeployWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/de import { RunWorkflowClientTool } from '@/lib/copilot/tools/client/workflow/run-workflow' import { createLogger } from '@/lib/logs/console/logger' import { getQueryClient } from '@/app/query-provider' +import { MONITOR_DATA_CHANGED_EVENT } from '@/app/workspace/[workspaceId]/monitor/components/data/api' import { customToolsKeys } from '@/hooks/queries/custom-tools' import { environmentKeys } from '@/hooks/queries/environment' import { indicatorKeys } from '@/hooks/queries/indicators' @@ -357,6 +358,12 @@ export async function handleCopilotServerToolSuccess( ]) } else if (toolName.endsWith('_mcp_server')) { await useMcpServersStore.getState().fetchServers(workspaceId) + } else if (toolName === CopilotTool.edit_monitor) { + window.dispatchEvent( + new CustomEvent(MONITOR_DATA_CHANGED_EVENT, { + detail: { workspaceId }, + }) + ) } } catch (error) { logger.warn('Failed to refresh client state after server-managed tool success', { From 98783e197c65d2a8b4d063b8954480655db0c950 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 23:27:25 -0600 Subject: [PATCH 198/284] style(monitor): apply formatting cleanup Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../monitor/components/data/api.ts | 3 +- .../[workspaceId]/monitor/monitor.tsx | 76 ++++++++++--------- .../tools/server/monitor/list-monitors.ts | 11 ++- .../tools/server/monitor/read-monitor.ts | 4 +- 4 files changed, 53 insertions(+), 41 deletions(-) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/api.ts b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/api.ts index 95b63a7bf..813b584a2 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/api.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/components/data/api.ts @@ -155,8 +155,7 @@ export async function loadWorkflowTargetOptions( const resolvedBlockId = toTrimmed(data?.id) || blockId const workflowName = toTrimmed(workflowRow?.name) || fallbackCopy.workflowName - const blockName = - toTrimmed(data?.name) || fallbackCopy.triggerBlockNames[data.type] + const blockName = toTrimmed(data?.name) || fallbackCopy.triggerBlockNames[data.type] const source = getMonitorProviderForTriggerId(data.type) return { source, diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.tsx index a3822e820..c3bcccc60 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.tsx @@ -65,15 +65,11 @@ import { } from '@/app/workspace/[workspaceId]/monitor/components/view/view-preferences' import { MonitorConfigWorkspace } from '@/app/workspace/[workspaceId]/monitor/components/workspace/monitor-config-workspace' import { MonitorExecutionWorkspace } from '@/app/workspace/[workspaceId]/monitor/components/workspace/monitor-execution-workspace' +import { getMonitorModeLabel, useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' import { AutocompleteSearch } from '@/app/workspace/[workspaceId]/records/components/logs-toolbar' import { GlobalNavbarHeader } from '@/global-navbar' import { buildLogsRequestParams, useLogDetail } from '@/hooks/queries/logs' -import { formatTemplate } from '@/i18n/utils' import { usePathname } from '@/i18n/navigation' -import { - getMonitorModeLabel, - useMonitorCopy, -} from '@/app/workspace/[workspaceId]/monitor/copy' type MonitorPageProps = { workspaceId: string @@ -693,7 +689,13 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) { } finally { setIsRefreshingAll(false) } - }, [copy.errors.persistBeforeRefresh, loadMonitorData, persistDirtyModes, refresh, reloadViewState]) + }, [ + copy.errors.persistBeforeRefresh, + loadMonitorData, + persistDirtyModes, + refresh, + reloadViewState, + ]) const handleExportExecutionLogs = useCallback(() => { const filters = buildMonitorExecutionLogFilters(executionViewConfig) @@ -813,18 +815,21 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) { [copy.errors.updateMonitorState, upsertMonitor, workspaceId] ) - const handleDeleteMonitor = useCallback(async (monitorId: string) => { - setMonitorsError(null) + const handleDeleteMonitor = useCallback( + async (monitorId: string) => { + setMonitorsError(null) - try { - await deleteMonitorRecord(monitorId) - setMonitors((current) => current.filter((monitor) => monitor.monitorId !== monitorId)) - } catch (error) { - const message = error instanceof Error ? error.message : copy.errors.deleteMonitor - setMonitorsError(message) - throw error instanceof Error ? error : new Error(message) - } - }, [copy.errors.deleteMonitor]) + try { + await deleteMonitorRecord(monitorId) + setMonitors((current) => current.filter((monitor) => monitor.monitorId !== monitorId)) + } catch (error) { + const message = error instanceof Error ? error.message : copy.errors.deleteMonitor + setMonitorsError(message) + throw error instanceof Error ? error : new Error(message) + } + }, + [copy.errors.deleteMonitor] + ) const handleReorderColumnCards = useCallback( (columnId: string, nextExecutionIds: string[]) => { @@ -1062,14 +1067,14 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) { nameDialogValue, persistDirtyModes, setActiveModeViewId, - updateWorkingState, - viewNameDialog, - workspaceId, - copy.errors.createView, - copy.errors.dialogStale, - copy.errors.nameEmpty, - copy.errors.renameView, - ]) + updateWorkingState, + viewNameDialog, + workspaceId, + copy.errors.createView, + copy.errors.dialogStale, + copy.errors.nameEmpty, + copy.errors.renameView, + ]) const handleReorderViews = useCallback( async (nextLayouts: LayoutTab[]) => { @@ -1184,7 +1189,9 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) { if (nextMode === activeMode) return true if (!renderableModes.includes(nextMode)) { setViewsError( - nextMode === 'config' ? copy.errors.configViewsUnavailable : copy.errors.executionViewsUnavailable + nextMode === 'config' + ? copy.errors.configViewsUnavailable + : copy.errors.executionViewsUnavailable ) return false } @@ -1196,11 +1203,7 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) { try { await persistDirtyModes() } catch (error) { - setViewsError( - error instanceof Error - ? error.message - : copy.errors.persistBeforeSwitching - ) + setViewsError(error instanceof Error ? error.message : copy.errors.persistBeforeSwitching) return false } @@ -1229,9 +1232,14 @@ export function MonitorPage({ workspaceId, userId }: MonitorPageProps) { const configHeaderCards = useMemo( () => - buildConfigMonitorCards(monitors, referenceData, {}, { - unknownListingLabel: copy.execution.unknownListing, - }), + buildConfigMonitorCards( + monitors, + referenceData, + {}, + { + unknownListingLabel: copy.execution.unknownListing, + } + ), [copy.execution.unknownListing, monitors, referenceData] ) const viewControlsBusy = diff --git a/apps/tradinggoose/lib/copilot/tools/server/monitor/list-monitors.ts b/apps/tradinggoose/lib/copilot/tools/server/monitor/list-monitors.ts index f00b5211c..e3c5350df 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/monitor/list-monitors.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/monitor/list-monitors.ts @@ -1,8 +1,11 @@ -import { listMonitorRows, toMonitorRecord } from '@/app/api/monitors/shared' -import { buildMonitorListEntry, type MonitorRecord } from '@/lib/copilot/tools/server/monitor/shared' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' +import { + buildMonitorListEntry, + type MonitorRecord, +} from '@/lib/copilot/tools/server/monitor/shared' import { checkWorkspaceAccess } from '@/lib/permissions/utils' +import { listMonitorRows, toMonitorRecord } from '@/app/api/monitors/shared' type ListMonitorsArgs = { workspaceId: string @@ -30,7 +33,9 @@ export const listMonitorsServerTool: BaseServerTool = { workflowId: args.entityId, blockId: args.blockId, }) - const monitors = (await Promise.all(rows.map((row) => toMonitorRecord(row.webhook)))) as MonitorRecord[] + const monitors = (await Promise.all( + rows.map((row) => toMonitorRecord(row.webhook)) + )) as MonitorRecord[] const monitorEntries = monitors.map(buildMonitorListEntry) return { diff --git a/apps/tradinggoose/lib/copilot/tools/server/monitor/read-monitor.ts b/apps/tradinggoose/lib/copilot/tools/server/monitor/read-monitor.ts index 60126d441..fb575f2c6 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/monitor/read-monitor.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/monitor/read-monitor.ts @@ -1,10 +1,10 @@ -import { getMonitorRowById, toMonitorRecord } from '@/app/api/monitors/shared' +import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { buildMonitorDocumentEnvelope, type MonitorRecord, } from '@/lib/copilot/tools/server/monitor/shared' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { checkWorkspaceAccess } from '@/lib/permissions/utils' +import { getMonitorRowById, toMonitorRecord } from '@/app/api/monitors/shared' type ReadMonitorArgs = { monitorId: string From 1f5e6218138364d555680afcac1a5795dcd6c9ea Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Thu, 25 Jun 2026 23:58:22 -0600 Subject: [PATCH 199/284] feat(mcp): use dedicated api key type Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/copilot/mcp/route.test.ts | 5 +++-- apps/tradinggoose/app/api/copilot/mcp/route.ts | 6 +++--- .../app/api/users/me/api-keys/[id]/route.ts | 2 +- apps/tradinggoose/app/api/v1/auth.ts | 8 ++++++-- .../app/api/workflows/middleware.ts | 13 ++++++++++++- apps/tradinggoose/lib/api-key/service.test.ts | 11 +++++++++++ apps/tradinggoose/lib/api-key/service.ts | 18 +++++++++++------- apps/tradinggoose/lib/auth/hybrid.ts | 8 ++++++-- apps/tradinggoose/lib/mcp/auth.ts | 2 +- apps/tradinggoose/lib/mcp/install-script.ts | 11 ++++------- packages/db/schema/workspaces.ts | 6 +++--- 11 files changed, 61 insertions(+), 29 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index 196de6e14..66367e9e7 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -130,7 +130,7 @@ describe('Copilot MCP route', () => { expect(response.headers.get('MCP-Protocol-Version')).toBe('2025-03-26') expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('sk-tradinggoose-test', { - keyTypes: ['personal'], + keyTypes: ['mcp'], }) expect(mockUpdateApiKeyLastUsed).toHaveBeenCalledWith('key-1') expect(mockCheckApiEndpointRateLimit).toHaveBeenCalledWith('user-1', 'copilot-mcp') @@ -144,6 +144,7 @@ describe('Copilot MCP route', () => { ) expect(body.result.instructions).toContain('trusted personal coding agents') expect(body.result.instructions).toContain('Mutating tools execute directly') + expect(body.result.instructions).toContain('authenticated MCP key') }) it('accepts a case-insensitive bearer auth scheme', async () => { @@ -155,7 +156,7 @@ describe('Copilot MCP route', () => { expect(response.status).toBe(200) expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('sk-lowercase', { - keyTypes: ['personal'], + keyTypes: ['mcp'], }) }) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index e374211a5..e7e5fd98d 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -97,9 +97,9 @@ async function authenticateCopilotMcpRequest( return { error: 'Bearer token required' } } - const auth = await authenticateApiKeyFromHeader(token, { keyTypes: ['personal'] }) + const auth = await authenticateApiKeyFromHeader(token, { keyTypes: ['mcp'] }) if (!auth.success || !auth.userId) { - return { error: 'Invalid TradingGoose personal API key' } + return { error: 'Invalid TradingGoose MCP token' } } if (auth.keyId) { @@ -122,7 +122,7 @@ async function buildInstructions(userId: string) { return [ 'TradingGoose Copilot MCP exposes server-side Copilot tools for trusted personal coding agents, including direct mutation tools.', 'Local MCP config stores only this user auth token. Do not store workspaceId, entityId, or entity targets in the local MCP config.', - 'Use tools/list as the source of truth for each tool input schema; target identifiers are tool-specific and come from list/read tool results. Mutating tools execute directly for the authenticated personal API key; Studio review tokens are not part of the external MCP protocol. Credential, OAuth, and environment reads require scope="personal" for the authenticated user or scope="workspace" with workspaceId. Workspace-scoped tools, including list/create, Google Drive, and workspace account reads, require workspaceId. Environment writes use the same personal/workspace scope rule.', + 'Use tools/list as the source of truth for each tool input schema; target identifiers are tool-specific and come from list/read tool results. Mutating tools execute directly for the authenticated MCP key; Studio review tokens are not part of the external MCP protocol. Credential, OAuth, and environment reads require scope="personal" for the authenticated user or scope="workspace" with workspaceId. Workspace-scoped tools, including list/create, Google Drive, and workspace account reads, require workspaceId. Environment writes use the same personal/workspace scope rule.', 'MCP server documents redact header/env secret values as [redacted]. Keep [redacted] to preserve an existing secret, send a concrete value to replace it, or omit the key to delete it.', 'Accessible workspaces for the authenticated user:', ...workspaceLines, diff --git a/apps/tradinggoose/app/api/users/me/api-keys/[id]/route.ts b/apps/tradinggoose/app/api/users/me/api-keys/[id]/route.ts index 368b9492f..b865d5d9c 100644 --- a/apps/tradinggoose/app/api/users/me/api-keys/[id]/route.ts +++ b/apps/tradinggoose/app/api/users/me/api-keys/[id]/route.ts @@ -33,7 +33,7 @@ export async function DELETE( // Delete the API key, ensuring it belongs to the current user const result = await db .delete(apiKey) - .where(and(eq(apiKey.id, keyId), eq(apiKey.userId, userId))) + .where(and(eq(apiKey.id, keyId), eq(apiKey.userId, userId), eq(apiKey.type, 'personal'))) .returning({ id: apiKey.id }) if (!result.length) { diff --git a/apps/tradinggoose/app/api/v1/auth.ts b/apps/tradinggoose/app/api/v1/auth.ts index b9e5b59c2..2f5b6c1f2 100644 --- a/apps/tradinggoose/app/api/v1/auth.ts +++ b/apps/tradinggoose/app/api/v1/auth.ts @@ -1,5 +1,9 @@ import type { NextRequest } from 'next/server' -import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' +import { + type ApiKeyType, + authenticateApiKeyFromHeader, + updateApiKeyLastUsed, +} from '@/lib/api-key/service' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('V1Auth') @@ -8,7 +12,7 @@ export interface AuthResult { authenticated: boolean userId?: string workspaceId?: string - keyType?: 'personal' | 'workspace' + keyType?: ApiKeyType error?: string } diff --git a/apps/tradinggoose/app/api/workflows/middleware.ts b/apps/tradinggoose/app/api/workflows/middleware.ts index 0ad8b66d0..750db7c5d 100644 --- a/apps/tradinggoose/app/api/workflows/middleware.ts +++ b/apps/tradinggoose/app/api/workflows/middleware.ts @@ -67,6 +67,17 @@ export async function validateWorkflowAccess( // If a pinned key exists, only accept that specific key if (workflow.pinnedApiKey?.key) { + if ( + workflow.pinnedApiKey.type !== 'personal' && + workflow.pinnedApiKey.type !== 'workspace' + ) { + return { + error: { + message: 'Unauthorized: Invalid API key', + status: 401, + }, + } + } const isValidPinnedKey = await storedApiKeyMatches(apiKeyHeader, workflow.pinnedApiKey.key) if (!isValidPinnedKey) { return { @@ -82,7 +93,7 @@ export async function validateWorkflowAccess( success: true, userId: workflow.pinnedApiKey.userId, keyId: workflow.pinnedApiKey.id, - keyType: workflow.pinnedApiKey.type === 'workspace' ? 'workspace' : 'personal', + keyType: workflow.pinnedApiKey.type, workspaceId: workflow.pinnedApiKey.workspaceId || undefined, }, } diff --git a/apps/tradinggoose/lib/api-key/service.test.ts b/apps/tradinggoose/lib/api-key/service.test.ts index 87f3c1434..3a20cfc3c 100644 --- a/apps/tradinggoose/lib/api-key/service.test.ts +++ b/apps/tradinggoose/lib/api-key/service.test.ts @@ -67,4 +67,15 @@ describe('API key service', () => { }) expect(mockDbSelect).not.toHaveBeenCalled() }) + + it('only authenticates MCP keys when MCP scope is explicit', async () => { + const { authenticateApiKeyFromHeader } = await import('./service') + const { eq, inArray } = await import('drizzle-orm') + + await authenticateApiKeyFromHeader(`tradinggoose_${'a'.repeat(32)}`) + expect(inArray).toHaveBeenCalledWith('apiKey.type', ['personal', 'workspace']) + + await authenticateApiKeyFromHeader(`tradinggoose_${'a'.repeat(32)}`, { keyTypes: ['mcp'] }) + expect(eq).toHaveBeenCalledWith('apiKey.type', 'mcp') + }) }) diff --git a/apps/tradinggoose/lib/api-key/service.ts b/apps/tradinggoose/lib/api-key/service.ts index 1b7c312fc..525f6f5d5 100644 --- a/apps/tradinggoose/lib/api-key/service.ts +++ b/apps/tradinggoose/lib/api-key/service.ts @@ -8,18 +8,21 @@ import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('ApiKeyService') const API_KEY_SECRET_PATTERN = /^[A-Za-z0-9_-]{32}$/ +const DEFAULT_API_KEY_AUTH_TYPES: ApiKeyType[] = ['personal', 'workspace'] + +export type ApiKeyType = 'personal' | 'workspace' | 'mcp' export interface ApiKeyAuthOptions { userId?: string workspaceId?: string - keyTypes?: ('personal' | 'workspace')[] + keyTypes?: ApiKeyType[] } export interface ApiKeyAuthResult { success: boolean userId?: string keyId?: string - keyType?: 'personal' | 'workspace' + keyType?: ApiKeyType workspaceId?: string error?: string } @@ -70,10 +73,11 @@ export async function authenticateApiKeyFromHeader( conditions.push(eq(apiKeyTable.workspaceId, options.workspaceId)) } - if (options.keyTypes?.length === 1) { - conditions.push(eq(apiKeyTable.type, options.keyTypes[0])) - } else if (options.keyTypes?.length) { - conditions.push(inArray(apiKeyTable.type, options.keyTypes)) + const keyTypes = options.keyTypes?.length ? options.keyTypes : DEFAULT_API_KEY_AUTH_TYPES + if (keyTypes.length === 1) { + conditions.push(eq(apiKeyTable.type, keyTypes[0])) + } else { + conditions.push(inArray(apiKeyTable.type, keyTypes)) } const query = db @@ -103,7 +107,7 @@ export async function authenticateApiKeyFromHeader( success: true, userId: storedKey.userId, keyId: storedKey.id, - keyType: storedKey.type as 'personal' | 'workspace', + keyType: storedKey.type as ApiKeyType, workspaceId: storedKey.workspaceId || undefined, } } diff --git a/apps/tradinggoose/lib/auth/hybrid.ts b/apps/tradinggoose/lib/auth/hybrid.ts index 2e22fc286..49063f932 100644 --- a/apps/tradinggoose/lib/auth/hybrid.ts +++ b/apps/tradinggoose/lib/auth/hybrid.ts @@ -1,5 +1,9 @@ import type { NextRequest } from 'next/server' -import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' +import { + type ApiKeyType, + authenticateApiKeyFromHeader, + updateApiKeyLastUsed, +} from '@/lib/api-key/service' import { getSession } from '@/lib/auth' import { type InternalTokenVerificationResult, @@ -28,7 +32,7 @@ export interface AuthResult { userName?: string | null userEmail?: string | null authType?: (typeof AuthType)[keyof typeof AuthType] - apiKeyType?: 'personal' | 'workspace' + apiKeyType?: ApiKeyType internalWorkflowExecution?: InternalWorkflowExecutionContext error?: string } diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 3d3e643a2..a88913d87 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -477,7 +477,7 @@ export async function acknowledgeMcpDeviceLogin({ workspaceId: null, name: `TradingGoose MCP Access ${now.toISOString()}`, key: encryptedKey, - type: 'personal', + type: 'mcp', createdAt: now, updatedAt: now, }) diff --git a/apps/tradinggoose/lib/mcp/install-script.ts b/apps/tradinggoose/lib/mcp/install-script.ts index a4bacb6fe..6fe547170 100644 --- a/apps/tradinggoose/lib/mcp/install-script.ts +++ b/apps/tradinggoose/lib/mcp/install-script.ts @@ -98,7 +98,7 @@ async function authenticate() { fail('Studio did not return an authorization URL') } - console.log('Open this URL in your browser to approve a personal API key:') + console.log('Open this URL in your browser to approve MCP access:') console.log(authorizeUrl) console.log('') @@ -150,10 +150,7 @@ async function main() { console.log('MCP endpoint:') console.log(mcpUrl) console.log('') - console.log('Bearer token:') - console.log(login.token) - console.log('') - console.log('Use this MCP auth header:') + console.log('MCP authorization header:') console.log('Authorization: Bearer ' + login.token) return } @@ -218,7 +215,7 @@ PowerShell: irm /mcp/login | iex Commands: - login Print a valid local personal API bearer token, authenticating when needed. + login Print the MCP endpoint and authorization header, authenticating when needed. setup Write MCP config, authenticating when needed. Options: @@ -337,7 +334,7 @@ POSIX shell: curl -fsSL /mcp/login | sh Commands: - login Print a valid local personal API bearer token, authenticating when needed. + login Print the MCP endpoint and authorization header, authenticating when needed. setup Write MCP config, authenticating when needed. Options: diff --git a/packages/db/schema/workspaces.ts b/packages/db/schema/workspaces.ts index 2c94806af..da51ee31d 100644 --- a/packages/db/schema/workspaces.ts +++ b/packages/db/schema/workspaces.ts @@ -79,17 +79,17 @@ export const apiKey = pgTable( createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), // Who created the workspace key name: text('name').notNull(), key: text('key').notNull().unique(), - type: text('type').notNull().default('personal'), // 'personal' or 'workspace' + type: text('type').notNull().default('personal'), // 'personal', 'workspace', or 'mcp' lastUsed: timestamp('last_used'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), expiresAt: timestamp('expires_at'), }, (table) => ({ - // Ensure workspace keys have a workspace_id and personal keys don't + // Ensure only workspace keys have a workspace_id. workspaceTypeCheck: check( 'workspace_type_check', - sql`(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)` + sql`(type = 'workspace' AND workspace_id IS NOT NULL) OR (type IN ('personal', 'mcp') AND workspace_id IS NULL)` ), }) ) From ddaa809b9a988521d1ef8dc69ec76d0c39fb6059 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 00:36:52 -0600 Subject: [PATCH 200/284] feat(mcp): hydrate server configs from saved entity state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/mcp/servers/[id]/route.ts | 19 --- .../tradinggoose/app/api/mcp/servers/route.ts | 81 +++++-------- .../lib/copilot/entity-documents.ts | 15 ++- apps/tradinggoose/lib/mcp/service.ts | 113 +++++++++++------- 4 files changed, 110 insertions(+), 118 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts index 6f936075d..2115a7500 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts @@ -5,7 +5,6 @@ import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' -import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { @@ -48,24 +47,6 @@ export const PATCH = withMcpAuth('write')( updates: Object.keys(body).filter((k) => k !== 'workspaceId'), }) - // Validate URL if being updated - if ( - body.url && - (body.transport === 'http' || - body.transport === 'sse' || - body.transport === 'streamable-http') - ) { - const urlValidation = validateMcpServerUrl(body.url) - if (!urlValidation.isValid) { - return createMcpErrorResponse( - new Error(`Invalid MCP server URL: ${urlValidation.error}`), - 'Invalid server URL', - 400 - ) - } - body.url = urlValidation.normalizedUrl - } - const [existingServer] = await db .select() .from(mcpServers) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 5a8b40e8c..c6869fbbf 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -1,12 +1,11 @@ import { db } from '@tradinggoose/db' import { mcpServers } from '@tradinggoose/db/schema' -import { and, eq, isNull } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { normalizeEntityFields } from '@/lib/copilot/entity-documents' import { createLogger } from '@/lib/logs/console/logger' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' -import type { McpTransport } from '@/lib/mcp/types' -import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { CreateMcpServerSchema } from './schema' @@ -15,13 +14,6 @@ const logger = createLogger('McpServersAPI') export const dynamic = 'force-dynamic' -/** - * Check if transport type requires a URL - */ -function isUrlBasedTransport(transport: McpTransport): boolean { - return transport === 'http' || transport === 'sse' || transport === 'streamable-http' -} - /** * GET - List all registered MCP servers for the workspace */ @@ -30,10 +22,7 @@ export const GET = withMcpAuth('read')( try { logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) - const servers = await db - .select() - .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) + const servers = await mcpService.listWorkspaceServers(workspaceId) logger.info( `[${requestId}] Listed ${servers.length} MCP servers for workspace ${workspaceId}` @@ -75,53 +64,49 @@ export const POST = withMcpAuth('write')( workspaceId, }) - if (isUrlBasedTransport(body.transport as McpTransport)) { - const urlValidation = validateMcpServerUrl(body.url ?? '') - if (!urlValidation.isValid) { - return createMcpErrorResponse( - new Error(`Invalid MCP server URL: ${urlValidation.error}`), - 'Invalid server URL', - 400 - ) - } - body.url = urlValidation.normalizedUrl + let fields: Record + try { + fields = normalizeEntityFields('mcp_server', body) + } catch (error) { + return createMcpErrorResponse( + error instanceof Error ? error : new Error('Invalid MCP server fields'), + 'Invalid MCP server fields', + 400 + ) } const serverId = crypto.randomUUID() - const [server] = await db - .insert(mcpServers) - .values({ - id: serverId, - workspaceId, - createdBy: userId, - name: body.name, - description: body.description ?? null, - transport: body.transport, - url: body.url ?? null, - headers: body.headers || {}, - command: body.command ?? null, - args: body.args ?? [], - env: body.env ?? {}, - timeout: body.timeout || 30000, - retries: body.retries || 3, - enabled: body.enabled !== false, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() + await db.insert(mcpServers).values({ + id: serverId, + workspaceId, + createdBy: userId, + name: String(fields.name ?? ''), + description: String(fields.description ?? '') || null, + transport: String(fields.transport ?? ''), + url: String(fields.url ?? '') || null, + headers: fields.headers, + command: String(fields.command ?? '') || null, + args: Array.isArray(fields.args) ? fields.args.map(String) : [], + env: fields.env, + timeout: Number(fields.timeout ?? 30000), + retries: Number(fields.retries ?? 3), + enabled: fields.enabled !== false, + createdAt: new Date(), + updatedAt: new Date(), + }) mcpService.clearCache(workspaceId) - logger.info(`[${requestId}] Successfully registered MCP server: ${body.name}`) + logger.info(`[${requestId}] Successfully registered MCP server: ${fields.name}`) // Track MCP server registration try { const { trackPlatformEvent } = await import('@/lib/telemetry/tracer') trackPlatformEvent('platform.mcp.server_added', { 'mcp.server_id': serverId, - 'mcp.server_name': body.name, - 'mcp.transport': body.transport, + 'mcp.server_name': String(fields.name ?? ''), + 'mcp.transport': String(fields.transport ?? ''), 'workspace.id': workspaceId, }) } catch (_e) { diff --git a/apps/tradinggoose/lib/copilot/entity-documents.ts b/apps/tradinggoose/lib/copilot/entity-documents.ts index cb4c5f934..b03a692d2 100644 --- a/apps/tradinggoose/lib/copilot/entity-documents.ts +++ b/apps/tradinggoose/lib/copilot/entity-documents.ts @@ -163,6 +163,14 @@ export function normalizeEntityFields( } } case 'mcp_server': { + if ( + source.transport !== 'http' && + source.transport !== 'sse' && + source.transport !== 'streamable-http' + ) { + throw new Error(`Invalid MCP server transport "${String(source.transport ?? '')}"`) + } + const rawUrl = typeof source.url === 'string' ? source.url.trim() : '' const validation = validateMcpServerUrl(rawUrl) if (!validation.isValid) { @@ -172,12 +180,7 @@ export function normalizeEntityFields( return { name: typeof source.name === 'string' ? source.name.trim() : '', description: typeof source.description === 'string' ? source.description.trim() : '', - transport: - source.transport === 'http' || - source.transport === 'sse' || - source.transport === 'streamable-http' - ? source.transport - : 'http', + transport: source.transport, url: validation.normalizedUrl ?? rawUrl, headers: normalizeHttpHeaderRecord(source.headers), command: typeof source.command === 'string' ? source.command.trim() : '', diff --git a/apps/tradinggoose/lib/mcp/service.ts b/apps/tradinggoose/lib/mcp/service.ts index 3137c4634..6e3f33fee 100644 --- a/apps/tradinggoose/lib/mcp/service.ts +++ b/apps/tradinggoose/lib/mcp/service.ts @@ -4,7 +4,8 @@ import { db } from '@tradinggoose/db' import { mcpServers } from '@tradinggoose/db/schema' -import { and, eq, isNull } from 'drizzle-orm' +import { and, eq, type InferSelectModel, isNull } from 'drizzle-orm' +import { normalizeEntityFields } from '@/lib/copilot/entity-documents' import { isTest } from '@/lib/environment' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { createLogger } from '@/lib/logs/console/logger' @@ -19,8 +20,10 @@ import type { } from '@/lib/mcp/types' import { MCP_CONSTANTS } from '@/lib/mcp/utils' import { generateRequestId } from '@/lib/utils' +import { readBootstrappedSavedEntityFields } from '@/lib/yjs/server/bootstrap-review-target' const logger = createLogger('McpService') +type McpServerRow = InferSelectModel interface ToolCache { tools: McpTool[] @@ -236,9 +239,60 @@ class McpService { } } - /** - * Get server configuration from database - */ + private async getWorkspaceServerRows(workspaceId: string): Promise { + return db + .select() + .from(mcpServers) + .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) + } + + private async readServerFields(server: McpServerRow): Promise> { + return normalizeEntityFields( + 'mcp_server', + await readBootstrappedSavedEntityFields('mcp_server', server.id, server.workspaceId) + ) + } + + private toServerConfig(server: McpServerRow, fields: Record): McpServerConfig { + return { + id: server.id, + name: String(fields.name ?? ''), + description: String(fields.description ?? '') || undefined, + transport: fields.transport as McpTransport, + url: String(fields.url ?? '') || undefined, + headers: fields.headers as Record, + timeout: Number(fields.timeout ?? 30000), + retries: Number(fields.retries ?? 3), + enabled: fields.enabled !== false, + createdAt: server.createdAt.toISOString(), + updatedAt: server.updatedAt.toISOString(), + } + } + + async listWorkspaceServers(workspaceId: string): Promise { + const servers = await this.getWorkspaceServerRows(workspaceId) + + return Promise.all( + servers.map(async (server) => { + const fields = await this.readServerFields(server) + return { + ...server, + name: String(fields.name ?? ''), + description: String(fields.description ?? '') || null, + transport: String(fields.transport ?? ''), + url: String(fields.url ?? '') || null, + headers: fields.headers, + command: String(fields.command ?? '') || null, + args: Array.isArray(fields.args) ? fields.args.map(String) : [], + env: fields.env, + timeout: Number(fields.timeout ?? 30000), + retries: Number(fields.retries ?? 3), + enabled: fields.enabled !== false, + } + }) + ) + } + private async getServerConfig( serverId: string, workspaceId: string @@ -259,51 +313,20 @@ class McpService { return null } - if (!server.enabled) { - return null - } - - return { - id: server.id, - name: server.name, - description: server.description || undefined, - transport: server.transport as 'http' | 'sse', - url: server.url || undefined, - headers: (server.headers as Record) || {}, - timeout: server.timeout || 30000, - retries: server.retries || 3, - enabled: server.enabled, - createdAt: server.createdAt.toISOString(), - updatedAt: server.updatedAt.toISOString(), - } + const fields = await this.readServerFields(server) + return fields.enabled === false ? null : this.toServerConfig(server, fields) } - /** - * Get all enabled servers for a workspace - */ private async getWorkspaceServers(workspaceId: string): Promise { - const whereConditions = [eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt)] + const servers = await this.getWorkspaceServerRows(workspaceId) + const configs = await Promise.all( + servers.map(async (server) => { + const fields = await this.readServerFields(server) + return fields.enabled === false ? null : this.toServerConfig(server, fields) + }) + ) - const servers = await db - .select() - .from(mcpServers) - .where(and(...whereConditions)) - - return servers - .filter((server) => server.enabled) - .map((server) => ({ - id: server.id, - name: server.name, - description: server.description || undefined, - transport: server.transport as McpTransport, - url: server.url || undefined, - headers: (server.headers as Record) || {}, - timeout: server.timeout || 30000, - retries: server.retries || 3, - enabled: server.enabled, - createdAt: server.createdAt.toISOString(), - updatedAt: server.updatedAt.toISOString(), - })) + return configs.filter((config): config is McpServerConfig => config !== null) } /** From 71d5490bc3cce524f78fcf28e8f2d719315bd203 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 01:00:28 -0600 Subject: [PATCH 201/284] fix(mcp): validate server fields consistently Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/mcp/servers/schema.ts | 18 +------ apps/tradinggoose/lib/mcp/service.ts | 51 ++++++++++++------- 2 files changed, 34 insertions(+), 35 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/schema.ts b/apps/tradinggoose/app/api/mcp/servers/schema.ts index 2d6360d90..16ae75277 100644 --- a/apps/tradinggoose/app/api/mcp/servers/schema.ts +++ b/apps/tradinggoose/app/api/mcp/servers/schema.ts @@ -1,14 +1,10 @@ import { z } from 'zod' -/** - * Base schema for MCP server fields shared between create and update operations. - * `name` and `transport` are required here; the update schema derives from this via `.partial()`. - */ const McpServerBaseSchema = z.object({ name: z.string().min(1), description: z.string().optional(), - transport: z.string().min(1), - url: z.string().optional(), + transport: z.enum(['http', 'sse', 'streamable-http']), + url: z.string().min(1), headers: z.record(z.string()).optional(), command: z.string().optional(), args: z.array(z.string()).optional(), @@ -18,20 +14,10 @@ const McpServerBaseSchema = z.object({ enabled: z.boolean().optional(), }) -/** - * Schema for creating a new MCP server. - * `name` and `transport` are required. IDs are generated by the server. - */ export const CreateMcpServerSchema = McpServerBaseSchema -/** - * Schema for updating an existing MCP server. - * All fields are optional. `description`, `url`, and `command` additionally accept null - * so that clients can explicitly clear those fields. - */ export const UpdateMcpServerSchema = McpServerBaseSchema.partial().extend({ description: z.string().optional().nullable(), - url: z.string().optional().nullable(), command: z.string().optional().nullable(), workspaceId: z.string().optional(), }) diff --git a/apps/tradinggoose/lib/mcp/service.ts b/apps/tradinggoose/lib/mcp/service.ts index 6e3f33fee..57309bbdc 100644 --- a/apps/tradinggoose/lib/mcp/service.ts +++ b/apps/tradinggoose/lib/mcp/service.ts @@ -247,10 +247,7 @@ class McpService { } private async readServerFields(server: McpServerRow): Promise> { - return normalizeEntityFields( - 'mcp_server', - await readBootstrappedSavedEntityFields('mcp_server', server.id, server.workspaceId) - ) + return readBootstrappedSavedEntityFields('mcp_server', server.id, server.workspaceId) } private toServerConfig(server: McpServerRow, fields: Record): McpServerConfig { @@ -275,19 +272,29 @@ class McpService { return Promise.all( servers.map(async (server) => { const fields = await this.readServerFields(server) - return { - ...server, - name: String(fields.name ?? ''), - description: String(fields.description ?? '') || null, - transport: String(fields.transport ?? ''), - url: String(fields.url ?? '') || null, - headers: fields.headers, - command: String(fields.command ?? '') || null, - args: Array.isArray(fields.args) ? fields.args.map(String) : [], - env: fields.env, - timeout: Number(fields.timeout ?? 30000), - retries: Number(fields.retries ?? 3), - enabled: fields.enabled !== false, + try { + const normalized = normalizeEntityFields('mcp_server', fields) + return { + ...server, + name: String(normalized.name ?? ''), + description: String(normalized.description ?? '') || null, + transport: String(normalized.transport ?? ''), + url: String(normalized.url ?? '') || null, + headers: normalized.headers, + command: String(normalized.command ?? '') || null, + args: Array.isArray(normalized.args) ? normalized.args.map(String) : [], + env: normalized.env, + timeout: Number(normalized.timeout ?? 30000), + retries: Number(normalized.retries ?? 3), + enabled: normalized.enabled !== false, + } + } catch (error) { + logger.warn(`MCP server ${server.id} has invalid saved-entity state:`, error) + return { + ...server, + connectionStatus: 'error', + lastError: error instanceof Error ? error.message : 'Invalid server state', + } } }) ) @@ -313,7 +320,7 @@ class McpService { return null } - const fields = await this.readServerFields(server) + const fields = normalizeEntityFields('mcp_server', await this.readServerFields(server)) return fields.enabled === false ? null : this.toServerConfig(server, fields) } @@ -322,7 +329,13 @@ class McpService { const configs = await Promise.all( servers.map(async (server) => { const fields = await this.readServerFields(server) - return fields.enabled === false ? null : this.toServerConfig(server, fields) + try { + const normalized = normalizeEntityFields('mcp_server', fields) + return normalized.enabled === false ? null : this.toServerConfig(server, normalized) + } catch (error) { + logger.warn(`Skipping MCP server ${server.id} with invalid saved-entity state:`, error) + return null + } }) ) From b2a0d184581fd2aed2b66f07810c2f0380c97844 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 09:22:31 -0600 Subject: [PATCH 202/284] fix(yjs): stage apply mutations before persistence Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/socket-server/index.test.ts | 48 +++++++++++ .../tradinggoose/socket-server/routes/http.ts | 79 ++++++++++++------- 2 files changed, 97 insertions(+), 30 deletions(-) diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 15b1c282b..82d3ae90f 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -480,6 +480,54 @@ describe('Socket Server Index Integration', () => { expect(await getExistingDocument('workflow-failed')).toBeNull() }) + it('does not mutate a connected live workflow session when persistence fails', async () => { + const conn = new (await import('node:events')).EventEmitter() as any + conn.readyState = 1 + conn.send = vi.fn((_message, _options, callback) => callback?.()) + conn.ping = vi.fn() + conn.close = vi.fn() + setupWSConnection(conn, {} as any, { docId: 'workflow-connected' }) + const liveDoc = (await getExistingDocument('workflow-connected'))! + setWorkflowState( + liveDoc, + { blocks: { keep: { id: 'keep' } as any }, edges: [], loops: {}, parallels: {} }, + 'test' + ) + + mockSaveWorkflowYjsDocToDb.mockRejectedValueOnce(new Error('database unavailable')) + + const response = await sendHttpRequestWithOptions( + PORT, + '/internal/yjs/workflows/workflow-connected/apply-state', + { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-internal-secret': INTERNAL_SECRET }, + body: JSON.stringify({ + workflowState: { + blocks: { replaced: { id: 'replaced' } }, + edges: [], + loops: {}, + parallels: {}, + lastSaved: '2026-04-06T00:00:00.000Z', + isDeployed: false, + }, + }), + } + ) + + // A failed write must never leave connected clients ahead of the database: + // the live session still holds the pre-command block. + expect(response.statusCode).toBe(500) + const liveBlocks = extractPersistedStateFromDoc( + (await getExistingDocument('workflow-connected'))! + ).blocks + expect(liveBlocks).toHaveProperty('keep') + expect(liveBlocks).not.toHaveProperty('replaced') + + conn.emit('close') + await new Promise((resolve) => setImmediate(resolve)) + }) + it('should discard an idle saved entity document when update materialization fails', async () => { mockSaveSavedEntityYjsDocToDb.mockRejectedValueOnce(new Error('database unavailable')) const updateDoc = new Y.Doc() diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index 4a01cf722..c75f55900 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -275,6 +275,40 @@ async function getBootstrappedApplyDocument( return getInitializedSessionDocument(descriptor.yjsSessionId, bootstrapped.state) } +/** + * Applies a programmatic mutation to a live Yjs apply-document durably: the change + * is staged on a detached copy and persisted FIRST, so the live session never + * broadcasts state that is not in the database. Only after the write succeeds is + * the same mutation reflected into the live document for connected clients. + */ +async function applyThroughStaging( + doc: Y.Doc, + sessionId: string, + mutate: (target: Y.Doc) => void, + persist: (staged: Y.Doc) => Promise +): Promise { + const staging = new Y.Doc() + Y.applyUpdate(staging, Y.encodeStateAsUpdate(doc)) + try { + mutate(staging) + await persist(staging) + mutate(doc) + markDocumentPersisted(doc) + } finally { + staging.destroy() + discardDocumentIfIdle(sessionId) + } +} + +function applyWorkflowApplyRequest(doc: Y.Doc, body: ApplyWorkflowStateRequest): void { + if (body.workflowState) { + replaceWorkflowDocumentState(doc, body.workflowState, body.variables, body.metadata) + return + } + if (body.variables !== undefined) replaceWorkflowVariables(doc, body.variables, YJS_ORIGINS.SYSTEM) + if (body.metadata) setWorkflowEntityMetadata(doc, body.metadata) +} + async function handleInternalYjsWorkflowApplyRequest( req: IncomingMessage, res: ServerResponse, @@ -292,22 +326,12 @@ async function handleInternalYjsWorkflowApplyRequest( yjsSessionId: workflowId, } as const const doc = await getBootstrappedApplyDocument(descriptor) - - try { - if (body.workflowState) { - replaceWorkflowDocumentState(doc, body.workflowState, body.variables, body.metadata) - } - if (!body.workflowState && body.variables !== undefined) { - replaceWorkflowVariables(doc, body.variables, YJS_ORIGINS.SYSTEM) - } - if (!body.workflowState && body.metadata) setWorkflowEntityMetadata(doc, body.metadata) - await saveWorkflowYjsDocToDb(workflowId, doc) - markDocumentPersisted(doc) - discardDocumentIfIdle(workflowId) - } catch (error) { - discardDocumentIfIdle(descriptor.yjsSessionId) - throw error - } + await applyThroughStaging( + doc, + workflowId, + (target) => applyWorkflowApplyRequest(target, body), + (staged) => saveWorkflowYjsDocToDb(workflowId, staged) + ) sendJson(res, 200, { success: true }) } catch (error) { logger.error('Error applying workflow state', { error, workflowId }) @@ -335,20 +359,15 @@ async function handleInternalYjsEntityApplyRequest( yjsSessionId: entityId, } as const const doc = await getBootstrappedApplyDocument(descriptor) - - try { - seedEntitySession(doc, { - entityKind: body.entityKind, - payload: body.fields, - }) - clearSessionReseededFromCanonical(doc) - await saveSavedEntityYjsDocToDb(body.entityKind, entityId, doc) - markDocumentPersisted(doc) - discardDocumentIfIdle(entityId) - } catch (error) { - discardDocumentIfIdle(descriptor.yjsSessionId) - throw error - } + await applyThroughStaging( + doc, + entityId, + (target) => { + seedEntitySession(target, { entityKind: body.entityKind, payload: body.fields }) + clearSessionReseededFromCanonical(target) + }, + (staged) => saveSavedEntityYjsDocToDb(body.entityKind, entityId, staged) + ) sendJson(res, 200, { success: true }) } catch (error) { From 151a0a031f36c7b349c2cfff21f81fcde2213d48 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 09:22:51 -0600 Subject: [PATCH 203/284] fix(yjs): scope saved entity materialization by workspace Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/yjs/entity-session.ts | 16 +++++++- .../lib/yjs/server/apply-entity-state.test.ts | 37 ++++++++++++++++--- .../lib/yjs/server/apply-entity-state.ts | 26 ++++++++----- .../lib/yjs/server/bootstrap-review-target.ts | 4 +- apps/tradinggoose/socket-server/index.test.ts | 25 ++++++++----- 5 files changed, 82 insertions(+), 26 deletions(-) diff --git a/apps/tradinggoose/lib/yjs/entity-session.ts b/apps/tradinggoose/lib/yjs/entity-session.ts index 8ec4e916d..c18147d21 100644 --- a/apps/tradinggoose/lib/yjs/entity-session.ts +++ b/apps/tradinggoose/lib/yjs/entity-session.ts @@ -6,7 +6,9 @@ * * Top-level collections: * - "fields" (Y.Map) — entity-kind-specific field values - * - "metadata" (Y.Map) — session-level metadata (bootstrap-touch, etc.) + * - "metadata" (Y.Map) — session-level metadata: the resolved `workspaceId` + * that owns the entity (its canonical persistence + * scope), plus bootstrap-touch and identity markers. * * Entity-kind adapters: * - skill: name, description, content @@ -34,6 +36,18 @@ export function getEntityMetadataMap(doc: Y.Doc): Y.Map { return doc.getMap('metadata') } +/** + * Metadata key carrying the workspace that owns the entity. Resolved once when + * the entity doc is bootstrapped and used as the authoritative scope when + * materializing the doc back to its canonical DB row. + */ +export const ENTITY_METADATA_WORKSPACE_ID_KEY = 'workspaceId' + +export function getEntityWorkspaceId(doc: Y.Doc): string | null { + const value = getEntityMetadataMap(doc).get(ENTITY_METADATA_WORKSPACE_ID_KEY) + return typeof value === 'string' && value.length > 0 ? value : null +} + // --------------------------------------------------------------------------- // Seed options // --------------------------------------------------------------------------- diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts index 7667288ec..d6094c456 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts @@ -28,14 +28,15 @@ vi.mock('@tradinggoose/db', () => ({ })) vi.mock('@tradinggoose/db/schema', () => ({ - customTools: { id: 'customTools.id' }, - knowledgeBase: { id: 'knowledgeBase.id' }, - mcpServers: { id: 'mcpServers.id' }, - pineIndicators: { id: 'pineIndicators.id' }, - skill: { id: 'skill.id' }, + customTools: { id: 'customTools.id', workspaceId: 'customTools.workspaceId' }, + knowledgeBase: { id: 'knowledgeBase.id', workspaceId: 'knowledgeBase.workspaceId' }, + mcpServers: { id: 'mcpServers.id', workspaceId: 'mcpServers.workspaceId' }, + pineIndicators: { id: 'pineIndicators.id', workspaceId: 'pineIndicators.workspaceId' }, + skill: { id: 'skill.id', workspaceId: 'skill.workspaceId' }, })) vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions) => ({ and: conditions })), eq: vi.fn((field, value) => ({ field, value })), })) @@ -51,10 +52,13 @@ vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ applyEntityStateInSocketServer: mockApplyEntityStateInSocketServer, })) -function buildDoc(fields: Record) { +function buildDoc(fields: Record, workspaceId: string | null = 'workspace-1') { const doc = new Y.Doc() const map = doc.getMap('fields') for (const [key, value] of Object.entries(fields)) map.set(key, value) + if (workspaceId !== null) { + doc.getMap('metadata').set('workspaceId', workspaceId) + } return doc } @@ -112,9 +116,30 @@ describe('applySavedEntityState', () => { content: 'Use the Yjs document.', updatedAt: expect.any(Date), }) + expect(mockUpdateWhere).toHaveBeenCalledWith({ + and: [ + { field: 'skill.id', value: 'skill-1' }, + { field: 'skill.workspaceId', value: 'workspace-1' }, + ], + }) expect(events).toEqual(['db']) }) + it('refuses to materialize when the Yjs document carries no workspace identity', async () => { + const { saveSavedEntityYjsDocToDb } = await import('./apply-entity-state') + const doc = buildDoc({ name: 'Yjs Skill', description: '', content: '' }, null) + + try { + await expect(saveSavedEntityYjsDocToDb('skill', 'skill-1', doc)).rejects.toMatchObject({ + status: 404, + }) + } finally { + doc.destroy() + } + + expect(mockDbUpdate).not.toHaveBeenCalled() + }) + it('throws when document materialization cannot find the saved entity row', async () => { const { saveSavedEntityYjsDocToDb } = await import('./apply-entity-state') const doc = buildDoc({ name: 'Yjs Skill', description: '', content: '' }) diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index fbae3f810..769ce1520 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -6,11 +6,11 @@ import { pineIndicators, skill, } from '@tradinggoose/db/schema' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import type * as Y from 'yjs' import { normalizeEntityFields } from '@/lib/copilot/entity-documents' import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' -import { getEntityFields } from '@/lib/yjs/entity-session' +import { getEntityFields, getEntityWorkspaceId } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { applyEntityStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' @@ -74,7 +74,8 @@ function normalizeSavedEntityFields( async function persistSavedEntityState( entityKind: SavedEntityKind, entityId: string, - fields: Record + fields: Record, + workspaceId: string ): Promise { const now = new Date() let persisted: Array<{ id: string }> @@ -91,7 +92,7 @@ async function persistSavedEntityState( content: String(fields.content ?? ''), updatedAt: now, }) - .where(eq(skill.id, entityId)) + .where(and(eq(skill.id, entityId), eq(skill.workspaceId, workspaceId))) .returning({ id: skill.id }), `A skill with the name "${name}" already exists in this workspace` ) @@ -108,7 +109,7 @@ async function persistSavedEntityState( code: String(fields.codeText ?? ''), updatedAt: now, }) - .where(eq(customTools.id, entityId)) + .where(and(eq(customTools.id, entityId), eq(customTools.workspaceId, workspaceId))) .returning({ id: customTools.id }), `A tool with the title "${title}" already exists in this workspace` ) @@ -124,7 +125,7 @@ async function persistSavedEntityState( inputMeta: objectField(fields.inputMeta), updatedAt: now, }) - .where(eq(pineIndicators.id, entityId)) + .where(and(eq(pineIndicators.id, entityId), eq(pineIndicators.workspaceId, workspaceId))) .returning({ id: pineIndicators.id }) break case 'knowledge_base': @@ -136,7 +137,7 @@ async function persistSavedEntityState( chunkingConfig: fields.chunkingConfig, updatedAt: now, }) - .where(eq(knowledgeBase.id, entityId)) + .where(and(eq(knowledgeBase.id, entityId), eq(knowledgeBase.workspaceId, workspaceId))) .returning({ id: knowledgeBase.id }) break case 'mcp_server': @@ -156,7 +157,7 @@ async function persistSavedEntityState( enabled: fields.enabled !== false, updatedAt: now, }) - .where(eq(mcpServers.id, entityId)) + .where(and(eq(mcpServers.id, entityId), eq(mcpServers.workspaceId, workspaceId))) .returning({ id: mcpServers.id }) break } @@ -199,5 +200,12 @@ export async function saveSavedEntityYjsDocToDb( doc: Y.Doc ): Promise { const yjsFields = normalizeSavedEntityFields(entityKind, getEntityFields(doc, entityKind)) - await persistSavedEntityState(entityKind, entityId, yjsFields) + const workspaceId = getEntityWorkspaceId(doc) + if (!workspaceId) { + throw new SavedEntityPersistenceError( + 404, + `Saved ${entityKind} ${entityId} workspace is missing while materializing Yjs state` + ) + } + await persistSavedEntityState(entityKind, entityId, yjsFields, workspaceId) } diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index db28be212..c03dd3656 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -92,6 +92,7 @@ export async function createSavedReviewTargetBootstrapUpdate( let workflowName: string | null | undefined let workflowDescription: string | null | undefined let workflowFolderId: string | null | undefined + let resolvedWorkspaceId: string | null = descriptor.workspaceId if (descriptor.entityKind === 'workflow') { const workflowState = await loadWorkflowBootstrapStateFromDb(descriptor.entityId) if (!workflowState) { @@ -121,6 +122,7 @@ export async function createSavedReviewTargetBootstrapUpdate( if (!workspaceId) { throw new ReviewTargetBootstrapError(404, 'Saved entity workspace is missing') } + resolvedWorkspaceId = workspaceId seedEntitySession(doc, { entityKind, @@ -132,7 +134,7 @@ export async function createSavedReviewTargetBootstrapUpdate( metadata.set('bootstrap-touch', Date.now()) metadata.set('entityKind', descriptor.entityKind) metadata.set('entityId', descriptor.entityId) - metadata.set('workspaceId', descriptor.workspaceId) + metadata.set('workspaceId', resolvedWorkspaceId) metadata.set('draftSessionId', descriptor.draftSessionId) metadata.set('reviewSessionId', descriptor.reviewSessionId) metadata.set('reseededFromCanonical', true) diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 82d3ae90f..59e27bcd7 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -74,15 +74,22 @@ vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ - createSavedReviewTargetBootstrapUpdate: vi.fn(async (descriptor) => ({ - descriptor, - runtime: { - docState: 'active', - replaySafe: false, - reseededFromCanonical: true, - }, - state: new Uint8Array([0, 0]), - })), + createSavedReviewTargetBootstrapUpdate: vi.fn(async (descriptor) => { + const Y = await import('yjs') + const doc = new Y.Doc() + doc.getMap('metadata').set('workspaceId', descriptor.workspaceId ?? 'workspace-1') + const state = Y.encodeStateAsUpdate(doc) + doc.destroy() + return { + descriptor, + runtime: { + docState: 'active', + replaySafe: false, + reseededFromCanonical: true, + }, + state, + } + }), getRuntimeStateFromDoc: vi.fn(() => ({ docState: 'active', replaySafe: false, From 61f21e51a603e27bab6bee72a4dd813d6ae24b0b Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 09:58:42 -0600 Subject: [PATCH 204/284] refactor(api-key): use personal keys for mcp access Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/copilot/mcp/route.test.ts | 4 ++-- apps/tradinggoose/app/api/copilot/mcp/route.ts | 2 +- apps/tradinggoose/lib/api-key/service.test.ts | 6 +++--- apps/tradinggoose/lib/api-key/service.ts | 2 +- apps/tradinggoose/lib/mcp/auth.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index 66367e9e7..af5bb8a3c 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -130,7 +130,7 @@ describe('Copilot MCP route', () => { expect(response.headers.get('MCP-Protocol-Version')).toBe('2025-03-26') expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('sk-tradinggoose-test', { - keyTypes: ['mcp'], + keyTypes: ['personal'], }) expect(mockUpdateApiKeyLastUsed).toHaveBeenCalledWith('key-1') expect(mockCheckApiEndpointRateLimit).toHaveBeenCalledWith('user-1', 'copilot-mcp') @@ -156,7 +156,7 @@ describe('Copilot MCP route', () => { expect(response.status).toBe(200) expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('sk-lowercase', { - keyTypes: ['mcp'], + keyTypes: ['personal'], }) }) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index e7e5fd98d..d3d3a6385 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -97,7 +97,7 @@ async function authenticateCopilotMcpRequest( return { error: 'Bearer token required' } } - const auth = await authenticateApiKeyFromHeader(token, { keyTypes: ['mcp'] }) + const auth = await authenticateApiKeyFromHeader(token, { keyTypes: ['personal'] }) if (!auth.success || !auth.userId) { return { error: 'Invalid TradingGoose MCP token' } } diff --git a/apps/tradinggoose/lib/api-key/service.test.ts b/apps/tradinggoose/lib/api-key/service.test.ts index 3a20cfc3c..01fd522e9 100644 --- a/apps/tradinggoose/lib/api-key/service.test.ts +++ b/apps/tradinggoose/lib/api-key/service.test.ts @@ -68,14 +68,14 @@ describe('API key service', () => { expect(mockDbSelect).not.toHaveBeenCalled() }) - it('only authenticates MCP keys when MCP scope is explicit', async () => { + it('uses eq for a single key type and inArray for the default set', async () => { const { authenticateApiKeyFromHeader } = await import('./service') const { eq, inArray } = await import('drizzle-orm') await authenticateApiKeyFromHeader(`tradinggoose_${'a'.repeat(32)}`) expect(inArray).toHaveBeenCalledWith('apiKey.type', ['personal', 'workspace']) - await authenticateApiKeyFromHeader(`tradinggoose_${'a'.repeat(32)}`, { keyTypes: ['mcp'] }) - expect(eq).toHaveBeenCalledWith('apiKey.type', 'mcp') + await authenticateApiKeyFromHeader(`tradinggoose_${'a'.repeat(32)}`, { keyTypes: ['personal'] }) + expect(eq).toHaveBeenCalledWith('apiKey.type', 'personal') }) }) diff --git a/apps/tradinggoose/lib/api-key/service.ts b/apps/tradinggoose/lib/api-key/service.ts index 525f6f5d5..595d70bdb 100644 --- a/apps/tradinggoose/lib/api-key/service.ts +++ b/apps/tradinggoose/lib/api-key/service.ts @@ -10,7 +10,7 @@ const logger = createLogger('ApiKeyService') const API_KEY_SECRET_PATTERN = /^[A-Za-z0-9_-]{32}$/ const DEFAULT_API_KEY_AUTH_TYPES: ApiKeyType[] = ['personal', 'workspace'] -export type ApiKeyType = 'personal' | 'workspace' | 'mcp' +export type ApiKeyType = 'personal' | 'workspace' export interface ApiKeyAuthOptions { userId?: string diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index a88913d87..3d3e643a2 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -477,7 +477,7 @@ export async function acknowledgeMcpDeviceLogin({ workspaceId: null, name: `TradingGoose MCP Access ${now.toISOString()}`, key: encryptedKey, - type: 'mcp', + type: 'personal', createdAt: now, updatedAt: now, }) From 7237f9246116b1a0c84b94baecfe3cc0a4872d49 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 09:59:07 -0600 Subject: [PATCH 205/284] fix(mcp): bootstrap workspaces for copilot initialize Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/copilot/mcp/route.test.ts | 2 +- apps/tradinggoose/app/api/copilot/mcp/route.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index af5bb8a3c..3895597ac 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -134,7 +134,7 @@ describe('Copilot MCP route', () => { }) expect(mockUpdateApiKeyLastUsed).toHaveBeenCalledWith('key-1') expect(mockCheckApiEndpointRateLimit).toHaveBeenCalledWith('user-1', 'copilot-mcp') - expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ userId: 'user-1', autoCreate: false }) + expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ userId: 'user-1' }) expect(body.result.capabilities).toEqual({ tools: {} }) expect(body.result.serverInfo).toEqual({ name: 'TradingGoose', version: '0.1.0' }) expect(body.result.instructions).toContain('workspaceId=workspace-1, permissions=admin') diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index d3d3a6385..cbe082a5f 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -110,7 +110,7 @@ async function authenticateCopilotMcpRequest( } async function buildInstructions(userId: string) { - const workspaces = await getUserWorkspaces({ userId, autoCreate: false }) + const workspaces = await getUserWorkspaces({ userId }) const workspaceLines = workspaces.length > 0 ? workspaces.map( From 6c1434bcf17c33de31a059a0b6d4bd57ac4326af Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 09:59:25 -0600 Subject: [PATCH 206/284] fix(mcp): sanitize copilot rpc errors Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/copilot/mcp/route.test.ts | 31 +++++ .../tradinggoose/app/api/copilot/mcp/route.ts | 113 ++++++++++-------- 2 files changed, 91 insertions(+), 53 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index 3895597ac..f368ce38b 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -282,6 +282,37 @@ describe('Copilot MCP route', () => { expect(body.result.structuredContent).toEqual({ success: true }) }) + it('returns a sanitized tool result when a tool execution fails', async () => { + const { POST } = await import('./route') + mockGetMcpServerToolIds.mockReturnValue(['list_workflows']) + mockRouteExecution.mockRejectedValueOnce(new Error('connection refused at db.internal:5432')) + + const response = await POST( + createMcpRequest({ + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { name: 'list_workflows', arguments: {} }, + }) + ) + const body = await response.json() + + expect(body.error).toBeUndefined() + expect(body.result.isError).toBe(true) + expect(body.result.structuredContent.code).toBe('server_tool_execution_failed') + }) + + it('sanitizes errors thrown by non-tool methods instead of leaking a raw response', async () => { + const { POST } = await import('./route') + mockGetUserWorkspaces.mockRejectedValueOnce(new Error('workspace bootstrap failed at shard-3')) + + const response = await POST(createMcpRequest({ jsonrpc: '2.0', id: 7, method: 'initialize' })) + const body = await response.json() + + expect(body.error.code).toBe(-32603) + expect(body.error.data.code).toBe('server_tool_execution_failed') + }) + it('returns per-entry invalid request errors for malformed batches', async () => { const { POST } = await import('./route') diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index cbe082a5f..137b51936 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -6,6 +6,7 @@ import { } from '@/lib/api/rate-limit' import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' import { getCopilotRuntimeToolManifest } from '@/lib/copilot/runtime-tool-manifest' +import { buildCopilotServerToolErrorResponse } from '@/lib/copilot/server-tool-errors' import { getMcpServerToolIds, routeExecution } from '@/lib/copilot/tools/server/router' import { getUserWorkspaces } from '@/lib/workspaces/service' @@ -181,67 +182,73 @@ async function handleJsonRpcRequest(entry: unknown, auth: AuthenticatedMcpUser) return null } - switch (request.method) { - case 'initialize': - return jsonRpcResult(id, { - protocolVersion: MCP_PROTOCOL_VERSION, - capabilities: { - tools: {}, - }, - serverInfo: { - name: SERVER_NAME, - version: SERVER_VERSION, - }, - instructions: await buildInstructions(auth.userId), - }) - - case 'ping': - return jsonRpcResult(id, {}) - - case 'tools/list': - return jsonRpcResult(id, { - tools: await listMcpTools(), - }) - - case 'tools/call': { - const toolCall = getToolCallParams(request.params) - if (!toolCall) { - return jsonRpcError(id, -32602, 'Invalid tools/call params') - } - if (!getMcpServerToolIds().some((toolName) => toolName === toolCall.name)) { - return jsonRpcError(id, -32601, `Unsupported MCP tool: ${toolCall.name}`) - } - - try { - const result = await routeExecution(toolCall.name, toolCall.args, { - userId: auth.userId, - accessLevel: 'full', - }) + try { + switch (request.method) { + case 'initialize': return jsonRpcResult(id, { - content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], - structuredContent: result, + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { + tools: {}, + }, + serverInfo: { + name: SERVER_NAME, + version: SERVER_VERSION, + }, + instructions: await buildInstructions(auth.userId), }) - } catch (error) { + + case 'ping': + return jsonRpcResult(id, {}) + + case 'tools/list': return jsonRpcResult(id, { - isError: true, - content: [ - { - type: 'text', - text: error instanceof Error ? error.message : 'Copilot MCP tool call failed', - }, - ], + tools: await listMcpTools(), }) + + case 'tools/call': { + const toolCall = getToolCallParams(request.params) + if (!toolCall) { + return jsonRpcError(id, -32602, 'Invalid tools/call params') + } + if (!getMcpServerToolIds().some((toolName) => toolName === toolCall.name)) { + return jsonRpcError(id, -32601, `Unsupported MCP tool: ${toolCall.name}`) + } + + // Tool-execution failures are MCP tool results (isError), not protocol errors, + // so the agent sees them; both paths shape errors via the shared sanitizer. + try { + const result = await routeExecution(toolCall.name, toolCall.args, { + userId: auth.userId, + accessLevel: 'full', + }) + return jsonRpcResult(id, { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + structuredContent: result, + }) + } catch (error) { + const structuredError = buildCopilotServerToolErrorResponse(toolCall.name, error) + return jsonRpcResult(id, { + isError: true, + content: [{ type: 'text', text: JSON.stringify(structuredError.body, null, 2) }], + structuredContent: structuredError.body, + }) + } } - } - case 'resources/list': - return jsonRpcResult(id, { resources: [] }) + case 'resources/list': + return jsonRpcResult(id, { resources: [] }) - case 'prompts/list': - return jsonRpcResult(id, { prompts: [] }) + case 'prompts/list': + return jsonRpcResult(id, { prompts: [] }) - default: - return jsonRpcError(id, -32601, `Unsupported MCP method: ${request.method}`) + default: + return jsonRpcError(id, -32601, `Unsupported MCP method: ${request.method}`) + } + } catch (error) { + // Any other method (initialize/tools/list/...) that throws is sanitized through + // the same path as Studio instead of leaking a raw Next.js error response. + const structuredError = buildCopilotServerToolErrorResponse(undefined, error) + return jsonRpcError(id, -32603, structuredError.body.error, structuredError.body) } } From 93ee1c8367dbda000aa6986de25cd82e23664c35 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 13:25:48 -0600 Subject: [PATCH 207/284] feat(yjs): read saved entity state through canonical sessions Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/indicators/custom/route.ts | 4 +- .../app/api/knowledge/route.test.ts | 10 +++ apps/tradinggoose/app/api/knowledge/route.ts | 5 +- .../app/api/mcp/servers/[id]/route.ts | 15 +++-- .../tradinggoose/app/api/skills/route.test.ts | 10 +++ apps/tradinggoose/app/api/skills/route.ts | 4 +- .../app/api/tools/custom/route.test.ts | 10 +++ .../app/api/tools/custom/route.ts | 4 +- .../lib/copilot/process-contents.ts | 19 +----- apps/tradinggoose/lib/copilot/registry.ts | 30 ++------- .../lib/copilot/review-sessions/identity.ts | 23 +++++++ .../tools/server/entities/custom-tool.ts | 41 +----------- .../tools/server/entities/indicator.test.ts | 10 +++ .../tools/server/entities/indicator.ts | 14 ++-- .../tools/server/entities/mcp-server.ts | 15 +---- .../copilot/tools/server/entities/shared.ts | 48 ++++++++----- .../copilot/tools/server/entities/skill.ts | 13 +--- .../tools/server/knowledge/knowledge-base.ts | 19 +----- apps/tradinggoose/lib/mcp/service.ts | 67 ++++++++++--------- apps/tradinggoose/lib/yjs/entity-state.ts | 31 +++++++++ .../lib/yjs/server/bootstrap-review-target.ts | 43 +++++++++--- .../tradinggoose/lib/yjs/use-entity-fields.ts | 43 +++++++++--- .../tradinggoose/socket-server/routes/http.ts | 10 +-- .../socket-server/yjs/ws-handler.ts | 20 ++---- 24 files changed, 278 insertions(+), 230 deletions(-) diff --git a/apps/tradinggoose/app/api/indicators/custom/route.ts b/apps/tradinggoose/app/api/indicators/custom/route.ts index 4cf81be3b..88d0c76cd 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -7,6 +7,7 @@ import { createIndicators, saveIndicator } from '@/lib/indicators/custom/operati import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' +import { buildSavedEntityListThroughYjs } from '@/lib/yjs/server/bootstrap-review-target' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { authenticateIndicatorRequest, checkWorkspacePermission } from '../utils' @@ -104,7 +105,8 @@ export async function GET(request: NextRequest) { .from(pineIndicators) .where(eq(pineIndicators.workspaceId, resolvedWorkspaceId)) .orderBy(desc(pineIndicators.createdAt)) - return NextResponse.json({ data: rows }, { status: 200 }) + const data = await buildSavedEntityListThroughYjs('indicator', rows) + return NextResponse.json({ data }, { status: 200 }) } catch (error) { logger.error(`[${requestId}] Error fetching indicators:`, error) return NextResponse.json({ error: 'Failed to fetch indicators' }, { status: 500 }) diff --git a/apps/tradinggoose/app/api/knowledge/route.test.ts b/apps/tradinggoose/app/api/knowledge/route.test.ts index d0d96fbf9..d7fbda3eb 100644 --- a/apps/tradinggoose/app/api/knowledge/route.test.ts +++ b/apps/tradinggoose/app/api/knowledge/route.test.ts @@ -15,6 +15,16 @@ vi.mock('@/lib/knowledge/service', () => ({ getKnowledgeBases: vi.fn(), })) +vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ + buildSavedEntityListThroughYjs: async ( + _kind: string, + rows: Array>, + buildEntry: (row: Record, fields: Record) => unknown = ( + row + ) => row + ) => Promise.all(rows.map((row) => buildEntry(row, row))), +})) + describe('Knowledge Base API Route', () => { const mockAuth$ = mockAuth() let mockCreateKnowledgeBase: any diff --git a/apps/tradinggoose/app/api/knowledge/route.ts b/apps/tradinggoose/app/api/knowledge/route.ts index 96df89822..9208210d1 100644 --- a/apps/tradinggoose/app/api/knowledge/route.ts +++ b/apps/tradinggoose/app/api/knowledge/route.ts @@ -4,6 +4,7 @@ import { getSession } from '@/lib/auth' import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' +import { buildSavedEntityListThroughYjs } from '@/lib/yjs/server/bootstrap-review-target' const logger = createLogger('KnowledgeBaseAPI') @@ -47,9 +48,11 @@ export async function GET(req: NextRequest) { const knowledgeBasesWithCounts = await getKnowledgeBases(session.user.id, workspaceId) + const data = await buildSavedEntityListThroughYjs('knowledge_base', knowledgeBasesWithCounts) + return NextResponse.json({ success: true, - data: knowledgeBasesWithCounts, + data, }) } catch (error) { logger.error(`[${requestId}] Error fetching knowledge bases`, error) diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts index 2115a7500..a96123d36 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts @@ -6,11 +6,11 @@ import { createLogger } from '@/lib/logs/console/logger' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { applySavedEntityState, SavedEntityPersistenceError, } from '@/lib/yjs/server/apply-entity-state' +import { readBootstrappedSavedEntityFields } from '@/lib/yjs/server/bootstrap-review-target' import { UpdateMcpServerSchema } from '../schema' const logger = createLogger('McpServerAPI') @@ -67,6 +67,13 @@ export const PATCH = withMcpAuth('write')( ) } + // Read the current fields through Yjs (live session if open, else bootstrapped + // from DB) so the partial update merges into the canonical state, not a stale row. + const currentFields = await readBootstrappedSavedEntityFields( + 'mcp_server', + serverId, + workspaceId + ) const { workspaceId: _, ...updateData } = body const nextServer = { ...existingServer, @@ -74,11 +81,7 @@ export const PATCH = withMcpAuth('write')( updatedAt: new Date(), } - await applySavedEntityState( - 'mcp_server', - nextServer.id, - savedEntityRowToFields('mcp_server', nextServer) - ) + await applySavedEntityState('mcp_server', serverId, { ...currentFields, ...updateData }) // Clear MCP service cache after update mcpService.clearCache(workspaceId) diff --git a/apps/tradinggoose/app/api/skills/route.test.ts b/apps/tradinggoose/app/api/skills/route.test.ts index bfcf5fa99..277408b9f 100644 --- a/apps/tradinggoose/app/api/skills/route.test.ts +++ b/apps/tradinggoose/app/api/skills/route.test.ts @@ -42,6 +42,16 @@ vi.mock('@tradinggoose/db/schema', () => ({ skill: {}, })) +vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ + buildSavedEntityListThroughYjs: async ( + _kind: string, + rows: Array>, + buildEntry: (row: Record, fields: Record) => unknown = ( + row + ) => row + ) => Promise.all(rows.map((row) => buildEntry(row, row))), +})) + describe('Skills API Routes', () => { beforeEach(() => { vi.resetAllMocks() diff --git a/apps/tradinggoose/app/api/skills/route.ts b/apps/tradinggoose/app/api/skills/route.ts index 36a737d70..77cb1d768 100644 --- a/apps/tradinggoose/app/api/skills/route.ts +++ b/apps/tradinggoose/app/api/skills/route.ts @@ -11,6 +11,7 @@ import { import { createSkills, deleteSkill, listSkills, saveSkill } from '@/lib/skills/operations' import { generateRequestId } from '@/lib/utils' import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' +import { buildSavedEntityListThroughYjs } from '@/lib/yjs/server/bootstrap-review-target' const logger = createLogger('SkillsAPI') @@ -59,7 +60,8 @@ export async function GET(request: NextRequest) { } const result = await listSkills({ workspaceId }) - return NextResponse.json({ data: result }, { status: 200 }) + const data = await buildSavedEntityListThroughYjs('skill', result) + return NextResponse.json({ data }, { status: 200 }) } catch (error) { logger.error(`[${requestId}] Error fetching skills:`, error) return NextResponse.json({ error: 'Failed to fetch skills' }, { status: 500 }) diff --git a/apps/tradinggoose/app/api/tools/custom/route.test.ts b/apps/tradinggoose/app/api/tools/custom/route.test.ts index b1ef59753..c47bb8d28 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.test.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.test.ts @@ -44,6 +44,16 @@ vi.mock('@tradinggoose/db/schema', () => ({ workflow: {}, })) +vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ + buildSavedEntityListThroughYjs: async ( + _kind: string, + rows: Array>, + buildEntry: (row: Record, fields: Record) => unknown = ( + row + ) => row + ) => Promise.all(rows.map((row) => buildEntry(row, row))), +})) + describe('Custom Tools API Routes', () => { beforeEach(() => { vi.resetAllMocks() diff --git a/apps/tradinggoose/app/api/tools/custom/route.ts b/apps/tradinggoose/app/api/tools/custom/route.ts index cf1d6398a..8ef75ae7e 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.ts @@ -10,6 +10,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' +import { buildSavedEntityListThroughYjs } from '@/lib/yjs/server/bootstrap-review-target' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('CustomToolsAPI') @@ -62,7 +63,8 @@ export async function GET(request: NextRequest) { } } - const result = await listCustomTools({ workspaceId: resolvedWorkspaceId }) + const rows = await listCustomTools({ workspaceId: resolvedWorkspaceId }) + const result = await buildSavedEntityListThroughYjs('custom_tool', rows) return NextResponse.json({ data: result }, { status: 200 }) } catch (error) { diff --git a/apps/tradinggoose/lib/copilot/process-contents.ts b/apps/tradinggoose/lib/copilot/process-contents.ts index 93e119c4d..4c8aa0d01 100644 --- a/apps/tradinggoose/lib/copilot/process-contents.ts +++ b/apps/tradinggoose/lib/copilot/process-contents.ts @@ -11,6 +11,7 @@ import { } from '@tradinggoose/db/schema' import { and, asc, eq, isNull } from 'drizzle-orm' import * as Y from 'yjs' +import { buildSavedEntityDescriptor } from '@/lib/copilot/review-sessions/identity' import { verifyReviewTargetAccess, verifyWorkflowAccess, @@ -173,14 +174,7 @@ async function processEntityContext(params: { try { const access = await verifyReviewTargetAccess( params.userId, - { - entityKind: params.entityKind, - entityId: params.entityId, - draftSessionId: null, - reviewSessionId: null, - workspaceId: params.workspaceId, - yjsSessionId: params.entityId, - }, + buildSavedEntityDescriptor(params.entityKind, params.entityId, params.workspaceId), 'read' ) if (!access.hasAccess || !access.workspaceId) { @@ -472,14 +466,7 @@ async function processKnowledgeContext( try { const access = await verifyReviewTargetAccess( userId, - { - entityKind: ENTITY_KIND_KNOWLEDGE_BASE, - entityId: knowledgeBaseId, - draftSessionId: null, - reviewSessionId: null, - workspaceId, - yjsSessionId: knowledgeBaseId, - }, + buildSavedEntityDescriptor(ENTITY_KIND_KNOWLEDGE_BASE, knowledgeBaseId, workspaceId), 'read' ) if (!access.hasAccess || !access.workspaceId) { diff --git a/apps/tradinggoose/lib/copilot/registry.ts b/apps/tradinggoose/lib/copilot/registry.ts index 15189b2ae..a58c5c5d2 100644 --- a/apps/tradinggoose/lib/copilot/registry.ts +++ b/apps/tradinggoose/lib/copilot/registry.ts @@ -712,16 +712,10 @@ const WorkflowVariableDocumentEnvelope = WorkflowTargetEnvelope.extend({ variables: z.record(z.any()), }) +// A list is a discovery surface: only id + canonical name, no per-entity details. const GenericEntityListEntry = z.object({ entityId: z.string(), entityName: z.string().optional(), - workspaceId: z.string().optional(), - entityDescription: z.string().optional(), - entityTitle: z.string().optional(), - entityTransport: z.string().optional(), - entityUrl: z.string().optional(), - entityEnabled: z.boolean().optional(), - entityConnectionStatus: z.string().optional(), }) const GenericEntityListResult = z.object({ @@ -730,24 +724,6 @@ const GenericEntityListResult = z.object({ count: z.number(), }) -const KnowledgeBaseListEntry = z.object({ - entityId: z.string(), - entityName: z.string(), - workspaceId: z.string(), - entityDescription: z.string().optional(), - docCount: z.number(), - tokenCount: z.number(), - embeddingModel: z.string(), - createdAt: z.string().optional(), - updatedAt: z.string().optional(), -}) - -const KnowledgeBaseListResult = z.object({ - entityKind: z.literal('knowledge_base'), - entities: z.array(KnowledgeBaseListEntry), - count: z.number(), -}) - const KnowledgeBaseDocumentEnvelope = z.object({ entityKind: z.literal('knowledge_base'), entityId: z.string(), @@ -1140,7 +1116,9 @@ export const ToolResultSchemas = { chatDeployed: z.boolean(), deployedAt: z.string().nullable(), }), - list_knowledge_bases: KnowledgeBaseListResult, + list_knowledge_bases: GenericEntityListResult.extend({ + entityKind: z.literal('knowledge_base'), + }), read_knowledge_base: KnowledgeBaseDocumentEnvelope, create_knowledge_base: KnowledgeBaseDocumentMutationResult, edit_knowledge_base: KnowledgeBaseDocumentMutationResult, diff --git a/apps/tradinggoose/lib/copilot/review-sessions/identity.ts b/apps/tradinggoose/lib/copilot/review-sessions/identity.ts index 3caf61c82..e042b378b 100644 --- a/apps/tradinggoose/lib/copilot/review-sessions/identity.ts +++ b/apps/tradinggoose/lib/copilot/review-sessions/identity.ts @@ -7,6 +7,7 @@ import { type YjsTransportEnvelope, } from './types' import { normalizeOptionalString } from '@/lib/utils' +import type { SavedEntityKind } from '@/lib/yjs/entity-state' const REVIEW_ENTITY_KIND_SET = new Set(REVIEW_ENTITY_KINDS) const YJS_TARGET_KIND_SET = new Set(YJS_TARGET_KINDS) @@ -31,6 +32,28 @@ const requireYjsTargetKind = (value: string | undefined): YjsTargetKind => { return normalized as YjsTargetKind } +/** + * Canonical descriptor for a saved entity's own Yjs document (no draft/review + * session; the Yjs session id is the entity id). The single source of the + * "saved entity Yjs target" contract, reused by the editor session hook, the + * server-side field reader, the access check, and the apply route, so every + * read/write addresses the entity identically. + */ +export function buildSavedEntityDescriptor( + entityKind: SavedEntityKind, + entityId: string, + workspaceId: string | null +): ReviewTargetDescriptor { + return { + workspaceId, + entityKind, + entityId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: entityId, + } +} + /** * Builds a YjsTransportEnvelope from a ReviewTargetDescriptor. */ diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts index 707517a53..328e95f29 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts @@ -5,8 +5,8 @@ import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { buildDocumentEnvelope, + buildSavedEntityListInfo, type EntityCreateResult, - type EntityListEntry, type EntityServerTool, executeCreateEntityDocumentMutation, executeUpdateEntityDocumentMutation, @@ -16,43 +16,6 @@ import { verifyWorkspaceContext, } from './shared' -function readFunctionSchemaField(row: Awaited>[number]) { - const functionSchema = - row.schema && typeof row.schema === 'object' && 'function' in row.schema - ? row.schema.function - : null - - if (!functionSchema || typeof functionSchema !== 'object') { - return {} - } - - return { - functionName: - 'name' in functionSchema && typeof functionSchema.name === 'string' - ? functionSchema.name - : undefined, - functionDescription: - 'description' in functionSchema && typeof functionSchema.description === 'string' - ? functionSchema.description - : undefined, - } -} - -function toCustomToolListEntry( - row: Awaited>[number] -): EntityListEntry { - const { functionName, functionDescription } = readFunctionSchemaField(row) - - return { - entityId: row.id, - entityName: row.title ?? functionName ?? '', - workspaceId: row.workspaceId, - entityTitle: row.title ?? '', - entityFunctionName: functionName, - entityDescription: functionDescription, - } -} - async function createCustomToolEntity( fields: Record, context: Parameters[0] @@ -88,7 +51,7 @@ export const listCustomToolsServerTool: EntityServerTool> 'read' ) const rows = await listCustomTools({ workspaceId }) - const entities = rows.map(toCustomToolListEntry) + const entities = await buildSavedEntityListInfo(ENTITY_KIND_CUSTOM_TOOL, rows) return { entityKind: ENTITY_KIND_CUSTOM_TOOL, diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts index 592f2347f..28aaa8780 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts @@ -31,6 +31,16 @@ vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ readBootstrappedSavedEntityFields: (...args: unknown[]) => mockReadBootstrappedSavedEntityFields(...args), + buildSavedEntityListThroughYjs: async ( + kind: string, + rows: Array<{ id: string; workspaceId: string }>, + buildEntry: (row: unknown, fields: unknown) => unknown + ) => + Promise.all( + rows.map(async (row) => + buildEntry(row, await mockReadBootstrappedSavedEntityFields(kind, row.id, row.workspaceId)) + ) + ), })) describe('indicator server tools', () => { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts index dfe3c19c7..fb7438cd1 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts @@ -12,6 +12,7 @@ import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { buildDocumentEnvelope, + buildSavedEntityListThroughYjs, type CopilotIndicatorListEntry, type EntityCreateResult, type EntityServerTool, @@ -38,13 +39,14 @@ function toDefaultIndicatorListEntry(entry: (typeof DEFAULT_INDICATOR_RUNTIME_EN } function toCustomIndicatorListEntry( - row: typeof pineIndicators.$inferSelect + row: typeof pineIndicators.$inferSelect, + fields: Record ): CopilotIndicatorListEntry { - const inputMeta = normalizeInputMetaMap(row.inputMeta) + const inputMeta = normalizeInputMetaMap(fields.inputMeta) const inputTitles = Object.keys(inputMeta ?? {}) return { - name: row.name, + name: String(fields.name ?? ''), source: 'custom', editable: true, callableInFunctionBlock: true, @@ -61,7 +63,11 @@ async function listCopilotIndicators(workspaceId: string): Promise a.name.localeCompare(b.name)) } diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts index e40e1eb1f..d184cbee5 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -14,8 +14,8 @@ import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' import { buildDocumentEnvelope, + buildSavedEntityListInfo, type EntityCreateResult, - type EntityListEntry, type EntityServerTool, executeCreateEntityDocumentMutation, executeUpdateEntityDocumentMutation, @@ -92,17 +92,6 @@ function prepareNewMcpServerFields(fields: Record): Record, context: Parameters[0] @@ -175,7 +164,7 @@ export const listMcpServersServerTool: EntityServerTool> = .select() .from(mcpServers) .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) - const entities = rows.map(toMcpServerListEntry) + const entities = await buildSavedEntityListInfo(ENTITY_KIND_MCP_SERVER, rows) return { entityKind: ENTITY_KIND_MCP_SERVER, diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index 4572333f6..faba2e276 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -5,6 +5,7 @@ import { parseEntityDocument, serializeEntityDocument, } from '@/lib/copilot/entity-documents' +import { buildSavedEntityDescriptor } from '@/lib/copilot/review-sessions/identity' import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' import type { BaseServerTool, @@ -18,8 +19,11 @@ import { } from '@/lib/copilot/tools/server/base-tool' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import type { SavedEntityKind } from '@/lib/yjs/entity-state' -import { readBootstrappedSavedEntityFields } from '@/lib/yjs/server/bootstrap-review-target' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { + buildSavedEntityListThroughYjs, + readBootstrappedSavedEntityFields, +} from '@/lib/yjs/server/bootstrap-review-target' export type SavedEntityDocumentKind = EntityDocumentKind export type EntityDocumentArgs = { @@ -30,17 +34,13 @@ export type EntityDocumentArgs = { documentFormat?: string } +/** + * Canonical list_* entry. A list is a discovery surface — "what exists" — so it + * carries only the entity's id and canonical name, never per-entity details. + */ export type EntityListEntry = { entityId: string - entityName?: string - workspaceId?: string - entityDescription?: string - entityTitle?: string - entityFunctionName?: string - entityTransport?: string - entityUrl?: string - entityEnabled?: boolean - entityConnectionStatus?: string + entityName: string } export type CopilotIndicatorListEntry = { @@ -123,14 +123,7 @@ export async function verifySavedEntityContext( const userId = requireUserId(context) const access = await verifyReviewTargetAccess( userId, - { - workspaceId: null, - entityKind, - entityId, - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: entityId, - }, + buildSavedEntityDescriptor(entityKind, entityId, null), accessMode ) @@ -203,6 +196,25 @@ export async function readSavedEntityDocumentFields( return readBootstrappedSavedEntityFields(kind as SavedEntityKind, entityId, workspaceId) } +// The canonical list-through-Yjs primitive lives in the Yjs read layer so widget +// API routes and the MCP service share it too; re-exported here for the tools. +export { buildSavedEntityListThroughYjs } + +/** + * Canonical projection for every saved-entity list_* tool: read each row through + * Yjs and expose only the discovery info (id + canonical name). One projection + * for all kinds — no tool invents its own list mapper. + */ +export function buildSavedEntityListInfo( + entityKind: SavedEntityKind, + rows: TRow[] +): Promise { + return buildSavedEntityListThroughYjs(entityKind, rows, (row, fields) => ({ + entityId: row.id, + entityName: getEntityDocumentName(entityKind, fields), + })) +} + export async function executeCreateEntityDocumentMutation( kind: SavedEntityDocumentKind, args: EntityDocumentArgs, diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts index 42e7ea028..e311fd320 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts @@ -4,8 +4,8 @@ import { createSkills, listSkills } from '@/lib/skills/operations' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { buildDocumentEnvelope, + buildSavedEntityListInfo, type EntityCreateResult, - type EntityListEntry, type EntityServerTool, executeCreateEntityDocumentMutation, executeUpdateEntityDocumentMutation, @@ -15,15 +15,6 @@ import { verifyWorkspaceContext, } from './shared' -function toSkillListEntry(row: Awaited>[number]): EntityListEntry { - return { - entityId: row.id, - entityName: row.name, - workspaceId: row.workspaceId, - entityDescription: row.description ?? '', - } -} - async function createSkillEntity( fields: Record, context: Parameters[0] @@ -59,7 +50,7 @@ export const listSkillsServerTool: EntityServerTool> = { 'read' ) const rows = await listSkills({ workspaceId }) - const entities = rows.map(toSkillListEntry) + const entities = await buildSavedEntityListInfo(ENTITY_KIND_SKILL, rows) return { entityKind: ENTITY_KIND_SKILL, diff --git a/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts index e6b18082b..c56c831a7 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -16,6 +16,7 @@ import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { getQueryStrategy, handleVectorOnlySearch } from '@/app/api/knowledge/search/utils' import { buildDocumentEnvelope, + buildSavedEntityListInfo, type EntityCreateResult, type EntityDocumentArgs, type EntityServerTool, @@ -29,11 +30,6 @@ import { const logger = createLogger('KnowledgeBaseServerTools') -function normalizeOptionalString(value: string | null | undefined): string | undefined { - const normalized = value?.trim() - return normalized ? normalized : undefined -} - function toIsoString(value: Date | string | null | undefined): string | undefined { if (!value) return undefined if (typeof value === 'string') return value @@ -95,20 +91,11 @@ export const listKnowledgeBasesServerTool: BaseServerTool<{ workspaceId: string const scopedContext = withWorkspaceArgContext(context, args) const { userId, workspaceId } = await verifyWorkspaceContext(scopedContext, 'read') const knowledgeBases = await getKnowledgeBases(userId, workspaceId) + const entities = await buildSavedEntityListInfo(ENTITY_KIND_KNOWLEDGE_BASE, knowledgeBases) return { entityKind: ENTITY_KIND_KNOWLEDGE_BASE, - entities: knowledgeBases.map((kb) => ({ - entityId: kb.id, - entityName: kb.name, - workspaceId: kb.workspaceId, - ...(normalizeOptionalString(kb.description) ? { entityDescription: kb.description! } : {}), - docCount: kb.docCount, - tokenCount: kb.tokenCount, - embeddingModel: kb.embeddingModel, - createdAt: toIsoString(kb.createdAt), - updatedAt: toIsoString(kb.updatedAt), - })), + entities, count: knowledgeBases.length, } }, diff --git a/apps/tradinggoose/lib/mcp/service.ts b/apps/tradinggoose/lib/mcp/service.ts index 57309bbdc..86c67696a 100644 --- a/apps/tradinggoose/lib/mcp/service.ts +++ b/apps/tradinggoose/lib/mcp/service.ts @@ -20,7 +20,10 @@ import type { } from '@/lib/mcp/types' import { MCP_CONSTANTS } from '@/lib/mcp/utils' import { generateRequestId } from '@/lib/utils' -import { readBootstrappedSavedEntityFields } from '@/lib/yjs/server/bootstrap-review-target' +import { + buildSavedEntityListThroughYjs, + readBootstrappedSavedEntityFields, +} from '@/lib/yjs/server/bootstrap-review-target' const logger = createLogger('McpService') type McpServerRow = InferSelectModel @@ -269,35 +272,32 @@ class McpService { async listWorkspaceServers(workspaceId: string): Promise { const servers = await this.getWorkspaceServerRows(workspaceId) - return Promise.all( - servers.map(async (server) => { - const fields = await this.readServerFields(server) - try { - const normalized = normalizeEntityFields('mcp_server', fields) - return { - ...server, - name: String(normalized.name ?? ''), - description: String(normalized.description ?? '') || null, - transport: String(normalized.transport ?? ''), - url: String(normalized.url ?? '') || null, - headers: normalized.headers, - command: String(normalized.command ?? '') || null, - args: Array.isArray(normalized.args) ? normalized.args.map(String) : [], - env: normalized.env, - timeout: Number(normalized.timeout ?? 30000), - retries: Number(normalized.retries ?? 3), - enabled: normalized.enabled !== false, - } - } catch (error) { - logger.warn(`MCP server ${server.id} has invalid saved-entity state:`, error) - return { - ...server, - connectionStatus: 'error', - lastError: error instanceof Error ? error.message : 'Invalid server state', - } + return buildSavedEntityListThroughYjs('mcp_server', servers, (server, fields) => { + try { + const normalized = normalizeEntityFields('mcp_server', fields) + return { + ...server, + name: String(normalized.name ?? ''), + description: String(normalized.description ?? '') || null, + transport: String(normalized.transport ?? ''), + url: String(normalized.url ?? '') || null, + headers: normalized.headers, + command: String(normalized.command ?? '') || null, + args: Array.isArray(normalized.args) ? normalized.args.map(String) : [], + env: normalized.env, + timeout: Number(normalized.timeout ?? 30000), + retries: Number(normalized.retries ?? 3), + enabled: normalized.enabled !== false, } - }) - ) + } catch (error) { + logger.warn(`MCP server ${server.id} has invalid saved-entity state:`, error) + return { + ...server, + connectionStatus: 'error', + lastError: error instanceof Error ? error.message : 'Invalid server state', + } + } + }) } private async getServerConfig( @@ -326,9 +326,10 @@ class McpService { private async getWorkspaceServers(workspaceId: string): Promise { const servers = await this.getWorkspaceServerRows(workspaceId) - const configs = await Promise.all( - servers.map(async (server) => { - const fields = await this.readServerFields(server) + const configs = await buildSavedEntityListThroughYjs( + 'mcp_server', + servers, + (server, fields) => { try { const normalized = normalizeEntityFields('mcp_server', fields) return normalized.enabled === false ? null : this.toServerConfig(server, normalized) @@ -336,7 +337,7 @@ class McpService { logger.warn(`Skipping MCP server ${server.id} with invalid saved-entity state:`, error) return null } - }) + } ) return configs.filter((config): config is McpServerConfig => config !== null) diff --git a/apps/tradinggoose/lib/yjs/entity-state.ts b/apps/tradinggoose/lib/yjs/entity-state.ts index 0d7222413..d903beff3 100644 --- a/apps/tradinggoose/lib/yjs/entity-state.ts +++ b/apps/tradinggoose/lib/yjs/entity-state.ts @@ -61,3 +61,34 @@ export function savedEntityRowToFields( } } } + +function parseEntitySchemaText(schemaText: unknown): unknown { + if (typeof schemaText !== 'string') { + return schemaText ?? {} + } + try { + return JSON.parse(schemaText) + } catch { + return {} + } +} + +/** + * Canonical inverse of {@link savedEntityRowToFields}. The Yjs document field + * names mirror the entity's editable row columns 1:1 for every kind, so the + * inverse is identity — except custom_tool, whose schema/code are stored as + * editable text (schemaText/codeText) and parsed back to row columns here. + */ +export function savedEntityFieldsToRow( + entityKind: SavedEntityKind, + fields: Record +): Record { + if (entityKind === 'custom_tool') { + return { + title: fields.title, + schema: parseEntitySchemaText(fields.schemaText), + code: fields.codeText, + } + } + return fields +} diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index c03dd3656..2801ab146 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -1,5 +1,6 @@ import * as Y from 'yjs' import { + buildSavedEntityDescriptor, buildYjsTransportEnvelope, serializeYjsTransportEnvelope, } from '@/lib/copilot/review-sessions/identity' @@ -11,7 +12,7 @@ import type { } from '@/lib/copilot/review-sessions/types' import { loadWorkflowBootstrapStateFromDb } from '@/lib/workflows/db-helpers' import { getEntityFields, seedEntitySession } from '@/lib/yjs/entity-session' -import type { SavedEntityKind } from '@/lib/yjs/entity-state' +import { type SavedEntityKind, savedEntityFieldsToRow } from '@/lib/yjs/entity-state' import { readSavedEntityFieldsFromDb, resolveEntityWorkspaceId, @@ -59,14 +60,9 @@ export async function readBootstrappedSavedEntityFields( entityId: string, workspaceId: string ): Promise> { - const snapshot = await readBootstrappedReviewTargetSnapshot({ - workspaceId, - entityKind, - entityId, - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: entityId, - }) + const snapshot = await readBootstrappedReviewTargetSnapshot( + buildSavedEntityDescriptor(entityKind, entityId, workspaceId) + ) if (!snapshot.snapshotBase64) { throw new ReviewTargetBootstrapError(404, `Saved ${entityKind} ${entityId} state is missing`) } @@ -80,6 +76,35 @@ export async function readBootstrappedSavedEntityFields( } } +/** + * Canonical "list existing saved entities through Yjs" primitive. Reads each + * row's fields via {@link readBootstrappedSavedEntityFields} (live session if + * open, else a transient DB bootstrap) concurrently, and maps each through a + * pure row+fields → entry function. Every list surface (copilot tools, widget + * API routes, the MCP service) reuses this instead of re-implementing the loop. + * + * `buildEntry` defaults to overlaying the live fields onto the row via the + * canonical {@link savedEntityFieldsToRow} inverse, so the result is the same + * row shape with its fields taken from Yjs — the natural output for the widget + * list routes. Callers whose output shape differs (the copilot summaries, the + * MCP config) pass their own row→entry projection. + */ +export function buildSavedEntityListThroughYjs< + TRow extends { id: string; workspaceId: string }, + TEntry = TRow, +>( + entityKind: SavedEntityKind, + rows: TRow[], + buildEntry: (row: TRow, fields: Record) => TEntry = (row, fields) => + ({ ...row, ...savedEntityFieldsToRow(entityKind, fields) }) as TEntry +): Promise { + return Promise.all( + rows.map(async (row) => + buildEntry(row, await readBootstrappedSavedEntityFields(entityKind, row.id, row.workspaceId)) + ) + ) +} + export async function createSavedReviewTargetBootstrapUpdate( descriptor: ReviewTargetDescriptor ): Promise { diff --git a/apps/tradinggoose/lib/yjs/use-entity-fields.ts b/apps/tradinggoose/lib/yjs/use-entity-fields.ts index e17bb14f0..35681a36b 100644 --- a/apps/tradinggoose/lib/yjs/use-entity-fields.ts +++ b/apps/tradinggoose/lib/yjs/use-entity-fields.ts @@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import * as Y from 'yjs' import { + buildSavedEntityDescriptor, buildYjsTransportEnvelope, serializeYjsTransportEnvelope, } from '@/lib/copilot/review-sessions/identity' @@ -49,14 +50,7 @@ export function useSavedEntityYjsSession( let active = true let current: YjsProviderBootstrapResult | null = null - bootstrapYjsProvider({ - workspaceId, - entityKind, - entityId, - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: entityId, - }) + bootstrapYjsProvider(buildSavedEntityDescriptor(entityKind, entityId, workspaceId)) .then((next) => { if (!active) { next.provider.disconnect() @@ -141,9 +135,36 @@ export function useYjsStringField( if (!doc) return (cb: () => void) => () => {} const fields = getFieldsMap(doc) return (cb: () => void) => { - const handler = () => cb() - fields.observeDeep(handler) - return () => fields.unobserveDeep(handler) + // Track the Y.Text currently bound to `key` so we can observe in-place + // text edits directly and re-bind when the key's value is replaced. + let boundText: Y.Text | null = null + + const textHandler = () => cb() + + const bindText = (next: Y.Text | null) => { + if (next === boundText) return + if (boundText) boundText.unobserve(textHandler) + boundText = next + if (boundText) boundText.observe(textHandler) + } + + const mapHandler = (event: Y.YMapEvent) => { + if (!event.keysChanged.has(key)) return + // The value at `key` was set/added/deleted: re-bind a Y.Text observer + // (or clear it for plain-string values) and notify subscribers. + const val = fields.get(key) + bindText(val instanceof Y.Text ? val : null) + cb() + } + + fields.observe(mapHandler) + const initial = fields.get(key) + bindText(initial instanceof Y.Text ? initial : null) + + return () => { + fields.unobserve(mapHandler) + if (boundText) boundText.unobserve(textHandler) + } } }, [doc, key]) diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index c75f55900..b84facc9d 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'http' import * as Y from 'yjs' import { buildReviewTargetDescriptorFromEnvelope, + buildSavedEntityDescriptor, parseYjsTransportEnvelope, } from '@/lib/copilot/review-sessions/identity' import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' @@ -350,14 +351,7 @@ async function handleInternalYjsEntityApplyRequest( ): Promise { try { const body = parseApplyEntityStateRequest(await readJsonBody(req)) - const descriptor = { - workspaceId: null, - entityKind: body.entityKind, - entityId, - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: entityId, - } as const + const descriptor = buildSavedEntityDescriptor(body.entityKind, entityId, null) const doc = await getBootstrappedApplyDocument(descriptor) await applyThroughStaging( doc, diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.ts index a89abfb04..652e52d67 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.ts @@ -6,8 +6,6 @@ import { buildReviewTargetDescriptorFromEnvelope } from '@/lib/copilot/review-se import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' import { createLogger } from '@/lib/logs/console/logger' import { saveWorkflowYjsDocToDb } from '@/lib/workflows/db-helpers' -import type { SavedEntityKind } from '@/lib/yjs/entity-state' -import { saveSavedEntityYjsDocToDb } from '@/lib/yjs/server/apply-entity-state' import { createSavedReviewTargetBootstrapUpdate, getRuntimeStateFromDoc, @@ -16,13 +14,6 @@ import { authenticateYjsConnection, YjsAuthError } from './auth' import { getExistingDocument, markDocumentPersisted, setupWSConnection } from './upstream-utils' const logger = createLogger('YjsWsHandler') -const savedEntityKinds = new Set([ - 'skill', - 'custom_tool', - 'indicator', - 'knowledge_base', - 'mcp_server', -]) interface YjsIncomingMessage extends IncomingMessage { yjsSessionId?: string @@ -32,7 +23,6 @@ interface YjsIncomingMessage extends IncomingMessage { async function persistIdleDocument(docId: string, doc: Y.Doc): Promise { const metadata = doc.getMap('metadata') - const entityKind = metadata.get('entityKind') if ( metadata.get('entityId') !== docId || metadata.get('draftSessionId') !== null || @@ -41,14 +31,12 @@ async function persistIdleDocument(docId: string, doc: Y.Doc): Promise { return } - if (entityKind === 'workflow') { + // Only workflows auto-save on idle. Saved entities (skill, custom_tool, + // indicator, knowledge_base, mcp_server) persist ONLY via the explicit Save + // path or the Copilot apply path; their dirty docs are discarded on idle. + if (metadata.get('entityKind') === 'workflow') { await saveWorkflowYjsDocToDb(docId, doc) markDocumentPersisted(doc) - return - } - if (typeof entityKind === 'string' && savedEntityKinds.has(entityKind as SavedEntityKind)) { - await saveSavedEntityYjsDocToDb(entityKind as SavedEntityKind, docId, doc) - markDocumentPersisted(doc) } } From 359c222af493f72858a8e0b0723706c190e74b1c Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 14:07:43 -0600 Subject: [PATCH 208/284] fix(yjs): skip invalid saved entity list rows Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../server/bootstrap-review-target.test.ts | 80 +++++++++++++++++++ .../lib/yjs/server/bootstrap-review-target.ts | 44 ++++++---- .../lib/yjs/server/entity-loaders.ts | 11 ++- 3 files changed, 117 insertions(+), 18 deletions(-) create mode 100644 apps/tradinggoose/lib/yjs/server/bootstrap-review-target.test.ts diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.test.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.test.ts new file mode 100644 index 000000000..ad551ae3b --- /dev/null +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' + +const mocks = vi.hoisted(() => { + class SocketServerBridgeError extends Error { + constructor( + public status: number, + public body: string + ) { + super(`Bridge failed: ${status}`) + } + } + + return { getEntityFields: vi.fn(), getYjsSnapshot: vi.fn(), SocketServerBridgeError } +}) + +vi.mock('@/lib/workflows/db-helpers', () => ({ loadWorkflowBootstrapStateFromDb: vi.fn() })) +vi.mock('@/lib/yjs/entity-session', () => ({ + getEntityFields: (...args: unknown[]) => mocks.getEntityFields(...args), + seedEntitySession: vi.fn(), +})) +vi.mock('@/lib/yjs/server/entity-loaders', () => ({ + readSavedEntityFieldsFromDb: vi.fn(), + resolveEntityWorkspaceId: vi.fn(), +})) +vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ + getYjsSnapshot: (...args: unknown[]) => mocks.getYjsSnapshot(...args), + SocketServerBridgeError: mocks.SocketServerBridgeError, +})) + +const snapshot = () => { + const doc = new Y.Doc() + const snapshotBase64 = Buffer.from(Y.encodeStateAsUpdate(doc)).toString('base64') + doc.destroy() + return { snapshotBase64 } +} + +describe('buildSavedEntityListThroughYjs', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getEntityFields.mockReturnValue({ name: 'live' }) + mocks.getYjsSnapshot.mockResolvedValue(snapshot()) + }) + + it('skips row-local missing snapshots and invalid projections', async () => { + mocks.getYjsSnapshot + .mockRejectedValueOnce(new mocks.SocketServerBridgeError(404, 'missing')) + .mockResolvedValueOnce(snapshot()) + .mockResolvedValueOnce(snapshot()) + + const { buildSavedEntityListThroughYjs } = await import('./bootstrap-review-target') + const result = await buildSavedEntityListThroughYjs( + 'skill', + [ + { id: 'missing', workspaceId: 'workspace-1' }, + { id: 'invalid', workspaceId: 'workspace-1' }, + { id: 'live', workspaceId: 'workspace-1' }, + ], + (row, fields) => { + if (row.id === 'invalid') throw new Error('invalid fields') + return { id: row.id, name: fields.name } + } + ) + + expect(result).toEqual([{ id: 'live', name: 'live' }]) + }) + + it('keeps realtime bridge outages as list-level failures', async () => { + mocks.getYjsSnapshot.mockRejectedValue(new mocks.SocketServerBridgeError(500, 'down')) + + const { buildSavedEntityListThroughYjs } = await import('./bootstrap-review-target') + + await expect( + buildSavedEntityListThroughYjs('skill', [{ id: 'skill-1', workspaceId: 'workspace-1' }]) + ).rejects.toThrow('Bridge failed: 500') + }) +}) diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 2801ab146..ab6129511 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -17,7 +17,7 @@ import { readSavedEntityFieldsFromDb, resolveEntityWorkspaceId, } from '@/lib/yjs/server/entity-loaders' -import { getYjsSnapshot } from '@/lib/yjs/server/snapshot-bridge' +import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' import { createWorkflowSnapshot, @@ -77,19 +77,11 @@ export async function readBootstrappedSavedEntityFields( } /** - * Canonical "list existing saved entities through Yjs" primitive. Reads each - * row's fields via {@link readBootstrappedSavedEntityFields} (live session if - * open, else a transient DB bootstrap) concurrently, and maps each through a - * pure row+fields → entry function. Every list surface (copilot tools, widget - * API routes, the MCP service) reuses this instead of re-implementing the loop. - * - * `buildEntry` defaults to overlaying the live fields onto the row via the - * canonical {@link savedEntityFieldsToRow} inverse, so the result is the same - * row shape with its fields taken from Yjs — the natural output for the widget - * list routes. Callers whose output shape differs (the copilot summaries, the - * MCP config) pass their own row→entry projection. + * Canonical list-through-Yjs primitive for existing saved entities. Row-local + * missing/expired snapshots and invalid projections are skipped; realtime + * bridge failures still fail the list because saved-entity reads require Yjs. */ -export function buildSavedEntityListThroughYjs< +export async function buildSavedEntityListThroughYjs< TRow extends { id: string; workspaceId: string }, TEntry = TRow, >( @@ -98,11 +90,29 @@ export function buildSavedEntityListThroughYjs< buildEntry: (row: TRow, fields: Record) => TEntry = (row, fields) => ({ ...row, ...savedEntityFieldsToRow(entityKind, fields) }) as TEntry ): Promise { - return Promise.all( - rows.map(async (row) => - buildEntry(row, await readBootstrappedSavedEntityFields(entityKind, row.id, row.workspaceId)) - ) + const entries: Array = await Promise.all( + rows.map(async (row): Promise => { + let fields: Record + try { + fields = await readBootstrappedSavedEntityFields(entityKind, row.id, row.workspaceId) + } catch (error) { + const status = + error instanceof ReviewTargetBootstrapError || error instanceof SocketServerBridgeError + ? error.status + : null + if (status === 404 || status === 410) return null + throw error + } + + try { + return buildEntry(row, fields) + } catch { + return null + } + }) ) + + return entries.filter((entry): entry is TEntry => entry !== null) } export async function createSavedReviewTargetBootstrapUpdate( diff --git a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts index 7861c94fa..e68c6113f 100644 --- a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts +++ b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts @@ -29,6 +29,15 @@ function entityIdCondition(entityKind: SavedEntityKind, entityId: string) { : byId } +class SavedEntityLoadError extends Error { + status = 404 + + constructor(message: string) { + super(message) + this.name = 'SavedEntityLoadError' + } +} + export async function resolveEntityWorkspaceId( entityKind: SavedEntityKind, entityId: string @@ -100,7 +109,7 @@ export async function readSavedEntityFieldsFromDb( } if (!row) { - throw new Error(`Saved ${entityKind} ${entityId} was not found`) + throw new SavedEntityLoadError(`Saved ${entityKind} ${entityId} was not found`) } return savedEntityRowToFields(entityKind, row) From e2cdf7eed3a4e3228ca3b278c8a15fece087d139 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 17:20:49 -0600 Subject: [PATCH 209/284] feat(yjs): add entity-list bootstrap sessions Introduce Yjs-backed entity list sessions, bootstrap them from canonical rows, and update MCP/widget consumers. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/indicators/custom/route.ts | 65 +++------ .../app/api/indicators/options/route.test.ts | 33 +---- .../app/api/indicators/options/route.ts | 16 +-- .../app/api/knowledge/route.test.ts | 10 -- apps/tradinggoose/app/api/knowledge/route.ts | 7 +- .../app/api/mcp/servers/[id]/refresh/route.ts | 38 +---- .../app/api/mcp/servers/[id]/route.ts | 41 ++---- .../tradinggoose/app/api/mcp/servers/route.ts | 10 +- .../tradinggoose/app/api/skills/route.test.ts | 10 -- apps/tradinggoose/app/api/skills/route.ts | 5 +- .../app/api/tools/custom/route.test.ts | 12 +- .../app/api/tools/custom/route.ts | 47 ++----- .../copilot/review-sessions/identity.test.ts | 42 ++++++ .../lib/copilot/review-sessions/identity.ts | 105 +++++++------- .../copilot/review-sessions/permissions.ts | 16 ++- .../lib/copilot/review-sessions/types.ts | 3 +- .../tools/server/entities/custom-tool.ts | 5 +- .../tools/server/entities/indicator.test.ts | 40 +----- .../tools/server/entities/indicator.ts | 42 ++---- .../tools/server/entities/mcp-server.ts | 11 +- .../copilot/tools/server/entities/shared.ts | 21 +-- .../copilot/tools/server/entities/skill.ts | 5 +- .../tools/server/knowledge/knowledge-base.ts | 13 +- .../lib/custom-tools/operations.ts | 35 ++++- .../lib/indicators/custom/operations.ts | 51 +++++-- apps/tradinggoose/lib/knowledge/service.ts | 68 ++++----- apps/tradinggoose/lib/mcp/service.ts | 131 +++++++----------- .../lib/skills/operations.test.ts | 4 + apps/tradinggoose/lib/skills/operations.ts | 40 ++++-- apps/tradinggoose/lib/yjs/entity-session.ts | 64 +++++++++ apps/tradinggoose/lib/yjs/entity-state.ts | 36 +---- apps/tradinggoose/lib/yjs/provider.ts | 10 +- .../lib/yjs/server/apply-entity-state.test.ts | 2 + .../lib/yjs/server/apply-entity-state.ts | 10 +- .../server/bootstrap-review-target.test.ts | 80 ----------- .../lib/yjs/server/bootstrap-review-target.ts | 109 +++++++++------ .../lib/yjs/server/entity-loaders.ts | 108 ++++++--------- .../lib/yjs/server/snapshot-bridge.ts | 48 +++++++ .../lib/yjs/workflow-shared-session.test.ts | 10 +- .../lib/yjs/workflow-shared-session.ts | 4 +- apps/tradinggoose/socket-server/index.test.ts | 4 +- .../tradinggoose/socket-server/routes/http.ts | 41 ++++-- .../socket-server/yjs/auth.test.ts | 2 +- .../socket-server/yjs/ws-handler.test.ts | 9 +- .../socket-server/yjs/ws-handler.ts | 38 +++-- .../widgets/list_custom_tool/index.tsx | 9 +- .../components/indicator-create-menu.tsx | 5 +- .../components/indicator-list-item.tsx | 5 +- .../widgets/list_indicator/index.test.tsx | 10 +- .../widgets/widgets/list_indicator/index.tsx | 8 +- .../widgets/widgets/list_mcp/index.tsx | 2 +- .../components/skill-create-menu.tsx | 5 +- .../components/skill-list/skill-list.tsx | 3 +- .../widgets/widgets/list_skill/index.tsx | 3 +- 54 files changed, 734 insertions(+), 817 deletions(-) delete mode 100644 apps/tradinggoose/lib/yjs/server/bootstrap-review-target.test.ts diff --git a/apps/tradinggoose/app/api/indicators/custom/route.ts b/apps/tradinggoose/app/api/indicators/custom/route.ts index 88d0c76cd..84f399637 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -1,14 +1,16 @@ import { db } from '@tradinggoose/db' -import { pineIndicators, workflow } from '@tradinggoose/db/schema' -import { and, desc, eq } from 'drizzle-orm' +import { pineIndicators } from '@tradinggoose/db/schema' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { createIndicators, saveIndicator } from '@/lib/indicators/custom/operations' +import { createIndicators, listIndicators, saveIndicator } from '@/lib/indicators/custom/operations' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' -import { buildSavedEntityListThroughYjs } from '@/lib/yjs/server/bootstrap-review-target' -import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { + deleteYjsSessionInSocketServer, + notifyEntityListMemberRemoved, +} from '@/lib/yjs/server/snapshot-bridge' import { authenticateIndicatorRequest, checkWorkspacePermission } from '../utils' const logger = createLogger('IndicatorsAPI') @@ -48,7 +50,6 @@ export async function GET(request: NextRequest) { const requestId = generateRequestId() const searchParams = request.nextUrl.searchParams const workspaceId = searchParams.get('workspaceId') - const workflowId = searchParams.get('workflowId') try { const auth = await authenticateIndicatorRequest({ @@ -61,52 +62,27 @@ export async function GET(request: NextRequest) { if ('response' in auth) return auth.response const userId = auth.userId - let resolvedWorkspaceId: string | null = workspaceId - - if (!resolvedWorkspaceId && workflowId) { - const [workflowData] = await db - .select({ workspaceId: workflow.workspaceId }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (!workflowData?.workspaceId) { - logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - resolvedWorkspaceId = workflowData.workspaceId - } - - if (!resolvedWorkspaceId) { + if (!workspaceId) { logger.warn(`[${requestId}] Missing workspaceId for indicators fetch`) return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) } - if (!(auth.authType === 'internal_jwt' && workflowId)) { - const permissionCheck = await checkWorkspacePermission({ + const permissionCheck = await checkWorkspacePermission({ + userId, + workspaceId, + responseShape: 'errorOnly', + }) + if (!permissionCheck.ok) { + logWorkspacePermissionDenied({ + requestId, userId, - workspaceId: resolvedWorkspaceId, - responseShape: 'errorOnly', + workspaceId, + code: permissionCheck.code, }) - if (!permissionCheck.ok) { - logWorkspacePermissionDenied({ - requestId, - userId, - workspaceId: resolvedWorkspaceId, - code: permissionCheck.code, - }) - return permissionCheck.response - } + return permissionCheck.response } - const rows = await db - .select() - .from(pineIndicators) - .where(eq(pineIndicators.workspaceId, resolvedWorkspaceId)) - .orderBy(desc(pineIndicators.createdAt)) - const data = await buildSavedEntityListThroughYjs('indicator', rows) - return NextResponse.json({ data }, { status: 200 }) + return NextResponse.json({ data: await listIndicators({ workspaceId }) }, { status: 200 }) } catch (error) { logger.error(`[${requestId}] Error fetching indicators:`, error) return NextResponse.json({ error: 'Failed to fetch indicators' }, { status: 500 }) @@ -269,6 +245,7 @@ export async function DELETE(request: NextRequest) { .delete(pineIndicators) .where(and(eq(pineIndicators.id, indicatorId), eq(pineIndicators.workspaceId, workspaceId))) await deleteYjsSessionInSocketServer(indicatorId).catch(() => undefined) + await notifyEntityListMemberRemoved('indicator', workspaceId, indicatorId) logger.info(`[${requestId}] Deleted indicator ${indicatorId}`) return NextResponse.json({ success: true }, { status: 200 }) diff --git a/apps/tradinggoose/app/api/indicators/options/route.test.ts b/apps/tradinggoose/app/api/indicators/options/route.test.ts index 228837d7d..cfb08e307 100644 --- a/apps/tradinggoose/app/api/indicators/options/route.test.ts +++ b/apps/tradinggoose/app/api/indicators/options/route.test.ts @@ -8,38 +8,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockAuthenticateIndicatorRequest, mockCheckWorkspacePermission, - mockFrom, - mockSelect, - mockWhere, + mockListIndicators, mockIsIndicatorTriggerCapable, } = vi.hoisted(() => ({ mockAuthenticateIndicatorRequest: vi.fn(), mockCheckWorkspacePermission: vi.fn(), - mockFrom: vi.fn(), - mockSelect: vi.fn(), - mockWhere: vi.fn(), + mockListIndicators: vi.fn(), mockIsIndicatorTriggerCapable: vi.fn(), })) -vi.mock('@tradinggoose/db', () => ({ - db: { - select: mockSelect, - }, -})) - -vi.mock('@tradinggoose/db/schema', () => ({ - pineIndicators: { - id: 'pineIndicators.id', - name: 'pineIndicators.name', - color: 'pineIndicators.color', - pineCode: 'pineIndicators.pineCode', - inputMeta: 'pineIndicators.inputMeta', - workspaceId: 'pineIndicators.workspaceId', - }, -})) - -vi.mock('drizzle-orm', () => ({ - eq: vi.fn((field: unknown, value: unknown) => ({ field, type: 'eq', value })), +vi.mock('@/lib/indicators/custom/operations', () => ({ + listIndicators: (...args: unknown[]) => mockListIndicators(...args), })) vi.mock('@/lib/indicators/default/runtime', () => ({ @@ -81,7 +60,7 @@ describe('indicator options route', () => { }) mockCheckWorkspacePermission.mockResolvedValue({ ok: true, permission: 'admin' }) mockIsIndicatorTriggerCapable.mockImplementation((code: string) => code === 'trigger-capable') - mockWhere.mockResolvedValue([ + mockListIndicators.mockResolvedValue([ { id: 'custom-trigger', name: 'Custom Trigger', @@ -111,8 +90,6 @@ describe('indicator options route', () => { }, }, ]) - mockFrom.mockReturnValue({ where: mockWhere }) - mockSelect.mockReturnValue({ from: mockFrom }) }) const getOptions = async (search: string) => { diff --git a/apps/tradinggoose/app/api/indicators/options/route.ts b/apps/tradinggoose/app/api/indicators/options/route.ts index 748011055..97411c625 100644 --- a/apps/tradinggoose/app/api/indicators/options/route.ts +++ b/apps/tradinggoose/app/api/indicators/options/route.ts @@ -1,8 +1,6 @@ -import { db } from '@tradinggoose/db' -import { pineIndicators } from '@tradinggoose/db/schema' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { listIndicators } from '@/lib/indicators/custom/operations' import { DEFAULT_INDICATOR_RUNTIME_ENTRIES } from '@/lib/indicators/default/runtime' import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' import { isIndicatorTriggerCapable } from '@/lib/indicators/trigger-detection' @@ -85,17 +83,7 @@ export async function GET(request: NextRequest) { } }) - const customRows = await db - .select({ - id: pineIndicators.id, - workspaceId: pineIndicators.workspaceId, - name: pineIndicators.name, - color: pineIndicators.color, - pineCode: pineIndicators.pineCode, - inputMeta: pineIndicators.inputMeta, - }) - .from(pineIndicators) - .where(eq(pineIndicators.workspaceId, workspaceId)) + const customRows = await listIndicators({ workspaceId }) const customOptions: IndicatorOptionRecord[] = customRows .filter((row) => copilotSurface || isIndicatorTriggerCapable(row.pineCode)) diff --git a/apps/tradinggoose/app/api/knowledge/route.test.ts b/apps/tradinggoose/app/api/knowledge/route.test.ts index d7fbda3eb..d0d96fbf9 100644 --- a/apps/tradinggoose/app/api/knowledge/route.test.ts +++ b/apps/tradinggoose/app/api/knowledge/route.test.ts @@ -15,16 +15,6 @@ vi.mock('@/lib/knowledge/service', () => ({ getKnowledgeBases: vi.fn(), })) -vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ - buildSavedEntityListThroughYjs: async ( - _kind: string, - rows: Array>, - buildEntry: (row: Record, fields: Record) => unknown = ( - row - ) => row - ) => Promise.all(rows.map((row) => buildEntry(row, row))), -})) - describe('Knowledge Base API Route', () => { const mockAuth$ = mockAuth() let mockCreateKnowledgeBase: any diff --git a/apps/tradinggoose/app/api/knowledge/route.ts b/apps/tradinggoose/app/api/knowledge/route.ts index 9208210d1..e9dbcc828 100644 --- a/apps/tradinggoose/app/api/knowledge/route.ts +++ b/apps/tradinggoose/app/api/knowledge/route.ts @@ -4,7 +4,6 @@ import { getSession } from '@/lib/auth' import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { buildSavedEntityListThroughYjs } from '@/lib/yjs/server/bootstrap-review-target' const logger = createLogger('KnowledgeBaseAPI') @@ -46,13 +45,9 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) } - const knowledgeBasesWithCounts = await getKnowledgeBases(session.user.id, workspaceId) - - const data = await buildSavedEntityListThroughYjs('knowledge_base', knowledgeBasesWithCounts) - return NextResponse.json({ success: true, - data, + data: await getKnowledgeBases(session.user.id, workspaceId), }) } catch (error) { logger.error(`[${requestId}] Error fetching knowledge bases`, error) diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts index ea10bb706..443a4b9d7 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts @@ -1,6 +1,3 @@ -import { db } from '@tradinggoose/db' -import { mcpServers } from '@tradinggoose/db/schema' -import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { withMcpAuth } from '@/lib/mcp/middleware' @@ -30,26 +27,6 @@ export const POST = withMcpAuth('read')( } ) - const [server] = await db - .select() - .from(mcpServers) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) - ) - .limit(1) - - if (!server) { - return createMcpErrorResponse( - new Error('Server not found or access denied'), - 'Server not found', - 404 - ) - } - let connectionStatus: 'connected' | 'disconnected' | 'error' = 'error' let toolCount = 0 let lastError: string | null = null @@ -67,24 +44,11 @@ export const POST = withMcpAuth('read')( logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error) } - const [refreshedServer] = await db - .update(mcpServers) - .set({ - lastToolsRefresh: new Date(), - connectionStatus, - lastError, - lastConnected: connectionStatus === 'connected' ? new Date() : server.lastConnected, - toolCount, - updatedAt: new Date(), - }) - .where(eq(mcpServers.id, serverId)) - .returning() - logger.info(`[${requestId}] Successfully refreshed MCP server: ${serverId}`) return createMcpSuccessResponse({ status: connectionStatus, toolCount, - lastConnected: refreshedServer?.lastConnected?.toISOString() || null, + lastConnected: connectionStatus === 'connected' ? new Date().toISOString() : null, error: lastError, }) } catch (error) { diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts index a96123d36..4ab72ff3a 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts @@ -1,6 +1,3 @@ -import { db } from '@tradinggoose/db' -import { mcpServers } from '@tradinggoose/db/schema' -import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' @@ -10,7 +7,10 @@ import { applySavedEntityState, SavedEntityPersistenceError, } from '@/lib/yjs/server/apply-entity-state' -import { readBootstrappedSavedEntityFields } from '@/lib/yjs/server/bootstrap-review-target' +import { + ReviewTargetBootstrapError, + readBootstrappedSavedEntityFields, +} from '@/lib/yjs/server/bootstrap-review-target' import { UpdateMcpServerSchema } from '../schema' const logger = createLogger('McpServerAPI') @@ -47,39 +47,13 @@ export const PATCH = withMcpAuth('write')( updates: Object.keys(body).filter((k) => k !== 'workspaceId'), }) - const [existingServer] = await db - .select() - .from(mcpServers) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) - ) - .limit(1) - - if (!existingServer) { - return createMcpErrorResponse( - new Error('Server not found or access denied'), - 'Server not found', - 404 - ) - } - - // Read the current fields through Yjs (live session if open, else bootstrapped - // from DB) so the partial update merges into the canonical state, not a stale row. const currentFields = await readBootstrappedSavedEntityFields( 'mcp_server', serverId, workspaceId ) const { workspaceId: _, ...updateData } = body - const nextServer = { - ...existingServer, - ...updateData, - updatedAt: new Date(), - } + const nextServer = { ...currentFields, ...updateData, id: serverId, workspaceId } await applySavedEntityState('mcp_server', serverId, { ...currentFields, ...updateData }) @@ -90,7 +64,10 @@ export const PATCH = withMcpAuth('write')( return createMcpSuccessResponse({ server: nextServer }) } catch (error) { logger.error(`[${requestId}] Error updating MCP server:`, error) - if (error instanceof SavedEntityPersistenceError) { + if ( + error instanceof SavedEntityPersistenceError || + error instanceof ReviewTargetBootstrapError + ) { return createMcpErrorResponse(error, error.message, error.status) } diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index c6869fbbf..5fedb7e52 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -7,7 +7,11 @@ import { createLogger } from '@/lib/logs/console/logger' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { + deleteYjsSessionInSocketServer, + notifyEntityListMemberRemoved, + notifyEntityListMembersAdded, +} from '@/lib/yjs/server/snapshot-bridge' import { CreateMcpServerSchema } from './schema' const logger = createLogger('McpServersAPI') @@ -97,6 +101,9 @@ export const POST = withMcpAuth('write')( }) mcpService.clearCache(workspaceId) + await notifyEntityListMembersAdded('mcp_server', workspaceId, [ + { id: serverId, name: String(fields.name ?? '') }, + ]) logger.info(`[${requestId}] Successfully registered MCP server: ${fields.name}`) @@ -162,6 +169,7 @@ export const DELETE = withMcpAuth('write')( .delete(mcpServers) .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) await deleteYjsSessionInSocketServer(serverId).catch(() => undefined) + await notifyEntityListMemberRemoved('mcp_server', workspaceId, serverId) mcpService.clearCache(workspaceId) diff --git a/apps/tradinggoose/app/api/skills/route.test.ts b/apps/tradinggoose/app/api/skills/route.test.ts index 277408b9f..bfcf5fa99 100644 --- a/apps/tradinggoose/app/api/skills/route.test.ts +++ b/apps/tradinggoose/app/api/skills/route.test.ts @@ -42,16 +42,6 @@ vi.mock('@tradinggoose/db/schema', () => ({ skill: {}, })) -vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ - buildSavedEntityListThroughYjs: async ( - _kind: string, - rows: Array>, - buildEntry: (row: Record, fields: Record) => unknown = ( - row - ) => row - ) => Promise.all(rows.map((row) => buildEntry(row, row))), -})) - describe('Skills API Routes', () => { beforeEach(() => { vi.resetAllMocks() diff --git a/apps/tradinggoose/app/api/skills/route.ts b/apps/tradinggoose/app/api/skills/route.ts index 77cb1d768..d675ccaf3 100644 --- a/apps/tradinggoose/app/api/skills/route.ts +++ b/apps/tradinggoose/app/api/skills/route.ts @@ -11,7 +11,6 @@ import { import { createSkills, deleteSkill, listSkills, saveSkill } from '@/lib/skills/operations' import { generateRequestId } from '@/lib/utils' import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' -import { buildSavedEntityListThroughYjs } from '@/lib/yjs/server/bootstrap-review-target' const logger = createLogger('SkillsAPI') @@ -59,9 +58,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const result = await listSkills({ workspaceId }) - const data = await buildSavedEntityListThroughYjs('skill', result) - return NextResponse.json({ data }, { status: 200 }) + return NextResponse.json({ data: await listSkills({ workspaceId }) }, { status: 200 }) } catch (error) { logger.error(`[${requestId}] Error fetching skills:`, error) return NextResponse.json({ error: 'Failed to fetch skills' }, { status: 500 }) diff --git a/apps/tradinggoose/app/api/tools/custom/route.test.ts b/apps/tradinggoose/app/api/tools/custom/route.test.ts index c47bb8d28..ee013950d 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.test.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.test.ts @@ -44,16 +44,6 @@ vi.mock('@tradinggoose/db/schema', () => ({ workflow: {}, })) -vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ - buildSavedEntityListThroughYjs: async ( - _kind: string, - rows: Array>, - buildEntry: (row: Record, fields: Record) => unknown = ( - row - ) => row - ) => Promise.all(rows.map((row) => buildEntry(row, row))), -})) - describe('Custom Tools API Routes', () => { beforeEach(() => { vi.resetAllMocks() @@ -80,7 +70,7 @@ describe('Custom Tools API Routes', () => { expect(body.error).toBe('Unauthorized') }) - it('GET should require workspaceId or workflowId', async () => { + it('GET should require workspaceId', async () => { const req = new NextRequest('http://localhost:3000/api/tools/custom') const { GET } = await import('@/app/api/tools/custom/route') const res = await GET(req) diff --git a/apps/tradinggoose/app/api/tools/custom/route.ts b/apps/tradinggoose/app/api/tools/custom/route.ts index 8ef75ae7e..96f5079ab 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.ts @@ -1,5 +1,5 @@ import { db } from '@tradinggoose/db' -import { customTools, workflow } from '@tradinggoose/db/schema' +import { customTools } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -10,8 +10,10 @@ import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' -import { buildSavedEntityListThroughYjs } from '@/lib/yjs/server/bootstrap-review-target' -import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { + deleteYjsSessionInSocketServer, + notifyEntityListMemberRemoved, +} from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('CustomToolsAPI') @@ -20,7 +22,6 @@ export async function GET(request: NextRequest) { const requestId = generateRequestId() const searchParams = request.nextUrl.searchParams const workspaceId = searchParams.get('workspaceId') - const workflowId = searchParams.get('workflowId') try { const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) @@ -30,43 +31,18 @@ export async function GET(request: NextRequest) { } const userId = authResult.userId - let resolvedWorkspaceId: string | null = workspaceId - - if (!resolvedWorkspaceId && workflowId) { - const [workflowData] = await db - .select({ workspaceId: workflow.workspaceId }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (!workflowData?.workspaceId) { - logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - resolvedWorkspaceId = workflowData.workspaceId - } - - if (!resolvedWorkspaceId) { + if (!workspaceId) { logger.warn(`[${requestId}] Missing workspaceId for custom tools fetch`) return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) } - // Skip permission check for internal JWT workflow proxy requests - if (!(authResult.authType === 'internal_jwt' && workflowId)) { - const permission = await getUserEntityPermissions(userId, 'workspace', resolvedWorkspaceId) - if (!permission) { - logger.warn( - `[${requestId}] User ${userId} does not have access to workspace ${resolvedWorkspaceId}` - ) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permission) { + logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const rows = await listCustomTools({ workspaceId: resolvedWorkspaceId }) - const result = await buildSavedEntityListThroughYjs('custom_tool', rows) - - return NextResponse.json({ data: result }, { status: 200 }) + return NextResponse.json({ data: await listCustomTools({ workspaceId }) }, { status: 200 }) } catch (error) { logger.error(`[${requestId}] Error fetching custom tools:`, error) return NextResponse.json({ error: 'Failed to fetch custom tools' }, { status: 500 }) @@ -228,6 +204,7 @@ export async function DELETE(request: NextRequest) { .delete(customTools) .where(and(eq(customTools.id, toolId), eq(customTools.workspaceId, workspaceId))) await deleteYjsSessionInSocketServer(toolId).catch(() => undefined) + await notifyEntityListMemberRemoved('custom_tool', workspaceId, toolId) logger.info(`[${requestId}] Deleted tool: ${toolId}`) return NextResponse.json({ success: true }) diff --git a/apps/tradinggoose/lib/copilot/review-sessions/identity.test.ts b/apps/tradinggoose/lib/copilot/review-sessions/identity.test.ts index 797659175..d3950d1f6 100644 --- a/apps/tradinggoose/lib/copilot/review-sessions/identity.test.ts +++ b/apps/tradinggoose/lib/copilot/review-sessions/identity.test.ts @@ -3,8 +3,11 @@ */ import { describe, expect, it } from 'vitest' import { + buildEntityListDescriptor, buildReviewTargetDescriptorFromEnvelope, buildYjsTransportEnvelope, + parseYjsTransportEnvelope, + serializeYjsTransportEnvelope, } from '@/lib/copilot/review-sessions/identity' describe('review target identity helpers', () => { @@ -22,4 +25,43 @@ describe('review target identity helpers', () => { descriptor ) }) + + it('treats workflow as an entity transport target', () => { + const descriptor = { + workspaceId: 'ws-1', + entityKind: 'workflow' as const, + entityId: 'workflow-1', + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: 'workflow-1', + } + + const envelope = buildYjsTransportEnvelope(descriptor) + expect(envelope).toEqual({ + targetKind: 'entity', + sessionId: 'workflow-1', + reviewSessionId: null, + workspaceId: 'ws-1', + entityKind: 'workflow', + entityId: 'workflow-1', + draftSessionId: null, + }) + expect(buildReviewTargetDescriptorFromEnvelope(envelope)).toEqual(descriptor) + }) + + it('round-trips canonical entity-list envelopes and rejects entity targets', () => { + const descriptor = buildEntityListDescriptor('skill', 'ws-1') + const envelope = buildYjsTransportEnvelope(descriptor) + expect(envelope.targetKind).toBe('entity_list') + expect(envelope.entityId).toBeNull() + expect(envelope.sessionId).toBe('list:skill:ws-1') + expect(buildReviewTargetDescriptorFromEnvelope(envelope)).toEqual(descriptor) + const wire = serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor)) + expect(buildReviewTargetDescriptorFromEnvelope(parseYjsTransportEnvelope(wire))).toEqual( + descriptor + ) + expect(() => + buildReviewTargetDescriptorFromEnvelope({ ...envelope, entityId: 'skill-1' }) + ).toThrow(/cannot carry/) + }) }) diff --git a/apps/tradinggoose/lib/copilot/review-sessions/identity.ts b/apps/tradinggoose/lib/copilot/review-sessions/identity.ts index e042b378b..ff571c637 100644 --- a/apps/tradinggoose/lib/copilot/review-sessions/identity.ts +++ b/apps/tradinggoose/lib/copilot/review-sessions/identity.ts @@ -1,13 +1,13 @@ +import { normalizeOptionalString } from '@/lib/utils' +import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { REVIEW_ENTITY_KINDS, - YJS_TARGET_KINDS, type ReviewEntityKind, type ReviewTargetDescriptor, + YJS_TARGET_KINDS, type YjsTargetKind, type YjsTransportEnvelope, } from './types' -import { normalizeOptionalString } from '@/lib/utils' -import type { SavedEntityKind } from '@/lib/yjs/entity-state' const REVIEW_ENTITY_KIND_SET = new Set(REVIEW_ENTITY_KINDS) const YJS_TARGET_KIND_SET = new Set(YJS_TARGET_KINDS) @@ -54,23 +54,45 @@ export function buildSavedEntityDescriptor( } } +const ENTITY_LIST_SESSION_PREFIX = 'list:' + +function buildEntityListSessionId(entityKind: SavedEntityKind, workspaceId: string): string { + return `${ENTITY_LIST_SESSION_PREFIX}${entityKind}:${workspaceId}` +} + +export function isEntityListSessionId(sessionId: string): boolean { + return sessionId.startsWith(ENTITY_LIST_SESSION_PREFIX) +} + +export function buildEntityListDescriptor( + entityKind: SavedEntityKind, + workspaceId: string +): ReviewTargetDescriptor { + return { + workspaceId, + entityKind, + entityId: null, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: buildEntityListSessionId(entityKind, workspaceId), + } +} + /** * Builds a YjsTransportEnvelope from a ReviewTargetDescriptor. */ export function buildYjsTransportEnvelope( descriptor: ReviewTargetDescriptor ): YjsTransportEnvelope { - const targetKind: YjsTargetKind = - descriptor.entityKind === 'workflow' - ? 'workflow' - : descriptor.entityId - ? 'entity' - : 'review_session' + const targetKind: YjsTargetKind = isEntityListSessionId(descriptor.yjsSessionId) + ? 'entity_list' + : descriptor.entityId + ? 'entity' + : 'review_session' return { targetKind, sessionId: descriptor.yjsSessionId, - workflowId: descriptor.entityKind === 'workflow' ? descriptor.entityId : null, reviewSessionId: targetKind === 'review_session' ? descriptor.reviewSessionId : null, workspaceId: descriptor.workspaceId, entityKind: descriptor.entityKind, @@ -85,36 +107,33 @@ export function buildYjsTransportEnvelope( export function buildReviewTargetDescriptorFromEnvelope( envelope: YjsTransportEnvelope ): ReviewTargetDescriptor { - if (envelope.targetKind === 'workflow') { - if (envelope.entityKind !== 'workflow') { - throw new Error('Workflow Yjs envelope must use entityKind="workflow"') - } - - const workflowId = envelope.workflowId ?? envelope.entityId ?? envelope.sessionId - if (!workflowId) { - throw new Error('Workflow Yjs envelope requires a workflowId') - } - - if (envelope.sessionId !== workflowId) { - throw new Error('Workflow Yjs envelope sessionId must equal workflowId') + if (envelope.targetKind === 'entity_list') { + if (envelope.entityKind === 'workflow') { + throw new Error('Entity-list Yjs envelope cannot use entityKind="workflow"') } - if (envelope.entityId && envelope.entityId !== workflowId) { - throw new Error('Workflow Yjs envelope entityId must equal workflowId') + if (!envelope.workspaceId) { + throw new Error('Entity-list Yjs envelope requires workspaceId') } - if (envelope.draftSessionId) { - throw new Error('Workflow Yjs envelope cannot carry draftSessionId') + if (envelope.entityId || envelope.reviewSessionId || envelope.draftSessionId) { + throw new Error( + 'Entity-list Yjs envelope cannot carry entityId, reviewSessionId, or draftSessionId' + ) } - if (envelope.reviewSessionId) { - throw new Error('Workflow Yjs envelope cannot carry reviewSessionId') + if ( + envelope.sessionId !== buildEntityListSessionId(envelope.entityKind, envelope.workspaceId) + ) { + throw new Error( + 'Entity-list Yjs envelope sessionId must equal list:{entityKind}:{workspaceId}' + ) } return { - workspaceId: envelope.workspaceId ?? null, - entityKind: 'workflow', - entityId: workflowId, + workspaceId: envelope.workspaceId, + entityKind: envelope.entityKind, + entityId: null, draftSessionId: null, reviewSessionId: null, yjsSessionId: envelope.sessionId, @@ -122,11 +141,7 @@ export function buildReviewTargetDescriptorFromEnvelope( } if (envelope.targetKind === 'entity') { - if (envelope.entityKind === 'workflow') { - throw new Error('Entity Yjs envelope cannot use entityKind="workflow"') - } - - if (!envelope.workspaceId) { + if (envelope.entityKind !== 'workflow' && !envelope.workspaceId) { throw new Error('Entity Yjs envelope requires workspaceId') } @@ -138,14 +153,12 @@ export function buildReviewTargetDescriptorFromEnvelope( throw new Error('Entity Yjs envelope sessionId must equal entityId') } - if (envelope.workflowId || envelope.reviewSessionId || envelope.draftSessionId) { - throw new Error( - 'Entity Yjs envelope cannot carry workflowId, reviewSessionId, or draftSessionId' - ) + if (envelope.reviewSessionId || envelope.draftSessionId) { + throw new Error('Entity Yjs envelope cannot carry reviewSessionId or draftSessionId') } return { - workspaceId: envelope.workspaceId, + workspaceId: envelope.workspaceId ?? null, entityKind: envelope.entityKind, entityId: envelope.entityId, draftSessionId: null, @@ -167,10 +180,6 @@ export function buildReviewTargetDescriptorFromEnvelope( throw new Error('Review-session Yjs envelope sessionId must equal reviewSessionId') } - if (envelope.workflowId) { - throw new Error('Review-session Yjs envelope cannot carry workflowId') - } - if (!envelope.workspaceId) { throw new Error('Review-session Yjs envelope requires workspaceId') } @@ -206,7 +215,6 @@ export function serializeYjsTransportEnvelope( entityKind: envelope.entityKind, } - if (envelope.workflowId != null) result.workflowId = envelope.workflowId if (envelope.reviewSessionId != null) result.reviewSessionId = envelope.reviewSessionId if (envelope.workspaceId != null) result.workspaceId = envelope.workspaceId if (envelope.entityId != null) result.entityId = envelope.entityId @@ -221,6 +229,10 @@ export function serializeYjsTransportEnvelope( export function parseYjsTransportEnvelope( payload: Record ): YjsTransportEnvelope { + if (normalizeNullableString(payload.workflowId)) { + throw new Error('Yjs transport envelope cannot carry workflowId; use entityId') + } + const envelope: YjsTransportEnvelope = { targetKind: requireYjsTargetKind(payload.targetKind), sessionId: @@ -228,7 +240,6 @@ export function parseYjsTransportEnvelope( (() => { throw new Error('Missing required transport envelope field: sessionId') })(), - workflowId: normalizeNullableString(payload.workflowId), reviewSessionId: normalizeNullableString(payload.reviewSessionId), workspaceId: normalizeNullableString(payload.workspaceId), entityKind: requireReviewEntityKind(payload.entityKind), diff --git a/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts b/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts index f1ccee786..5b1a0ec2f 100644 --- a/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts +++ b/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts @@ -1,6 +1,7 @@ import { db } from '@tradinggoose/db' import { copilotReviewSessions, permissions, workspace } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' +import { isEntityListSessionId } from '@/lib/copilot/review-sessions/identity' import type { ReviewAccessMode, ReviewEntityKind, @@ -265,15 +266,20 @@ export async function verifyReviewTargetAccess( accessMode: ReviewAccessMode ): Promise { if (reviewTarget.entityKind === 'workflow') { - const workflowId = - reviewTarget.entityId ?? ('yjsSessionId' in reviewTarget ? reviewTarget.yjsSessionId : null) - - if (!workflowId) { + if (!reviewTarget.entityId) { logger.warn('Workflow review target missing workflow id', { userId, reviewTarget }) return { hasAccess: false, userPermission: null, workspaceId: null, isOwner: false } } - return verifyWorkflowAccess(userId, workflowId, accessMode) + return verifyWorkflowAccess(userId, reviewTarget.entityId, accessMode) + } + + if (reviewTarget.yjsSessionId && isEntityListSessionId(reviewTarget.yjsSessionId)) { + if (!reviewTarget.workspaceId) { + logger.warn('Entity-list review target missing workspaceId', { userId, reviewTarget }) + return { hasAccess: false, userPermission: null, workspaceId: null, isOwner: false } + } + return verifyWorkspaceAccess(userId, reviewTarget.workspaceId, accessMode) } if (!reviewTarget.reviewSessionId) { diff --git a/apps/tradinggoose/lib/copilot/review-sessions/types.ts b/apps/tradinggoose/lib/copilot/review-sessions/types.ts index 4336061e4..38692376a 100644 --- a/apps/tradinggoose/lib/copilot/review-sessions/types.ts +++ b/apps/tradinggoose/lib/copilot/review-sessions/types.ts @@ -39,14 +39,13 @@ export interface ResolvedReviewTarget { runtime: ReviewTargetRuntimeState } -export const YJS_TARGET_KINDS = ['workflow', 'entity', 'review_session'] as const +export const YJS_TARGET_KINDS = ['entity', 'review_session', 'entity_list'] as const export type YjsTargetKind = (typeof YJS_TARGET_KINDS)[number] export interface YjsTransportEnvelope { targetKind: YjsTargetKind sessionId: string - workflowId: string | null reviewSessionId: string | null workspaceId: string | null entityKind: ReviewEntityKind diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts index 328e95f29..f9d8d0fd9 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts @@ -1,6 +1,6 @@ import { ENTITY_KIND_CUSTOM_TOOL } from '@/lib/copilot/review-sessions/types' import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' -import { createCustomTools, listCustomTools } from '@/lib/custom-tools/operations' +import { createCustomTools } from '@/lib/custom-tools/operations' import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { @@ -50,8 +50,7 @@ export const listCustomToolsServerTool: EntityServerTool> withWorkspaceArgContext(context, args), 'read' ) - const rows = await listCustomTools({ workspaceId }) - const entities = await buildSavedEntityListInfo(ENTITY_KIND_CUSTOM_TOOL, rows) + const entities = await buildSavedEntityListInfo(ENTITY_KIND_CUSTOM_TOOL, workspaceId) return { entityKind: ENTITY_KIND_CUSTOM_TOOL, diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts index 28aaa8780..f6357afe5 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts @@ -4,22 +4,10 @@ import { DEFAULT_INDICATOR_RUNTIME_ENTRIES } from '@/lib/indicators/default/runt import { listIndicatorsServerTool, readIndicatorServerTool } from './indicator' const mockCheckWorkspaceAccess = vi.hoisted(() => vi.fn()) -const mockDbOrderBy = vi.hoisted(() => vi.fn()) +const mockReadBootstrappedEntityListMembers = vi.hoisted(() => vi.fn()) const mockReadBootstrappedSavedEntityFields = vi.hoisted(() => vi.fn()) const mockVerifyReviewTargetAccess = vi.hoisted(() => vi.fn()) -vi.mock('@tradinggoose/db', () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - orderBy: mockDbOrderBy, - })), - })), - })), - }, -})) - vi.mock('@/lib/permissions/utils', () => ({ checkWorkspaceAccess: (...args: unknown[]) => mockCheckWorkspaceAccess(...args), })) @@ -29,18 +17,10 @@ vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ + readBootstrappedEntityListMembers: (...args: unknown[]) => + mockReadBootstrappedEntityListMembers(...args), readBootstrappedSavedEntityFields: (...args: unknown[]) => mockReadBootstrappedSavedEntityFields(...args), - buildSavedEntityListThroughYjs: async ( - kind: string, - rows: Array<{ id: string; workspaceId: string }>, - buildEntry: (row: unknown, fields: unknown) => unknown - ) => - Promise.all( - rows.map(async (row) => - buildEntry(row, await mockReadBootstrappedSavedEntityFields(kind, row.id, row.workspaceId)) - ) - ), })) describe('indicator server tools', () => { @@ -51,17 +31,10 @@ describe('indicator server tools', () => { hasAccess: true, canWrite: true, }) - mockDbOrderBy.mockResolvedValue([ + mockReadBootstrappedEntityListMembers.mockResolvedValue([ { - id: 'indicator-custom-1', - name: 'Custom Momentum', - pineCode: 'indicator("Custom Momentum")', - inputMeta: { Length: { defaultValue: 14 } }, - workspaceId: 'workspace-1', - userId: 'user-1', - color: '#10b981', - createdAt: new Date('2026-06-23T00:00:00.000Z'), - updatedAt: new Date('2026-06-23T00:00:00.000Z'), + entityId: 'indicator-custom-1', + entityName: 'Custom Momentum', }, ]) mockVerifyReviewTargetAccess.mockResolvedValue({ @@ -90,7 +63,6 @@ describe('indicator server tools', () => { callableInFunctionBlock: true, entityId: 'indicator-custom-1', runtimeId: 'indicator-custom-1', - inputTitles: ['Length'], }) ) }) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts index fb7438cd1..ee059ac05 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts @@ -1,6 +1,3 @@ -import { db } from '@tradinggoose/db' -import { pineIndicators } from '@tradinggoose/db/schema' -import { desc, eq } from 'drizzle-orm' import { ENTITY_KIND_INDICATOR } from '@/lib/copilot/review-sessions/types' import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' import { createIndicators } from '@/lib/indicators/custom/operations' @@ -8,11 +5,10 @@ import { DEFAULT_INDICATOR_RUNTIME_ENTRIES, DEFAULT_INDICATOR_RUNTIME_MAP, } from '@/lib/indicators/default/runtime' -import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { buildDocumentEnvelope, - buildSavedEntityListThroughYjs, + buildSavedEntityListInfo, type CopilotIndicatorListEntry, type EntityCreateResult, type EntityServerTool, @@ -38,35 +34,17 @@ function toDefaultIndicatorListEntry(entry: (typeof DEFAULT_INDICATOR_RUNTIME_EN } } -function toCustomIndicatorListEntry( - row: typeof pineIndicators.$inferSelect, - fields: Record -): CopilotIndicatorListEntry { - const inputMeta = normalizeInputMetaMap(fields.inputMeta) - const inputTitles = Object.keys(inputMeta ?? {}) - - return { - name: String(fields.name ?? ''), - source: 'custom', - editable: true, - callableInFunctionBlock: true, - ...(inputTitles.length > 0 ? { inputTitles } : {}), - entityId: row.id, - runtimeId: row.id, - } -} - async function listCopilotIndicators(workspaceId: string): Promise { const defaultOptions = DEFAULT_INDICATOR_RUNTIME_ENTRIES.map(toDefaultIndicatorListEntry) - const customRows = await db - .select() - .from(pineIndicators) - .where(eq(pineIndicators.workspaceId, workspaceId)) - .orderBy(desc(pineIndicators.createdAt)) - const customOptions = await buildSavedEntityListThroughYjs( - ENTITY_KIND_INDICATOR, - customRows, - toCustomIndicatorListEntry + const customOptions = (await buildSavedEntityListInfo(ENTITY_KIND_INDICATOR, workspaceId)).map( + (entry) => ({ + name: entry.entityName, + source: 'custom' as const, + editable: true, + callableInFunctionBlock: true, + entityId: entry.entityId, + runtimeId: entry.entityId, + }) ) return [...defaultOptions, ...customOptions].sort((a, b) => a.name.localeCompare(b.name)) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts index d184cbee5..e2eb895ae 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -1,6 +1,5 @@ import { db } from '@tradinggoose/db' import { mcpServers } from '@tradinggoose/db/schema' -import { and, eq, isNull } from 'drizzle-orm' import { ENTITY_SECRET_PLACEHOLDER, normalizeEntityFields, @@ -12,6 +11,7 @@ import { mcpService } from '@/lib/mcp/service' import type { McpTransport } from '@/lib/mcp/types' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { notifyEntityListMembersAdded } from '@/lib/yjs/server/snapshot-bridge' import { buildDocumentEnvelope, buildSavedEntityListInfo, @@ -128,6 +128,9 @@ async function createMcpServerEntity( const savedFields = savedEntityRowToFields(ENTITY_KIND_MCP_SERVER, row) mcpService.clearCache(workspaceId) + await notifyEntityListMembersAdded('mcp_server', workspaceId, [ + { id: entityId, name: String(normalized.name ?? '') }, + ]) return { entityId, @@ -160,11 +163,7 @@ export const listMcpServersServerTool: EntityServerTool> = withWorkspaceArgContext(context, args), 'read' ) - const rows = await db - .select() - .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) - const entities = await buildSavedEntityListInfo(ENTITY_KIND_MCP_SERVER, rows) + const entities = await buildSavedEntityListInfo(ENTITY_KIND_MCP_SERVER, workspaceId) return { entityKind: ENTITY_KIND_MCP_SERVER, diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index faba2e276..3efac2987 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -21,7 +21,7 @@ import { checkWorkspaceAccess } from '@/lib/permissions/utils' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' import { - buildSavedEntityListThroughYjs, + readBootstrappedEntityListMembers, readBootstrappedSavedEntityFields, } from '@/lib/yjs/server/bootstrap-review-target' @@ -196,23 +196,16 @@ export async function readSavedEntityDocumentFields( return readBootstrappedSavedEntityFields(kind as SavedEntityKind, entityId, workspaceId) } -// The canonical list-through-Yjs primitive lives in the Yjs read layer so widget -// API routes and the MCP service share it too; re-exported here for the tools. -export { buildSavedEntityListThroughYjs } - /** - * Canonical projection for every saved-entity list_* tool: read each row through - * Yjs and expose only the discovery info (id + canonical name). One projection - * for all kinds — no tool invents its own list mapper. + * Canonical read for every saved-entity list_* tool: the workspace's membership + * through the live Yjs list session (id + canonical name only). Reflects realtime + * create/delete by any user — one read for all kinds, no per-tool list mapper. */ -export function buildSavedEntityListInfo( +export function buildSavedEntityListInfo( entityKind: SavedEntityKind, - rows: TRow[] + workspaceId: string ): Promise { - return buildSavedEntityListThroughYjs(entityKind, rows, (row, fields) => ({ - entityId: row.id, - entityName: getEntityDocumentName(entityKind, fields), - })) + return readBootstrappedEntityListMembers(entityKind, workspaceId) } export async function executeCreateEntityDocumentMutation( diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts index e311fd320..bff691dcc 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts @@ -1,6 +1,6 @@ import { ENTITY_KIND_SKILL } from '@/lib/copilot/review-sessions/types' import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' -import { createSkills, listSkills } from '@/lib/skills/operations' +import { createSkills } from '@/lib/skills/operations' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { buildDocumentEnvelope, @@ -49,8 +49,7 @@ export const listSkillsServerTool: EntityServerTool> = { withWorkspaceArgContext(context, args), 'read' ) - const rows = await listSkills({ workspaceId }) - const entities = await buildSavedEntityListInfo(ENTITY_KIND_SKILL, rows) + const entities = await buildSavedEntityListInfo(ENTITY_KIND_SKILL, workspaceId) return { entityKind: ENTITY_KIND_SKILL, diff --git a/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts index c56c831a7..1e1df60a2 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -5,11 +5,7 @@ import { withWorkspaceArgContext, } from '@/lib/copilot/tools/server/base-tool' import { generateSearchEmbedding } from '@/lib/embeddings/utils' -import { - createKnowledgeBase, - getKnowledgeBaseById, - getKnowledgeBases, -} from '@/lib/knowledge/service' +import { createKnowledgeBase, getKnowledgeBaseById } from '@/lib/knowledge/service' import type { ChunkingConfig, KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { createLogger } from '@/lib/logs/console/logger' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' @@ -89,14 +85,13 @@ export const listKnowledgeBasesServerTool: BaseServerTool<{ workspaceId: string name: 'list_knowledge_bases', async execute(args, context) { const scopedContext = withWorkspaceArgContext(context, args) - const { userId, workspaceId } = await verifyWorkspaceContext(scopedContext, 'read') - const knowledgeBases = await getKnowledgeBases(userId, workspaceId) - const entities = await buildSavedEntityListInfo(ENTITY_KIND_KNOWLEDGE_BASE, knowledgeBases) + const { workspaceId } = await verifyWorkspaceContext(scopedContext, 'read') + const entities = await buildSavedEntityListInfo(ENTITY_KIND_KNOWLEDGE_BASE, workspaceId) return { entityKind: ENTITY_KIND_KNOWLEDGE_BASE, entities, - count: knowledgeBases.length, + count: entities.length, } }, } diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index a59426c97..48643f85d 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -1,14 +1,17 @@ import { db } from '@tradinggoose/db' import { customTools } from '@tradinggoose/db/schema' -import { and, desc, eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import { type CustomToolTransferRecord, resolveImportedCustomTools, } from '@/lib/custom-tools/import-export' +import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { readBootstrappedSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' +import { notifyEntityListMembersAdded } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('CustomToolsOperations') @@ -42,11 +45,17 @@ interface ImportCustomToolsParams { } export async function listCustomTools(params: { workspaceId: string }) { - return db - .select() - .from(customTools) - .where(eq(customTools.workspaceId, params.workspaceId)) - .orderBy(desc(customTools.createdAt)) + const entries = await readBootstrappedSavedEntityListFields('custom_tool', params.workspaceId) + return entries.map(({ entityId, fields }) => ({ + id: entityId, + workspaceId: params.workspaceId, + userId: null, + title: String(fields.title ?? ''), + schema: parseCustomToolSchemaText(fields.schemaText), + code: String(fields.codeText ?? ''), + createdAt: new Date(0), + updatedAt: undefined, + })) } export async function createCustomTools({ @@ -59,7 +68,7 @@ export async function createCustomTools({ return [] } - return await db.transaction(async (tx) => { + const created = await db.transaction(async (tx) => { const existingTools = await tx .select({ id: customTools.id, @@ -97,6 +106,13 @@ export async function createCustomTools({ logger.info(`[${requestId}] Created ${createdTools.length} custom tool(s)`) return createdTools }) + + await notifyEntityListMembersAdded( + 'custom_tool', + workspaceId, + created.map((createdTool) => ({ id: createdTool.id, name: createdTool.title })) + ) + return created } export async function saveCustomTool({ @@ -169,5 +185,10 @@ export async function importCustomTools({ } }) + await notifyEntityListMembersAdded( + 'custom_tool', + workspaceId, + result.tools.map((importedTool) => ({ id: importedTool.id, name: importedTool.title })) + ) return result } diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index e15e569f3..5b471e7a9 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -1,6 +1,6 @@ import { db } from '@tradinggoose/db' import { pineIndicators } from '@tradinggoose/db/schema' -import { and, desc, eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { getStableVibrantColor } from '@/lib/colors' import { type IndicatorTransferRecord, @@ -10,19 +10,32 @@ import { inferInputMetaFromPineCode, normalizeInputMetaMap } from '@/lib/indicat import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { readBootstrappedSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' +import { notifyEntityListMembersAdded } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('IndicatorsOperations') export async function listCustomIndicatorRuntimeEntries(workspaceId: string) { - const rows = await db - .select() - .from(pineIndicators) - .where(eq(pineIndicators.workspaceId, workspaceId)) + const entries = await readBootstrappedSavedEntityListFields('indicator', workspaceId) + return entries.map(({ entityId, fields }) => ({ + id: entityId, + pineCode: String(fields.pineCode ?? ''), + inputMeta: normalizeInputMetaMap(fields.inputMeta), + })) +} - return rows.map(({ id, pineCode, inputMeta }) => ({ - id, - pineCode, - inputMeta: normalizeInputMetaMap(inputMeta), +export async function listIndicators(params: { workspaceId: string }) { + const entries = await readBootstrappedSavedEntityListFields('indicator', params.workspaceId) + return entries.map(({ entityId, fields }) => ({ + id: entityId, + workspaceId: params.workspaceId, + userId: null, + name: String(fields.name ?? ''), + color: String(fields.color ?? '') || undefined, + pineCode: String(fields.pineCode ?? ''), + inputMeta: normalizeInputMetaMap(fields.inputMeta), + createdAt: new Date(0), + updatedAt: undefined, })) } @@ -64,7 +77,7 @@ export async function createIndicators({ return [] } - return await db.transaction(async (tx) => { + const created = await db.transaction(async (tx) => { const nowTime = new Date() const insertValues = [] @@ -87,6 +100,13 @@ export async function createIndicators({ logger.info(`[${requestId}] Created ${createdIndicators.length} indicator(s)`) return createdIndicators }) + + await notifyEntityListMembersAdded( + 'indicator', + workspaceId, + created.map((createdIndicator) => ({ id: createdIndicator.id, name: createdIndicator.name })) + ) + return created } export async function saveIndicator({ @@ -113,11 +133,7 @@ export async function saveIndicator({ pineCode: indicator.pineCode, }) logger.info(`[${requestId}] Saved Indicator ${indicator.id}`) - return db - .select() - .from(pineIndicators) - .where(eq(pineIndicators.workspaceId, workspaceId)) - .orderBy(desc(pineIndicators.createdAt)) + return listIndicators({ workspaceId }) } export async function importIndicators({ @@ -173,5 +189,10 @@ export async function importIndicators({ } }) + await notifyEntityListMembersAdded( + 'indicator', + workspaceId, + result.indicators.map((imported) => ({ id: imported.id, name: imported.name })) + ) return result } diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index bc0349ee0..0983ee620 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -22,7 +22,12 @@ import type { import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/permissions/utils' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' -import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { readBootstrappedSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' +import { + deleteYjsSessionInSocketServer, + notifyEntityListMemberRemoved, + notifyEntityListMembersAdded, +} from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('KnowledgeBaseService') @@ -38,33 +43,19 @@ export async function getKnowledgeBases( return [] } - const knowledgeBasesWithCounts = await db - .select({ - id: knowledgeBase.id, - name: knowledgeBase.name, - description: knowledgeBase.description, - tokenCount: knowledgeBase.tokenCount, - embeddingModel: knowledgeBase.embeddingModel, - embeddingDimension: knowledgeBase.embeddingDimension, - chunkingConfig: knowledgeBase.chunkingConfig, - createdAt: knowledgeBase.createdAt, - updatedAt: knowledgeBase.updatedAt, - workspaceId: knowledgeBase.workspaceId, - docCount: count(document.id), - }) - .from(knowledgeBase) - .leftJoin( - document, - and(eq(document.knowledgeBaseId, knowledgeBase.id), isNull(document.deletedAt)) - ) - .where(and(isNull(knowledgeBase.deletedAt), eq(knowledgeBase.workspaceId, workspaceId))) - .groupBy(knowledgeBase.id) - .orderBy(knowledgeBase.createdAt) - - return knowledgeBasesWithCounts.map((kb) => ({ - ...kb, - chunkingConfig: kb.chunkingConfig as ChunkingConfig, - docCount: Number(kb.docCount), + const entries = await readBootstrappedSavedEntityListFields('knowledge_base', workspaceId) + return entries.map(({ entityId, fields }) => ({ + id: entityId, + name: String(fields.name ?? ''), + description: String(fields.description ?? '') || null, + tokenCount: Number(fields.tokenCount ?? 0), + embeddingModel: String(fields.embeddingModel ?? 'text-embedding-3-small'), + embeddingDimension: Number(fields.embeddingDimension ?? 1536), + chunkingConfig: fields.chunkingConfig as ChunkingConfig, + createdAt: new Date(0), + updatedAt: new Date(0), + workspaceId, + docCount: 0, })) } @@ -116,6 +107,9 @@ export async function createKnowledgeBase( docCount: 0, } + await notifyEntityListMembersAdded('knowledge_base', data.workspaceId, [ + { id: created.id, name: created.name }, + ]) return created } @@ -352,6 +346,9 @@ export async function copyKnowledgeBaseToWorkspace( `[${requestId}] Copied knowledge base ${sourceKnowledgeBaseId} to workspace ${targetWorkspaceId} as ${newKnowledgeBaseId}` ) + await notifyEntityListMembersAdded('knowledge_base', targetWorkspaceId, [ + { id: copied.id, name: copied.name }, + ]) return copied } @@ -369,11 +366,7 @@ export async function applyKnowledgeBaseMetadata( throw new Error(`Knowledge base ${knowledgeBaseId} not found`) } - await applySavedEntityState( - ENTITY_KIND_KNOWLEDGE_BASE, - knowledgeBaseId, - fields - ) + await applySavedEntityState(ENTITY_KIND_KNOWLEDGE_BASE, knowledgeBaseId, fields) logger.info(`[${requestId}] Applied knowledge base metadata through Yjs: ${knowledgeBaseId}`) @@ -429,6 +422,12 @@ export async function deleteKnowledgeBase( ): Promise { const now = new Date() + const [existing] = await db + .select({ workspaceId: knowledgeBase.workspaceId }) + .from(knowledgeBase) + .where(eq(knowledgeBase.id, knowledgeBaseId)) + .limit(1) + await db .update(knowledgeBase) .set({ @@ -437,6 +436,9 @@ export async function deleteKnowledgeBase( }) .where(eq(knowledgeBase.id, knowledgeBaseId)) await deleteYjsSessionInSocketServer(knowledgeBaseId).catch(() => undefined) + if (existing?.workspaceId) { + await notifyEntityListMemberRemoved('knowledge_base', existing.workspaceId, knowledgeBaseId) + } logger.info(`[${requestId}] Soft deleted knowledge base: ${knowledgeBaseId}`) } diff --git a/apps/tradinggoose/lib/mcp/service.ts b/apps/tradinggoose/lib/mcp/service.ts index 86c67696a..832cbf27f 100644 --- a/apps/tradinggoose/lib/mcp/service.ts +++ b/apps/tradinggoose/lib/mcp/service.ts @@ -2,9 +2,6 @@ * MCP Service - Clean stateless service for MCP operations */ -import { db } from '@tradinggoose/db' -import { mcpServers } from '@tradinggoose/db/schema' -import { and, eq, type InferSelectModel, isNull } from 'drizzle-orm' import { normalizeEntityFields } from '@/lib/copilot/entity-documents' import { isTest } from '@/lib/environment' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' @@ -21,12 +18,25 @@ import type { import { MCP_CONSTANTS } from '@/lib/mcp/utils' import { generateRequestId } from '@/lib/utils' import { - buildSavedEntityListThroughYjs, + ReviewTargetBootstrapError, readBootstrappedSavedEntityFields, + readBootstrappedSavedEntityListFields, } from '@/lib/yjs/server/bootstrap-review-target' const logger = createLogger('McpService') -type McpServerRow = InferSelectModel + +type McpServerListItem = Omit & { + workspaceId: string + description: string | null + url: string | null + command: string | null + args: string[] + env: Record + connectionStatus?: 'connected' | 'disconnected' | 'error' + lastError?: string +} + +const EPOCH_ISO = new Date(0).toISOString() interface ToolCache { tools: McpTool[] @@ -242,20 +252,13 @@ class McpService { } } - private async getWorkspaceServerRows(workspaceId: string): Promise { - return db - .select() - .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) - } - - private async readServerFields(server: McpServerRow): Promise> { - return readBootstrappedSavedEntityFields('mcp_server', server.id, server.workspaceId) - } - - private toServerConfig(server: McpServerRow, fields: Record): McpServerConfig { + private toServerConfig( + serverId: string, + fields: Record, + timestamps: { createdAt?: string; updatedAt?: string } = {} + ): McpServerConfig { return { - id: server.id, + id: serverId, name: String(fields.name ?? ''), description: String(fields.description ?? '') || undefined, transport: fields.transport as McpTransport, @@ -264,38 +267,25 @@ class McpService { timeout: Number(fields.timeout ?? 30000), retries: Number(fields.retries ?? 3), enabled: fields.enabled !== false, - createdAt: server.createdAt.toISOString(), - updatedAt: server.updatedAt.toISOString(), + createdAt: timestamps.createdAt ?? EPOCH_ISO, + updatedAt: timestamps.updatedAt ?? EPOCH_ISO, } } - async listWorkspaceServers(workspaceId: string): Promise { - const servers = await this.getWorkspaceServerRows(workspaceId) - - return buildSavedEntityListThroughYjs('mcp_server', servers, (server, fields) => { - try { - const normalized = normalizeEntityFields('mcp_server', fields) - return { - ...server, - name: String(normalized.name ?? ''), - description: String(normalized.description ?? '') || null, - transport: String(normalized.transport ?? ''), - url: String(normalized.url ?? '') || null, - headers: normalized.headers, - command: String(normalized.command ?? '') || null, - args: Array.isArray(normalized.args) ? normalized.args.map(String) : [], - env: normalized.env, - timeout: Number(normalized.timeout ?? 30000), - retries: Number(normalized.retries ?? 3), - enabled: normalized.enabled !== false, - } - } catch (error) { - logger.warn(`MCP server ${server.id} has invalid saved-entity state:`, error) - return { - ...server, - connectionStatus: 'error', - lastError: error instanceof Error ? error.message : 'Invalid server state', - } + async listWorkspaceServers(workspaceId: string): Promise { + const servers = await readBootstrappedSavedEntityListFields('mcp_server', workspaceId) + return servers.map((server) => { + const normalized = normalizeEntityFields('mcp_server', server.fields) + const config = this.toServerConfig(server.entityId, normalized) + return { + ...config, + workspaceId, + description: config.description ?? null, + url: config.url ?? null, + command: String(normalized.command ?? '') || null, + args: Array.isArray(normalized.args) ? normalized.args.map(String) : [], + env: normalized.env as Record, + connectionStatus: 'disconnected' as const, } }) } @@ -304,43 +294,26 @@ class McpService { serverId: string, workspaceId: string ): Promise { - const [server] = await db - .select() - .from(mcpServers) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) + try { + const fields = normalizeEntityFields( + 'mcp_server', + await readBootstrappedSavedEntityFields('mcp_server', serverId, workspaceId) ) - .limit(1) - - if (!server) { - return null + return fields.enabled === false ? null : this.toServerConfig(serverId, fields) + } catch (error) { + if (error instanceof ReviewTargetBootstrapError && error.status === 404) { + return null + } + throw error } - - const fields = normalizeEntityFields('mcp_server', await this.readServerFields(server)) - return fields.enabled === false ? null : this.toServerConfig(server, fields) } private async getWorkspaceServers(workspaceId: string): Promise { - const servers = await this.getWorkspaceServerRows(workspaceId) - const configs = await buildSavedEntityListThroughYjs( - 'mcp_server', - servers, - (server, fields) => { - try { - const normalized = normalizeEntityFields('mcp_server', fields) - return normalized.enabled === false ? null : this.toServerConfig(server, normalized) - } catch (error) { - logger.warn(`Skipping MCP server ${server.id} with invalid saved-entity state:`, error) - return null - } - } - ) - - return configs.filter((config): config is McpServerConfig => config !== null) + const servers = await readBootstrappedSavedEntityListFields('mcp_server', workspaceId) + return servers.flatMap((server) => { + const normalized = normalizeEntityFields('mcp_server', server.fields) + return normalized.enabled === false ? [] : [this.toServerConfig(server.entityId, normalized)] + }) } /** diff --git a/apps/tradinggoose/lib/skills/operations.test.ts b/apps/tradinggoose/lib/skills/operations.test.ts index 1c1c504c9..8c8f3b6fa 100644 --- a/apps/tradinggoose/lib/skills/operations.test.ts +++ b/apps/tradinggoose/lib/skills/operations.test.ts @@ -33,6 +33,10 @@ vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ applySavedEntityState: vi.fn(), })) +vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ + readBootstrappedSavedEntityListFields: vi.fn(), +})) + import { importSkills } from '@/lib/skills/operations' const createQueryChain = (result: unknown) => { diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index 80e3134a4..b8762eceb 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -1,6 +1,6 @@ import { db } from '@tradinggoose/db' import { skill } from '@tradinggoose/db/schema' -import { and, desc, eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import { createLogger } from '@/lib/logs/console/logger' import { @@ -10,7 +10,12 @@ import { } from '@/lib/skills/import-export' import { generateRequestId } from '@/lib/utils' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' -import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { readBootstrappedSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' +import { + deleteYjsSessionInSocketServer, + notifyEntityListMemberRemoved, + notifyEntityListMembersAdded, +} from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('SkillsOperations') @@ -44,11 +49,17 @@ interface ImportSkillsParams { } export async function listSkills(params: { workspaceId: string }) { - return db - .select() - .from(skill) - .where(eq(skill.workspaceId, params.workspaceId)) - .orderBy(desc(skill.createdAt)) + const entries = await readBootstrappedSavedEntityListFields('skill', params.workspaceId) + return entries.map(({ entityId, fields }) => ({ + id: entityId, + workspaceId: params.workspaceId, + userId: null, + name: String(fields.name ?? ''), + description: String(fields.description ?? ''), + content: String(fields.content ?? ''), + createdAt: new Date(0), + updatedAt: undefined, + })) } export async function deleteSkill(params: { @@ -69,6 +80,7 @@ export async function deleteSkill(params: { .delete(skill) .where(and(eq(skill.id, params.skillId), eq(skill.workspaceId, params.workspaceId))) await deleteYjsSessionInSocketServer(params.skillId).catch(() => undefined) + await notifyEntityListMemberRemoved('skill', params.workspaceId, params.skillId) logger.info(`Deleted skill ${params.skillId}`) return true @@ -84,7 +96,7 @@ export async function createSkills({ return [] } - return await db.transaction(async (tx) => { + const created = await db.transaction(async (tx) => { const existingSkills = await tx .select({ id: skill.id, @@ -126,6 +138,13 @@ export async function createSkills({ logger.info(`[${requestId}] Created ${createdSkills.length} skill(s)`) return createdSkills }) + + await notifyEntityListMembersAdded( + 'skill', + workspaceId, + created.map((createdSkill) => ({ id: createdSkill.id, name: createdSkill.name })) + ) + return created } export async function saveSkill({ @@ -211,5 +230,10 @@ export async function importSkills({ } }) + await notifyEntityListMembersAdded( + 'skill', + workspaceId, + result.skills.map((importedSkill) => ({ id: importedSkill.id, name: importedSkill.name })) + ) return result } diff --git a/apps/tradinggoose/lib/yjs/entity-session.ts b/apps/tradinggoose/lib/yjs/entity-session.ts index c18147d21..52b8e6273 100644 --- a/apps/tradinggoose/lib/yjs/entity-session.ts +++ b/apps/tradinggoose/lib/yjs/entity-session.ts @@ -36,6 +36,64 @@ export function getEntityMetadataMap(doc: Y.Doc): Y.Map { return doc.getMap('metadata') } +export interface EntityListMember { + entityId: string + entityName: string +} + +export type EntityListMemberMutation = + | { op: 'add'; entityId: string; name: string } + | { op: 'remove'; entityId: string } + +function getEntityListMembersMap(doc: Y.Doc): Y.Map<{ name: string; deleted?: boolean }> { + return doc.getMap('members') +} + +export function seedEntityListSession( + doc: Y.Doc, + members: Array<{ id: string; name: string }> +): void { + doc.transact(() => { + const listMembers = getEntityListMembersMap(doc) + for (const member of members) { + listMembers.set(member.id, { name: member.name }) + } + }, YJS_ORIGINS.SYSTEM) +} + +function applyEntityListMutation(doc: Y.Doc, mutation: EntityListMemberMutation): void { + doc.transact(() => { + getEntityListMembersMap(doc).set( + mutation.entityId, + mutation.op === 'add' ? { name: mutation.name, deleted: false } : { name: '', deleted: true } + ) + }, YJS_ORIGINS.SYSTEM) +} + +export function createEntityListMemberUpdate( + mutations: EntityListMemberMutation | EntityListMemberMutation[] +): Uint8Array { + const doc = new Y.Doc() + try { + for (const mutation of Array.isArray(mutations) ? mutations : [mutations]) { + applyEntityListMutation(doc, mutation) + } + return Y.encodeStateAsUpdate(doc) + } finally { + doc.destroy() + } +} + +export function getEntityListMembers(doc: Y.Doc): EntityListMember[] { + const entries: EntityListMember[] = [] + getEntityListMembersMap(doc).forEach((value, entityId) => { + if (value?.deleted) return + entries.push({ entityId, entityName: typeof value?.name === 'string' ? value.name : '' }) + }) + entries.sort((a, b) => a.entityName.localeCompare(b.entityName)) + return entries +} + /** * Metadata key carrying the workspace that owns the entity. Resolved once when * the entity doc is bootstrapped and used as the authoritative scope when @@ -107,6 +165,9 @@ export function seedEntitySession(doc: Y.Doc, options: EntitySessionSeedOptions) fields.set('name', payload.name ?? '') fields.set('description', payload.description ?? '') fields.set('chunkingConfig', payload.chunkingConfig) + fields.set('tokenCount', payload.tokenCount ?? 0) + fields.set('embeddingModel', payload.embeddingModel ?? 'text-embedding-3-small') + fields.set('embeddingDimension', payload.embeddingDimension ?? 1536) break case 'mcp_server': @@ -161,6 +222,9 @@ export function getEntityFields(doc: Y.Doc, entityKind: ReviewEntityKind): Recor result.name = fields.get('name') ?? '' result.description = fields.get('description') ?? '' result.chunkingConfig = fields.get('chunkingConfig') + result.tokenCount = fields.get('tokenCount') ?? 0 + result.embeddingModel = fields.get('embeddingModel') ?? 'text-embedding-3-small' + result.embeddingDimension = fields.get('embeddingDimension') ?? 1536 break case 'mcp_server': diff --git a/apps/tradinggoose/lib/yjs/entity-state.ts b/apps/tradinggoose/lib/yjs/entity-state.ts index d903beff3..ebdf0b186 100644 --- a/apps/tradinggoose/lib/yjs/entity-state.ts +++ b/apps/tradinggoose/lib/yjs/entity-state.ts @@ -2,7 +2,7 @@ import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' export type SavedEntityKind = Exclude -type SavedEntityRow = { +export type SavedEntityRow = { id: string workspaceId: string | null [key: string]: any @@ -41,6 +41,9 @@ export function savedEntityRowToFields( name: row.name ?? '', description: row.description ?? '', chunkingConfig: row.chunkingConfig, + tokenCount: row.tokenCount ?? 0, + embeddingModel: row.embeddingModel ?? 'text-embedding-3-small', + embeddingDimension: row.embeddingDimension ?? 1536, } case 'mcp_server': return { @@ -61,34 +64,3 @@ export function savedEntityRowToFields( } } } - -function parseEntitySchemaText(schemaText: unknown): unknown { - if (typeof schemaText !== 'string') { - return schemaText ?? {} - } - try { - return JSON.parse(schemaText) - } catch { - return {} - } -} - -/** - * Canonical inverse of {@link savedEntityRowToFields}. The Yjs document field - * names mirror the entity's editable row columns 1:1 for every kind, so the - * inverse is identity — except custom_tool, whose schema/code are stored as - * editable text (schemaText/codeText) and parsed back to row columns here. - */ -export function savedEntityFieldsToRow( - entityKind: SavedEntityKind, - fields: Record -): Record { - if (entityKind === 'custom_tool') { - return { - title: fields.title, - schema: parseEntitySchemaText(fields.schemaText), - code: fields.codeText, - } - } - return fields -} diff --git a/apps/tradinggoose/lib/yjs/provider.ts b/apps/tradinggoose/lib/yjs/provider.ts index 239958adf..b337a883c 100644 --- a/apps/tradinggoose/lib/yjs/provider.ts +++ b/apps/tradinggoose/lib/yjs/provider.ts @@ -19,7 +19,7 @@ export interface YjsProviderBootstrapResult { } const SOCKET_TOKEN_RETRY_MS = 1_000 -const WRITE_SYNC_TIMEOUT_MS = 10_000 +const SYNC_TIMEOUT_MS = 10_000 async function fetchSocketToken(): Promise { const res = await fetch('/api/auth/socket-token', { @@ -59,7 +59,7 @@ async function fetchSnapshot( return res.json() } -export function waitForYjsWriteSync(provider: WebsocketProvider): Promise { +export function waitForYjsSync(provider: WebsocketProvider): Promise { if (provider.synced) { return Promise.resolve() } @@ -85,10 +85,10 @@ export function waitForYjsWriteSync(provider: WebsocketProvider): Promise } const handleConnectionFailure = () => { - finish(new Error('Failed to establish authorized Yjs write sync')) + finish(new Error('Failed to establish authorized Yjs sync')) } - timeout = setTimeout(handleConnectionFailure, WRITE_SYNC_TIMEOUT_MS) + timeout = setTimeout(handleConnectionFailure, SYNC_TIMEOUT_MS) provider.on('sync', handleSync) provider.on('connection-close', handleConnectionFailure) provider.on('connection-error', handleConnectionFailure) @@ -170,7 +170,7 @@ export async function bootstrapYjsProvider( }) try { - await waitForYjsWriteSync(provider) + await waitForYjsSync(provider) } catch (error) { provider.disconnect() provider.destroy() diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts index d6094c456..ee04d1d87 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts @@ -42,6 +42,7 @@ vi.mock('drizzle-orm', () => ({ vi.mock('@/lib/copilot/entity-documents', () => ({ normalizeEntityFields: vi.fn((_entityKind, fields) => fields), + getEntityDocumentName: vi.fn((_entityKind, fields) => String(fields?.name ?? '')), })) vi.mock('@/lib/custom-tools/schema', () => ({ @@ -50,6 +51,7 @@ vi.mock('@/lib/custom-tools/schema', () => ({ vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ applyEntityStateInSocketServer: mockApplyEntityStateInSocketServer, + notifyEntityListMembersAdded: vi.fn(), })) function buildDoc(fields: Record, workspaceId: string | null = 'workspace-1') { diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index 769ce1520..ef17ae873 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -8,11 +8,14 @@ import { } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import type * as Y from 'yjs' -import { normalizeEntityFields } from '@/lib/copilot/entity-documents' +import { getEntityDocumentName, normalizeEntityFields } from '@/lib/copilot/entity-documents' import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' import { getEntityFields, getEntityWorkspaceId } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' -import { applyEntityStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { + applyEntityStateInSocketServer, + notifyEntityListMembersAdded, +} from '@/lib/yjs/server/snapshot-bridge' const SAVED_ENTITY_REALTIME_REQUIRED_CODE = 'SAVED_ENTITY_REALTIME_REQUIRED' @@ -208,4 +211,7 @@ export async function saveSavedEntityYjsDocToDb( ) } await persistSavedEntityState(entityKind, entityId, yjsFields, workspaceId) + await notifyEntityListMembersAdded(entityKind, workspaceId, [ + { id: entityId, name: getEntityDocumentName(entityKind, yjsFields) }, + ]) } diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.test.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.test.ts deleted file mode 100644 index ad551ae3b..000000000 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import * as Y from 'yjs' - -const mocks = vi.hoisted(() => { - class SocketServerBridgeError extends Error { - constructor( - public status: number, - public body: string - ) { - super(`Bridge failed: ${status}`) - } - } - - return { getEntityFields: vi.fn(), getYjsSnapshot: vi.fn(), SocketServerBridgeError } -}) - -vi.mock('@/lib/workflows/db-helpers', () => ({ loadWorkflowBootstrapStateFromDb: vi.fn() })) -vi.mock('@/lib/yjs/entity-session', () => ({ - getEntityFields: (...args: unknown[]) => mocks.getEntityFields(...args), - seedEntitySession: vi.fn(), -})) -vi.mock('@/lib/yjs/server/entity-loaders', () => ({ - readSavedEntityFieldsFromDb: vi.fn(), - resolveEntityWorkspaceId: vi.fn(), -})) -vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ - getYjsSnapshot: (...args: unknown[]) => mocks.getYjsSnapshot(...args), - SocketServerBridgeError: mocks.SocketServerBridgeError, -})) - -const snapshot = () => { - const doc = new Y.Doc() - const snapshotBase64 = Buffer.from(Y.encodeStateAsUpdate(doc)).toString('base64') - doc.destroy() - return { snapshotBase64 } -} - -describe('buildSavedEntityListThroughYjs', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.getEntityFields.mockReturnValue({ name: 'live' }) - mocks.getYjsSnapshot.mockResolvedValue(snapshot()) - }) - - it('skips row-local missing snapshots and invalid projections', async () => { - mocks.getYjsSnapshot - .mockRejectedValueOnce(new mocks.SocketServerBridgeError(404, 'missing')) - .mockResolvedValueOnce(snapshot()) - .mockResolvedValueOnce(snapshot()) - - const { buildSavedEntityListThroughYjs } = await import('./bootstrap-review-target') - const result = await buildSavedEntityListThroughYjs( - 'skill', - [ - { id: 'missing', workspaceId: 'workspace-1' }, - { id: 'invalid', workspaceId: 'workspace-1' }, - { id: 'live', workspaceId: 'workspace-1' }, - ], - (row, fields) => { - if (row.id === 'invalid') throw new Error('invalid fields') - return { id: row.id, name: fields.name } - } - ) - - expect(result).toEqual([{ id: 'live', name: 'live' }]) - }) - - it('keeps realtime bridge outages as list-level failures', async () => { - mocks.getYjsSnapshot.mockRejectedValue(new mocks.SocketServerBridgeError(500, 'down')) - - const { buildSavedEntityListThroughYjs } = await import('./bootstrap-review-target') - - await expect( - buildSavedEntityListThroughYjs('skill', [{ id: 'skill-1', workspaceId: 'workspace-1' }]) - ).rejects.toThrow('Bridge failed: 500') - }) -}) diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index ab6129511..6122f5e63 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -1,5 +1,6 @@ import * as Y from 'yjs' import { + buildEntityListDescriptor, buildSavedEntityDescriptor, buildYjsTransportEnvelope, serializeYjsTransportEnvelope, @@ -11,13 +12,20 @@ import type { ReviewTargetRuntimeState, } from '@/lib/copilot/review-sessions/types' import { loadWorkflowBootstrapStateFromDb } from '@/lib/workflows/db-helpers' -import { getEntityFields, seedEntitySession } from '@/lib/yjs/entity-session' -import { type SavedEntityKind, savedEntityFieldsToRow } from '@/lib/yjs/entity-state' import { + type EntityListMember, + getEntityFields, + getEntityListMembers, + seedEntityListSession, + seedEntitySession, +} from '@/lib/yjs/entity-session' +import type { SavedEntityKind } from '@/lib/yjs/entity-state' +import { + readEntityListMembersFromDb, readSavedEntityFieldsFromDb, resolveEntityWorkspaceId, } from '@/lib/yjs/server/entity-loaders' -import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' +import { getYjsSnapshot } from '@/lib/yjs/server/snapshot-bridge' import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' import { createWorkflowSnapshot, @@ -55,64 +63,58 @@ export async function readBootstrappedReviewTargetSnapshot(descriptor: ReviewTar return getYjsSnapshot(descriptor.yjsSessionId, bridgeParams) } -export async function readBootstrappedSavedEntityFields( +export async function readBootstrappedEntityListMembers( entityKind: SavedEntityKind, - entityId: string, workspaceId: string -): Promise> { +): Promise { const snapshot = await readBootstrappedReviewTargetSnapshot( - buildSavedEntityDescriptor(entityKind, entityId, workspaceId) + buildEntityListDescriptor(entityKind, workspaceId) ) if (!snapshot.snapshotBase64) { - throw new ReviewTargetBootstrapError(404, `Saved ${entityKind} ${entityId} state is missing`) + return [] } const doc = new Y.Doc() try { Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) - return getEntityFields(doc, entityKind) + return getEntityListMembers(doc) } finally { doc.destroy() } } -/** - * Canonical list-through-Yjs primitive for existing saved entities. Row-local - * missing/expired snapshots and invalid projections are skipped; realtime - * bridge failures still fail the list because saved-entity reads require Yjs. - */ -export async function buildSavedEntityListThroughYjs< - TRow extends { id: string; workspaceId: string }, - TEntry = TRow, ->( +export async function readBootstrappedSavedEntityListFields( entityKind: SavedEntityKind, - rows: TRow[], - buildEntry: (row: TRow, fields: Record) => TEntry = (row, fields) => - ({ ...row, ...savedEntityFieldsToRow(entityKind, fields) }) as TEntry -): Promise { - const entries: Array = await Promise.all( - rows.map(async (row): Promise => { - let fields: Record - try { - fields = await readBootstrappedSavedEntityFields(entityKind, row.id, row.workspaceId) - } catch (error) { - const status = - error instanceof ReviewTargetBootstrapError || error instanceof SocketServerBridgeError - ? error.status - : null - if (status === 404 || status === 410) return null - throw error - } + workspaceId: string +): Promise }>> { + const members = await readBootstrappedEntityListMembers(entityKind, workspaceId) + return Promise.all( + members.map(async (member) => ({ + ...member, + fields: await readBootstrappedSavedEntityFields(entityKind, member.entityId, workspaceId), + })) + ) +} - try { - return buildEntry(row, fields) - } catch { - return null - } - }) +export async function readBootstrappedSavedEntityFields( + entityKind: SavedEntityKind, + entityId: string, + workspaceId: string +): Promise> { + const snapshot = await readBootstrappedReviewTargetSnapshot( + buildSavedEntityDescriptor(entityKind, entityId, workspaceId) ) + if (!snapshot.snapshotBase64) { + throw new ReviewTargetBootstrapError(404, `Saved ${entityKind} ${entityId} state is missing`) + } - return entries.filter((entry): entry is TEntry => entry !== null) + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) + return getEntityFields(doc, entityKind) + } finally { + doc.destroy() + } } export async function createSavedReviewTargetBootstrapUpdate( @@ -193,3 +195,26 @@ export async function createSavedReviewTargetBootstrapUpdate( doc.destroy() } } + +export async function createEntityListBootstrapUpdate( + entityKind: SavedEntityKind, + workspaceId: string +): Promise { + const descriptor = buildEntityListDescriptor(entityKind, workspaceId) + const doc = new Y.Doc() + try { + seedEntityListSession(doc, await readEntityListMembersFromDb(entityKind, workspaceId)) + + const metadata = getMetadataMap(doc) + metadata.set('reseededFromCanonical', true) + const state = Y.encodeStateAsUpdate(doc) + + return { + descriptor, + runtime: getRuntimeStateFromUpdate(state), + state, + } + } finally { + doc.destroy() + } +} diff --git a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts index e68c6113f..85f2b3e44 100644 --- a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts +++ b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts @@ -6,27 +6,29 @@ import { pineIndicators, skill, } from '@tradinggoose/db/schema' -import { and, eq, isNull } from 'drizzle-orm' -import { type SavedEntityKind, savedEntityRowToFields } from '@/lib/yjs/entity-state' +import { and, eq, isNull, type SQL } from 'drizzle-orm' +import { + type SavedEntityKind, + type SavedEntityRow, + savedEntityRowToFields, +} from '@/lib/yjs/entity-state' const ENTITY_TABLES = { - skill, - custom_tool: customTools, - indicator: pineIndicators, - knowledge_base: knowledgeBase, - mcp_server: mcpServers, + skill: { table: skill, name: skill.name }, + custom_tool: { table: customTools, name: customTools.title }, + indicator: { table: pineIndicators, name: pineIndicators.name }, + knowledge_base: { table: knowledgeBase, name: knowledgeBase.name, softDelete: true }, + mcp_server: { table: mcpServers, name: mcpServers.name, softDelete: true }, } as const -function entityTable(entityKind: SavedEntityKind) { +function entityConfig(entityKind: SavedEntityKind) { return ENTITY_TABLES[entityKind] as any } -function entityIdCondition(entityKind: SavedEntityKind, entityId: string) { - const table = entityTable(entityKind) - const byId = eq(table.id, entityId) - return entityKind === 'knowledge_base' || entityKind === 'mcp_server' - ? and(byId, isNull(table.deletedAt)) - : byId +function entityCondition(entityKind: SavedEntityKind, clauses: SQL[]): SQL | undefined { + const { table, softDelete } = entityConfig(entityKind) + const conditions = softDelete ? [...clauses, isNull(table.deletedAt)] : clauses + return conditions.length === 1 ? conditions[0] : and(...conditions) } class SavedEntityLoadError extends Error { @@ -42,75 +44,45 @@ export async function resolveEntityWorkspaceId( entityKind: SavedEntityKind, entityId: string ): Promise { - const table = entityTable(entityKind) + const { table } = entityConfig(entityKind) const [row] = await db .select({ workspaceId: table.workspaceId }) .from(table) - .where(entityIdCondition(entityKind, entityId)) + .where(entityCondition(entityKind, [eq(table.id, entityId)])) .limit(1) return row?.workspaceId ?? null } +export async function readEntityListMembersFromDb( + entityKind: SavedEntityKind, + workspaceId: string +): Promise> { + const { table, name } = entityConfig(entityKind) + const rows: Array<{ id: string; name: string | null }> = await db + .select({ id: table.id, name }) + .from(table) + .where(entityCondition(entityKind, [eq(table.workspaceId, workspaceId)])) + + return rows.map((row) => ({ id: row.id, name: row.name ?? '' })) +} + export async function readSavedEntityFieldsFromDb( entityKind: SavedEntityKind, entityId: string, workspaceId: string ): Promise> { - let row: Parameters[1] | undefined - - switch (entityKind) { - case 'skill': - ;[row] = await db - .select() - .from(skill) - .where(and(eq(skill.id, entityId), eq(skill.workspaceId, workspaceId))) - .limit(1) - break - case 'custom_tool': - ;[row] = await db - .select() - .from(customTools) - .where(and(eq(customTools.id, entityId), eq(customTools.workspaceId, workspaceId))) - .limit(1) - break - case 'indicator': - ;[row] = await db - .select() - .from(pineIndicators) - .where(and(eq(pineIndicators.id, entityId), eq(pineIndicators.workspaceId, workspaceId))) - .limit(1) - break - case 'knowledge_base': - ;[row] = await db - .select() - .from(knowledgeBase) - .where( - and( - eq(knowledgeBase.id, entityId), - eq(knowledgeBase.workspaceId, workspaceId), - isNull(knowledgeBase.deletedAt) - ) - ) - .limit(1) - break - case 'mcp_server': - ;[row] = await db - .select() - .from(mcpServers) - .where( - and( - eq(mcpServers.id, entityId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) - ) - .limit(1) - break - } + const { table } = entityConfig(entityKind) + const [row] = await db + .select() + .from(table) + .where( + entityCondition(entityKind, [eq(table.id, entityId), eq(table.workspaceId, workspaceId)]) + ) + .limit(1) if (!row) { throw new SavedEntityLoadError(`Saved ${entityKind} ${entityId} was not found`) } - return savedEntityRowToFields(entityKind, row) + return savedEntityRowToFields(entityKind, row as SavedEntityRow) } diff --git a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts index 36bd484fe..05666ee33 100644 --- a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts +++ b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts @@ -1,8 +1,18 @@ +import { + buildEntityListDescriptor, + buildYjsTransportEnvelope, + serializeYjsTransportEnvelope, +} from '@/lib/copilot/review-sessions/identity' import type { ReviewTargetDescriptor, ReviewTargetRuntimeState, } from '@/lib/copilot/review-sessions/types' import { env, getInternalRealtimeUrl } from '@/lib/env' +import { + createEntityListMemberUpdate, + type EntityListMemberMutation, +} from '@/lib/yjs/entity-session' +import type { SavedEntityKind } from '@/lib/yjs/entity-state' import type { WorkflowMetadataPatch, WorkflowSnapshot } from '@/lib/yjs/workflow-session' export interface YjsSnapshotResponse { @@ -149,6 +159,44 @@ export async function applyYjsUpdateInSocketServer( ) } +async function applyEntityListUpdateInSocketServer( + entityKind: SavedEntityKind, + workspaceId: string, + mutation: EntityListMemberMutation | EntityListMemberMutation[] +): Promise { + const descriptor = buildEntityListDescriptor(entityKind, workspaceId) + const params = serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor)) + const search = new URLSearchParams(params).toString() + await applyYjsUpdateInSocketServer( + descriptor.yjsSessionId, + `?${search}`, + Buffer.from(createEntityListMemberUpdate(mutation)).toString('base64') + ) +} + +export async function notifyEntityListMembersAdded( + entityKind: SavedEntityKind, + workspaceId: string, + members: Array<{ id: string; name: string }> +): Promise { + await applyEntityListUpdateInSocketServer( + entityKind, + workspaceId, + members.map((member) => ({ op: 'add', entityId: member.id, name: member.name })) + ).catch(() => undefined) +} + +export async function notifyEntityListMemberRemoved( + entityKind: SavedEntityKind, + workspaceId: string, + entityId: string +): Promise { + await applyEntityListUpdateInSocketServer(entityKind, workspaceId, { + op: 'remove', + entityId, + }).catch(() => undefined) +} + export async function deleteYjsSessionInSocketServer(sessionId: string): Promise { await fetchFromSocketServer( new URL(`/internal/yjs/sessions/${encodeURIComponent(sessionId)}`, getSocketServerUrl()), diff --git a/apps/tradinggoose/lib/yjs/workflow-shared-session.test.ts b/apps/tradinggoose/lib/yjs/workflow-shared-session.test.ts index d0aca6f7d..ffca6863b 100644 --- a/apps/tradinggoose/lib/yjs/workflow-shared-session.test.ts +++ b/apps/tradinggoose/lib/yjs/workflow-shared-session.test.ts @@ -3,13 +3,13 @@ import * as Y from 'yjs' import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' const mockBootstrapYjsProvider = vi.fn() -const mockWaitForYjsWriteSync = vi.fn() +const mockWaitForYjsSync = vi.fn() const mockRegisterWorkflowSession = vi.fn() const mockUnregisterWorkflowSession = vi.fn() vi.mock('@/lib/yjs/provider', () => ({ bootstrapYjsProvider: (...args: any[]) => mockBootstrapYjsProvider(...args), - waitForYjsWriteSync: (...args: any[]) => mockWaitForYjsWriteSync(...args), + waitForYjsSync: (...args: any[]) => mockWaitForYjsSync(...args), })) vi.mock('@/lib/yjs/workflow-session-registry', () => ({ @@ -84,8 +84,8 @@ describe('workflow shared session lifecycle', () => { vi.resetModules() vi.useFakeTimers() mockBootstrapYjsProvider.mockReset() - mockWaitForYjsWriteSync.mockReset() - mockWaitForYjsWriteSync.mockResolvedValue(undefined) + mockWaitForYjsSync.mockReset() + mockWaitForYjsSync.mockResolvedValue(undefined) mockRegisterWorkflowSession.mockReset() mockUnregisterWorkflowSession.mockReset() globalThis.__workflowYjsSessionEntries = undefined @@ -176,7 +176,7 @@ describe('workflow shared session lifecycle', () => { workflowId: 'workflow-1', workspaceId: 'workspace-1', }) - expect(mockWaitForYjsWriteSync).toHaveBeenCalledWith(provider) + expect(mockWaitForYjsSync).toHaveBeenCalledWith(provider) expect(writeLease.session.entityName).toBe('Workflow 1') expect(writeLease.session.workspaceId).toBe('workspace-1') writeLease.release() diff --git a/apps/tradinggoose/lib/yjs/workflow-shared-session.ts b/apps/tradinggoose/lib/yjs/workflow-shared-session.ts index 40b125542..3ee97fbf3 100644 --- a/apps/tradinggoose/lib/yjs/workflow-shared-session.ts +++ b/apps/tradinggoose/lib/yjs/workflow-shared-session.ts @@ -6,7 +6,7 @@ import type { ReviewTargetDescriptor } from '@/lib/copilot/review-sessions/types import { deriveUserColor } from '@/lib/utils' import { bootstrapYjsProvider, - waitForYjsWriteSync, + waitForYjsSync, type YjsProviderBootstrapResult, } from '@/lib/yjs/provider' import { createYjsUndoTrackedOrigins } from '@/lib/yjs/transaction-origins' @@ -343,7 +343,7 @@ export async function acquireWritableWorkflowSessionLease(args: { } try { - await waitForYjsWriteSync(entry.result.provider) + await waitForYjsSync(entry.result.provider) } catch (error) { release() throw error diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 59e27bcd7..e3e75697d 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -596,7 +596,7 @@ describe('Socket Server Index Integration', () => { const response = await sendHttpRequestWithOptions( PORT, - '/internal/yjs/sessions/workflow-state-update/snapshot?targetKind=workflow&sessionId=workflow-state-update&workflowId=workflow-state-update&entityKind=workflow&entityId=workflow-state-update', + '/internal/yjs/sessions/workflow-state-update/snapshot?targetKind=entity&sessionId=workflow-state-update&entityKind=workflow&entityId=workflow-state-update', { method: 'GET', headers: { @@ -642,7 +642,7 @@ describe('Socket Server Index Integration', () => { it('should bootstrap a saved workflow snapshot into a live Yjs document', async () => { const response = await sendHttpRequestWithOptions( PORT, - '/internal/yjs/sessions/missing-workflow/snapshot?targetKind=workflow&sessionId=missing-workflow&workflowId=missing-workflow&entityKind=workflow&entityId=missing-workflow', + '/internal/yjs/sessions/missing-workflow/snapshot?targetKind=entity&sessionId=missing-workflow&entityKind=workflow&entityId=missing-workflow', { method: 'GET', headers: { diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index b84facc9d..0ffb03608 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -3,6 +3,7 @@ import * as Y from 'yjs' import { buildReviewTargetDescriptorFromEnvelope, buildSavedEntityDescriptor, + isEntityListSessionId, parseYjsTransportEnvelope, } from '@/lib/copilot/review-sessions/identity' import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' @@ -14,6 +15,7 @@ import { saveSavedEntityYjsDocToDb, } from '@/lib/yjs/server/apply-entity-state' import { + createEntityListBootstrapUpdate, createSavedReviewTargetBootstrapUpdate, getRuntimeStateFromDoc, } from '@/lib/yjs/server/bootstrap-review-target' @@ -306,7 +308,8 @@ function applyWorkflowApplyRequest(doc: Y.Doc, body: ApplyWorkflowStateRequest): replaceWorkflowDocumentState(doc, body.workflowState, body.variables, body.metadata) return } - if (body.variables !== undefined) replaceWorkflowVariables(doc, body.variables, YJS_ORIGINS.SYSTEM) + if (body.variables !== undefined) + replaceWorkflowVariables(doc, body.variables, YJS_ORIGINS.SYSTEM) if (body.metadata) setWorkflowEntityMetadata(doc, body.metadata) } @@ -401,6 +404,19 @@ async function handleInternalYjsSessionApplyUpdateRequest( if (typeof updateBase64 !== 'string' || !updateBase64) { throw new InvalidInternalYjsRequestError('updateBase64 is required') } + if (isEntityListSessionId(descriptor.yjsSessionId)) { + const liveDoc = await getExistingDocument(descriptor.yjsSessionId) + if (!liveDoc) { + sendJson(res, 200, { success: true, applied: false }) + return + } + Y.applyUpdate(liveDoc, Buffer.from(updateBase64, 'base64'), YJS_ORIGINS.SYSTEM) + markDocumentPersisted(liveDoc) + discardDocumentIfIdle(sessionId) + sendJson(res, 200, { success: true, applied: true }) + return + } + const doc = await getBootstrappedApplyDocument(descriptor) try { @@ -457,14 +473,23 @@ async function handleInternalYjsSnapshotRequest( try { let liveDoc = await getExistingDocument(sessionId) let bootstrappedForRequest = false - if (!liveDoc && descriptor.entityId) { - const bootstrapped = await createSavedReviewTargetBootstrapUpdate(descriptor) - if (!bootstrapped.runtime || bootstrapped.runtime.docState !== 'active') { - sendJson(res, 410, { error: 'Session expired', sessionId }) - return + if (!liveDoc) { + const bootstrapped = isEntityListSessionId(descriptor.yjsSessionId) + ? await createEntityListBootstrapUpdate( + descriptor.entityKind as SavedEntityKind, + descriptor.workspaceId as string + ) + : descriptor.entityId + ? await createSavedReviewTargetBootstrapUpdate(descriptor) + : null + if (bootstrapped) { + if (!bootstrapped.runtime || bootstrapped.runtime.docState !== 'active') { + sendJson(res, 410, { error: 'Session expired', sessionId }) + return + } + liveDoc = await getInitializedSessionDocument(sessionId, bootstrapped.state) + bootstrappedForRequest = true } - liveDoc = await getInitializedSessionDocument(sessionId, bootstrapped.state) - bootstrappedForRequest = true } if (!liveDoc) { diff --git a/apps/tradinggoose/socket-server/yjs/auth.test.ts b/apps/tradinggoose/socket-server/yjs/auth.test.ts index 7f9ac93db..719b83f76 100644 --- a/apps/tradinggoose/socket-server/yjs/auth.test.ts +++ b/apps/tradinggoose/socket-server/yjs/auth.test.ts @@ -32,7 +32,7 @@ describe('authenticateYjsConnection', () => { await expect( authenticateYjsConnection( new URL( - 'http://localhost:3002/yjs/workflow-1?token=test-token&targetKind=workflow&sessionId=workflow-1&workflowId=workflow-1&entityKind=workflow&entityId=workflow-1' + 'http://localhost:3002/yjs/workflow-1?token=test-token&targetKind=entity&sessionId=workflow-1&entityKind=workflow&entityId=workflow-1' ) ) ).rejects.toMatchObject({ diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts index 71e453b22..1f422e720 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts @@ -33,7 +33,7 @@ class MockYjsAuthError extends Error { function createRequest(sessionId: string, accessMode: 'read' | 'write' = 'write'): IncomingMessage { return { - url: `/yjs/${encodeURIComponent(sessionId)}?token=test-token&accessMode=${accessMode}&targetKind=workflow&sessionId=${encodeURIComponent(sessionId)}&workflowId=${encodeURIComponent(sessionId)}&entityKind=workflow&entityId=${encodeURIComponent(sessionId)}`, + url: `/yjs/${encodeURIComponent(sessionId)}?token=test-token&accessMode=${accessMode}&targetKind=entity&sessionId=${encodeURIComponent(sessionId)}&entityKind=workflow&entityId=${encodeURIComponent(sessionId)}`, headers: { host: 'localhost:3000' }, } as IncomingMessage } @@ -150,9 +150,8 @@ describe('handleYjsUpgrade', () => { userId: 'user-1', userName: 'User One', envelope: { - targetKind: 'workflow', + targetKind: 'entity', sessionId, - workflowId: sessionId, reviewSessionId: null, workspaceId: 'workspace-1', entityKind: 'workflow', @@ -189,9 +188,8 @@ describe('handleYjsUpgrade', () => { userId: 'user-2', userName: 'User Two', envelope: { - targetKind: 'workflow', + targetKind: 'entity', sessionId, - workflowId: sessionId, reviewSessionId: null, workspaceId: 'workspace-2', entityKind: 'workflow', @@ -273,7 +271,6 @@ describe('handleYjsUpgrade', () => { envelope: { targetKind: 'review_session', sessionId, - workflowId: null, reviewSessionId: sessionId, workspaceId: 'workspace-3', entityKind: 'skill', diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.ts index 652e52d67..8b9e15e4f 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.ts @@ -2,11 +2,17 @@ import type { IncomingMessage } from 'http' import type { Duplex } from 'stream' import type { WebSocket, WebSocketServer } from 'ws' import type * as Y from 'yjs' -import { buildReviewTargetDescriptorFromEnvelope } from '@/lib/copilot/review-sessions/identity' +import { + buildReviewTargetDescriptorFromEnvelope, + isEntityListSessionId, +} from '@/lib/copilot/review-sessions/identity' import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' +import type { ReviewAccessMode } from '@/lib/copilot/review-sessions/types' import { createLogger } from '@/lib/logs/console/logger' import { saveWorkflowYjsDocToDb } from '@/lib/workflows/db-helpers' +import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { + createEntityListBootstrapUpdate, createSavedReviewTargetBootstrapUpdate, getRuntimeStateFromDoc, } from '@/lib/yjs/server/bootstrap-review-target' @@ -22,6 +28,10 @@ interface YjsIncomingMessage extends IncomingMessage { } async function persistIdleDocument(docId: string, doc: Y.Doc): Promise { + if (isEntityListSessionId(docId)) { + return + } + const metadata = doc.getMap('metadata') if ( metadata.get('entityId') !== docId || @@ -84,7 +94,7 @@ async function authenticateAndPrepareUpgrade( pathSessionId: string, url: URL ): Promise<{ bootstrapState?: Uint8Array; userId: string; resolvedSessionId: string }> { - const accessMode = parseAccessMode(url) + const accessMode = parseAccessMode(url, pathSessionId) const { userId, envelope } = await authenticateYjsConnection(url) if (envelope.sessionId !== pathSessionId) { @@ -92,6 +102,7 @@ async function authenticateAndPrepareUpgrade( } const descriptor = buildReviewTargetDescriptorFromEnvelope(envelope) + const isListTarget = isEntityListSessionId(descriptor.yjsSessionId) const access = await verifyReviewTargetAccess( userId, @@ -113,9 +124,14 @@ async function authenticateAndPrepareUpgrade( const liveDoc = await getExistingDocument(pathSessionId) const bootstrapped = liveDoc ? null - : descriptor.entityId - ? await createSavedReviewTargetBootstrapUpdate(descriptor) - : null + : isListTarget + ? await createEntityListBootstrapUpdate( + descriptor.entityKind as SavedEntityKind, + descriptor.workspaceId as string + ) + : descriptor.entityId + ? await createSavedReviewTargetBootstrapUpdate(descriptor) + : null const runtime = liveDoc ? getRuntimeStateFromDoc(liveDoc) : bootstrapped?.runtime if (!runtime) { @@ -133,17 +149,23 @@ async function authenticateAndPrepareUpgrade( } } -function parseAccessMode(url: URL): 'write' { +function parseAccessMode(url: URL, sessionId: string): ReviewAccessMode { const accessMode = url.searchParams.get('accessMode') if (accessMode !== 'read' && accessMode !== 'write') { throw new YjsAuthError(409, 'Invalid or missing access mode') } - if (accessMode !== 'write') { + // Entity-list documents are read-only over the socket (membership is written + // only by server-side create/delete); every other target requires write. + if (isEntityListSessionId(sessionId)) { + if (accessMode !== 'read') { + throw new YjsAuthError(403, 'Entity-list websocket is read-only') + } + } else if (accessMode !== 'write') { throw new YjsAuthError(403, 'Yjs websocket requires write access') } - return 'write' + return accessMode } function ensureConnectionHandler(wss: WebSocketServer): void { diff --git a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx index c4820d423..ce550140f 100644 --- a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx @@ -2,7 +2,7 @@ import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Plus, Upload, Wrench } from 'lucide-react' -import { useLocale } from 'next-intl' +import { useLocale, useMessages } from 'next-intl' import { DropdownMenu, DropdownMenuContent, @@ -19,8 +19,6 @@ import { widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, } from '@/components/widget-header-control' -import { useMessages } from 'next-intl' -import type { LocaleCode } from '@/i18n/utils' import { parseImportedCustomToolsFile } from '@/lib/custom-tools/import-export' import { cn } from '@/lib/utils' import { @@ -34,6 +32,7 @@ import { useImportCustomTools, useUpdateCustomTool, } from '@/hooks/queries/custom-tools' +import type { LocaleCode } from '@/i18n/utils' import { useCustomToolsStore } from '@/stores/custom-tools/store' import type { CustomToolDefinition } from '@/stores/custom-tools/types' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' @@ -62,9 +61,7 @@ const sortCustomTools = (tools: CustomToolDefinition[]) => const buildNewCustomToolDraft = (tools: CustomToolDefinition[]) => { const existingTitles = new Set( - tools - .map((tool) => tool.title.trim()) - .filter((title): title is string => Boolean(title)) + tools.map((tool) => tool.title.trim()).filter((title): title is string => Boolean(title)) ) let nextTitle = DEFAULT_CUSTOM_TOOL_NAME diff --git a/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-create-menu.tsx b/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-create-menu.tsx index 915ec00f6..6ee10f40e 100644 --- a/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-create-menu.tsx +++ b/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-create-menu.tsx @@ -2,7 +2,7 @@ import { type ChangeEvent, useCallback, useRef } from 'react' import { Plus, Upload } from 'lucide-react' -import { useLocale } from 'next-intl' +import { useLocale, useMessages } from 'next-intl' import { DropdownMenu, DropdownMenuContent, @@ -10,8 +10,6 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { useMessages } from 'next-intl' -import { cn } from '@/lib/utils' import { widgetHeaderIconButtonClassName, widgetHeaderMenuContentClassName, @@ -19,6 +17,7 @@ import { widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, } from '@/components/widget-header-control' +import { cn } from '@/lib/utils' interface IndicatorCreateMenuProps { disabled?: boolean diff --git a/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/components/indicator-list-item.tsx b/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/components/indicator-list-item.tsx index 0fc8b85b4..ddcd905f4 100644 --- a/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/components/indicator-list-item.tsx +++ b/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/components/indicator-list-item.tsx @@ -1,8 +1,8 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import { Copy, Activity, Pencil, Trash2 } from 'lucide-react' -import { useLocale } from 'next-intl' +import { Activity, Copy, Pencil, Trash2 } from 'lucide-react' +import { useLocale, useMessages } from 'next-intl' import { AlertDialog, AlertDialogCancel, @@ -14,7 +14,6 @@ import { } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { useMessages } from 'next-intl' import { cn } from '@/lib/utils' import type { IndicatorDefinition } from '@/stores/indicators/types' diff --git a/apps/tradinggoose/widgets/widgets/list_indicator/index.test.tsx b/apps/tradinggoose/widgets/widgets/list_indicator/index.test.tsx index 45816566f..5a4d47c60 100644 --- a/apps/tradinggoose/widgets/widgets/list_indicator/index.test.tsx +++ b/apps/tradinggoose/widgets/widgets/list_indicator/index.test.tsx @@ -166,7 +166,15 @@ describe('Indicator List header controls', () => { expect(mutateAsync).toHaveBeenCalledWith({ workspaceId: 'workspace-1', - file: filePayload, + file: { + ...filePayload, + indicators: [ + { + name: 'RSI Export Example', + pineCode: "indicator('RSI Export Example')", + }, + ], + }, }) }) diff --git a/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx b/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx index e14d9fcec..f4720724c 100644 --- a/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx @@ -2,8 +2,8 @@ import { useCallback } from 'react' import { ListChecks } from 'lucide-react' +import { useLocale, useMessages } from 'next-intl' import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' -import { useLocale } from 'next-intl' import { parseImportedIndicatorsFile } from '@/lib/indicators/import-export' import { useUserPermissionsContext, @@ -16,17 +16,13 @@ import type { IndicatorDefinition } from '@/stores/indicators/types' import type { PairColor } from '@/widgets/pair-colors' import type { DashboardWidgetDefinition, WidgetComponentProps } from '@/widgets/types' import { emitIndicatorSelectionChange } from '@/widgets/utils/indicator-selection' -import { useMessages } from 'next-intl' import { IndicatorCreateMenu } from '@/widgets/widgets/list_indicator/components/indicator-create-menu' import { IndicatorList, IndicatorListMessage, } from '@/widgets/widgets/list_indicator/components/indicator-list/indicator-list' -const buildNewIndicator = ( - indicators: IndicatorDefinition[], - defaults: { name: string } -) => { +const buildNewIndicator = (indicators: IndicatorDefinition[], defaults: { name: string }) => { const existingNames = new Set( indicators.map((indicator) => indicator.name.trim()).filter((name) => name.length > 0) ) diff --git a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx index 24c88f50c..2d8f68d05 100644 --- a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Pencil, Plus, Server, Trash2 } from 'lucide-react' +import { useMessages } from 'next-intl' import { shallow } from 'zustand/shallow' import { AlertDialog, @@ -35,7 +36,6 @@ import { WorkspacePermissionsProvider, } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useMcpTools } from '@/hooks/use-mcp-tools' -import { useMessages } from 'next-intl' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import { useMcpServersStore } from '@/stores/mcp-servers/store' import type { McpServerWithStatus } from '@/stores/mcp-servers/types' diff --git a/apps/tradinggoose/widgets/widgets/list_skill/components/skill-create-menu.tsx b/apps/tradinggoose/widgets/widgets/list_skill/components/skill-create-menu.tsx index 4ad563bf3..b9902c970 100644 --- a/apps/tradinggoose/widgets/widgets/list_skill/components/skill-create-menu.tsx +++ b/apps/tradinggoose/widgets/widgets/list_skill/components/skill-create-menu.tsx @@ -2,7 +2,7 @@ import { type ChangeEvent, useCallback, useRef } from 'react' import { Plus, Upload } from 'lucide-react' -import { useLocale } from 'next-intl' +import { useLocale, useMessages } from 'next-intl' import { DropdownMenu, DropdownMenuContent, @@ -10,8 +10,6 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { useMessages } from 'next-intl' -import { cn } from '@/lib/utils' import { widgetHeaderIconButtonClassName, widgetHeaderMenuContentClassName, @@ -19,6 +17,7 @@ import { widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, } from '@/components/widget-header-control' +import { cn } from '@/lib/utils' interface SkillCreateMenuProps { disabled?: boolean diff --git a/apps/tradinggoose/widgets/widgets/list_skill/components/skill-list/skill-list.tsx b/apps/tradinggoose/widgets/widgets/list_skill/components/skill-list/skill-list.tsx index b84505984..623c770c4 100644 --- a/apps/tradinggoose/widgets/widgets/list_skill/components/skill-list/skill-list.tsx +++ b/apps/tradinggoose/widgets/widgets/list_skill/components/skill-list/skill-list.tsx @@ -1,12 +1,11 @@ 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' -import { useLocale } from 'next-intl' +import { useLocale, useMessages } from 'next-intl' import { LoadingAgent } from '@/components/ui/loading-agent' import { SKILL_NAME_MAX_LENGTH } from '@/lib/skills/import-export' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useDeleteSkill, useSkills, useUpdateSkill } from '@/hooks/queries/skills' -import { useMessages } from 'next-intl' import { formatTemplate } from '@/i18n/utils' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import { useSkillsStore } from '@/stores/skills/store' diff --git a/apps/tradinggoose/widgets/widgets/list_skill/index.tsx b/apps/tradinggoose/widgets/widgets/list_skill/index.tsx index 39ceccd8f..4609f2141 100644 --- a/apps/tradinggoose/widgets/widgets/list_skill/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_skill/index.tsx @@ -2,8 +2,8 @@ import { useCallback } from 'react' import { ToolCase } from 'lucide-react' +import { useLocale, useMessages } from 'next-intl' import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' -import { useLocale } from 'next-intl' import { parseImportedSkillsFile } from '@/lib/skills/import-export' import { useUserPermissionsContext, @@ -20,7 +20,6 @@ import { SKILL_EDITOR_WIDGET_KEY, SKILL_LIST_WIDGET_KEY, } from '@/widgets/widgets/_shared/skill/utils' -import { useMessages } from 'next-intl' import { SkillCreateMenu } from '@/widgets/widgets/list_skill/components/skill-create-menu' import { SkillList, From 2ae46e437cccf97c29e0fac97390e0bce1b63c3f Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 17:42:24 -0600 Subject: [PATCH 210/284] test(socket-server): keep connected saved entity drafts on save failure Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/socket-server/index.test.ts | 83 +++++++++++++++---- 1 file changed, 66 insertions(+), 17 deletions(-) diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index e3e75697d..1d759a168 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -217,6 +217,29 @@ function sendHttpRequestWithOptions( }) } +function createSkillUpdateBase64(payload: Record): string { + const updateDoc = new Y.Doc() + seedEntitySession(updateDoc, { entityKind: 'skill', payload }) + const updateBase64 = Buffer.from(Y.encodeStateAsUpdate(updateDoc)).toString('base64') + updateDoc.destroy() + return updateBase64 +} + +function applySkillSessionUpdate(port: number, sessionId: string, updateBase64: string) { + return sendHttpRequestWithOptions( + port, + `/internal/yjs/sessions/${sessionId}/apply-update?targetKind=entity&sessionId=${sessionId}&workspaceId=workspace-1&entityKind=skill&entityId=${sessionId}`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-secret': INTERNAL_SECRET, + }, + body: JSON.stringify({ updateBase64 }), + } + ) +} + describe('Socket Server Index Integration', () => { let httpServer: any let io: any @@ -537,33 +560,59 @@ describe('Socket Server Index Integration', () => { it('should discard an idle saved entity document when update materialization fails', async () => { mockSaveSavedEntityYjsDocToDb.mockRejectedValueOnce(new Error('database unavailable')) - const updateDoc = new Y.Doc() - seedEntitySession(updateDoc, { - entityKind: 'skill', - payload: { + const response = await applySkillSessionUpdate( + PORT, + 'skill-update-failed', + createSkillUpdateBase64({ name: 'Unsaved Skill', description: 'Draft', content: 'Draft content', + }) + ) + + expect(response.statusCode).toBe(500) + expect(await getExistingDocument('skill-update-failed')).toBeNull() + }) + + it('keeps a connected saved entity draft when update materialization fails', async () => { + const conn = new (await import('node:events')).EventEmitter() as any + conn.readyState = 1 + conn.send = vi.fn((_message, _options, callback) => callback?.()) + conn.ping = vi.fn() + conn.close = vi.fn() + setupWSConnection(conn, {} as any, { docId: 'skill-update-connected' }) + const liveDoc = (await getExistingDocument('skill-update-connected'))! + seedEntitySession(liveDoc, { + entityKind: 'skill', + payload: { + name: 'Original Skill', + description: 'Original', + content: 'Original content', }, }) - const updateBase64 = Buffer.from(Y.encodeStateAsUpdate(updateDoc)).toString('base64') - updateDoc.destroy() - const response = await sendHttpRequestWithOptions( + mockSaveSavedEntityYjsDocToDb.mockRejectedValueOnce(new Error('database unavailable')) + const response = await applySkillSessionUpdate( PORT, - '/internal/yjs/sessions/skill-update-failed/apply-update?targetKind=entity&sessionId=skill-update-failed&workspaceId=workspace-1&entityKind=skill&entityId=skill-update-failed', - { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'x-internal-secret': INTERNAL_SECRET, - }, - body: JSON.stringify({ updateBase64 }), - } + 'skill-update-connected', + createSkillUpdateBase64({ + name: 'Unsaved Skill', + description: 'Draft', + content: 'Draft content', + }) ) expect(response.statusCode).toBe(500) - expect(await getExistingDocument('skill-update-failed')).toBeNull() + expect( + getEntityFields((await getExistingDocument('skill-update-connected'))!, 'skill') + ).toEqual({ + name: 'Unsaved Skill', + description: 'Draft', + content: 'Draft content', + }) + + conn.emit('close') + await new Promise((resolve) => setImmediate(resolve)) }) it('should return the internal Yjs workflow snapshot through the generic session route', async () => { From ac118d901d492fab8db5990c8e18eb727d26f262 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 17:42:36 -0600 Subject: [PATCH 211/284] docs(socket-server): clarify explicit-save draft persistence Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/socket-server/routes/http.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index 0ffb03608..26be48add 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -279,10 +279,8 @@ async function getBootstrappedApplyDocument( } /** - * Applies a programmatic mutation to a live Yjs apply-document durably: the change - * is staged on a detached copy and persisted FIRST, so the live session never - * broadcasts state that is not in the database. Only after the write succeeds is - * the same mutation reflected into the live document for connected clients. + * Applies a server-authored mutation durably: the change is staged on a detached + * copy and persisted before it is reflected into the live collaborative document. */ async function applyThroughStaging( doc: Y.Doc, @@ -420,6 +418,8 @@ async function handleInternalYjsSessionApplyUpdateRequest( const doc = await getBootstrappedApplyDocument(descriptor) try { + // Client explicit-save flush: merge the user's collaborative draft first, + // then materialize it. Persistence failure keeps the draft for correction. Y.applyUpdate(doc, Buffer.from(updateBase64, 'base64'), YJS_ORIGINS.SAVE) clearSessionReseededFromCanonical(doc) if (descriptor.entityKind !== 'workflow' && descriptor.entityId) { From ffab7ceb2aa5564d5616278c3882b89c0bbfbdce Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 18:34:37 -0600 Subject: [PATCH 212/284] refactor(tradinggoose): simplify resource metadata and sorting Remove knowledge doc counts and timestamps from list/detail presentation, normalize list ordering to alphabetical labels, and relax the related types and normalizers so missing timestamps are accepted. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../base-overview/base-overview.tsx | 91 +------------------ .../components/create-modal/create-modal.tsx | 12 +-- .../knowledge/components/shared.ts | 7 +- .../[workspaceId]/knowledge/knowledge.tsx | 39 +++----- .../[workspaceId]/knowledge/utils/sort.ts | 27 +----- .../hooks/queries/custom-tools.ts | 7 +- apps/tradinggoose/hooks/queries/indicators.ts | 7 +- apps/tradinggoose/hooks/queries/skills.ts | 7 +- apps/tradinggoose/hooks/use-mcp-tools.ts | 5 +- apps/tradinggoose/i18n/messages/en.json | 13 +-- apps/tradinggoose/i18n/messages/es.json | 13 +-- apps/tradinggoose/i18n/messages/zh.json | 13 +-- .../lib/custom-tools/operations.ts | 2 - .../lib/indicators/custom/operations.ts | 2 - apps/tradinggoose/lib/knowledge/service.ts | 3 - apps/tradinggoose/lib/knowledge/types.ts | 6 +- apps/tradinggoose/lib/mcp/service.ts | 10 +- apps/tradinggoose/lib/skills/operations.ts | 2 - .../tradinggoose/stores/custom-tools/types.ts | 2 +- apps/tradinggoose/stores/indicators/types.ts | 2 +- apps/tradinggoose/stores/knowledge/store.ts | 4 +- apps/tradinggoose/stores/skills/types.ts | 2 +- .../components/custom-tool-dropdown.tsx | 6 +- .../widgets/components/mcp-dropdown.tsx | 8 +- .../components/pine-indicator-dropdown.tsx | 6 +- .../widgets/editor_custom_tool/index.tsx | 6 +- .../widgets/editor_mcp/editor-mcp-body.tsx | 13 +-- .../knowledge-base-selector.tsx | 13 +-- .../widgets/list_custom_tool/index.tsx | 6 +- .../widgets/widgets/list_mcp/index.tsx | 6 +- 30 files changed, 53 insertions(+), 287 deletions(-) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx index b9635d8aa..cc0b223a4 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx @@ -3,7 +3,7 @@ import { type KeyboardEvent, type MouseEvent, type SyntheticEvent, useState } from 'react' import { Check, Copy, LibraryBig, Loader2, Trash2 } from 'lucide-react' import { useParams } from 'next/navigation' -import { useLocale, useTranslations } from 'next-intl' +import { useTranslations } from 'next-intl' import { AlertDialog, AlertDialogAction, @@ -15,77 +15,21 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { CopyToWorkspace } from '@/app/workspace/[workspaceId]/knowledge/components/copy-to-workspace/copy-to-workspace' -import { useKnowledgeStore } from '@/stores/knowledge/store' import { Link } from '@/i18n/navigation' +import { useKnowledgeStore } from '@/stores/knowledge/store' interface BaseOverviewProps { id?: string title: string - docCount: number description: string - createdAt?: string - updatedAt?: string canEdit?: boolean } -function formatRelativeTime(dateString: string, locale: string): string { - const date = new Date(dateString) - const now = new Date() - const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000) - const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) - - if (diffInSeconds < 60) { - return rtf.format(0, 'second') - } - if (diffInSeconds < 3600) { - const minutes = Math.floor(diffInSeconds / 60) - return rtf.format(-minutes, 'minute') - } - if (diffInSeconds < 86400) { - const hours = Math.floor(diffInSeconds / 3600) - return rtf.format(-hours, 'hour') - } - if (diffInSeconds < 604800) { - const days = Math.floor(diffInSeconds / 86400) - return rtf.format(-days, 'day') - } - if (diffInSeconds < 2592000) { - const weeks = Math.floor(diffInSeconds / 604800) - return rtf.format(-weeks, 'week') - } - if (diffInSeconds < 31536000) { - const months = Math.floor(diffInSeconds / 2592000) - return rtf.format(-months, 'month') - } - const years = Math.floor(diffInSeconds / 31536000) - return rtf.format(-years, 'year') -} - -function formatAbsoluteDate(dateString: string, locale: string): string { - const date = new Date(dateString) - return date.toLocaleDateString(locale, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }) -} - -export function BaseOverview({ - id, - title, - docCount, - description, - createdAt, - updatedAt, - canEdit = true, -}: BaseOverviewProps) { +export function BaseOverview({ id, title, description, canEdit = true }: BaseOverviewProps) { const [isCopied, setIsCopied] = useState(false) const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const params = useParams() - const locale = useLocale() const t = useTranslations('workspace.knowledge.baseOverview') const workspaceSlug = params?.workspaceId as string const { removeKnowledgeBase } = useKnowledgeStore() @@ -190,10 +134,6 @@ export function BaseOverview({
- - {docCount} {docCount === 1 ? t('docsSingular') : t('docsPlural')} - -
{id?.slice(0, 8)}
- {/* Timestamps */} - {(createdAt || updatedAt) && ( -
- {updatedAt && ( - - {t('updated')} {formatRelativeTime(updatedAt, locale)} - - )} - {updatedAt && createdAt && } - {createdAt && ( - - {t('created')} {formatRelativeTime(createdAt, locale)} - - )} -
- )} -

{description}

@@ -240,13 +163,7 @@ export function BaseOverview({ {t('deleteTitle')} - - {t('deleteDescription', { - title, - count: docCount, - plural: docCount === 1 ? '' : 's', - })} - + {t('deleteDescription', { title })} {t('cancel')} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx index 9b0ce39e1..64a65f7e8 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { zodResolver } from '@hookform/resolvers/zod' import { AlertCircle, Check, Loader2, X } from 'lucide-react' import { useParams } from 'next/navigation' +import { useTranslations } from 'next-intl' import { useForm } from 'react-hook-form' import { z } from 'zod' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' @@ -22,7 +23,6 @@ import { import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload' import type { KnowledgeBaseData } from '@/stores/knowledge/store' -import { useTranslations } from 'next-intl' const logger = createLogger('CreateModal') @@ -317,8 +317,6 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea const newKnowledgeBase = result.data if (files.length > 0) { - newKnowledgeBase.docCount = files.length - if (onKnowledgeBaseCreated) { onKnowledgeBaseCreated(newKnowledgeBase) } @@ -524,13 +522,9 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea isDragging ? 'text-amber-700' : '' }`} > - {isDragging - ? t('dropFilesHere') - : t('dropFilesHereOrClickToBrowse')} -

-

- {t('supportedFormats')} + {isDragging ? t('dropFilesHere') : t('dropFilesHereOrClickToBrowse')}

+

{t('supportedFormats')}

diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/shared.ts b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/shared.ts index e113d769f..1d135c238 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/shared.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/shared.ts @@ -6,15 +6,10 @@ export const dropdownContentClass = export const commandListClass = 'overflow-y-auto overflow-x-hidden' -export type SortOption = 'name' | 'createdAt' | 'updatedAt' | 'docCount' +export type SortOption = 'name' export type SortOrder = 'asc' | 'desc' export const SORT_OPTION_DEFINITIONS = [ - { value: 'updatedAt-desc', labelKey: 'sort.lastUpdated' }, - { value: 'createdAt-desc', labelKey: 'sort.newestFirst' }, - { value: 'createdAt-asc', labelKey: 'sort.oldestFirst' }, { value: 'name-asc', labelKey: 'sort.nameAsc' }, { value: 'name-desc', labelKey: 'sort.nameDesc' }, - { value: 'docCount-desc', labelKey: 'sort.mostDocuments' }, - { value: 'docCount-asc', labelKey: 'sort.leastDocuments' }, ] as const diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 633ea290b..90ce47ca9 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -26,7 +26,6 @@ import { dropdownContentClass, filterButtonClass, SORT_OPTION_DEFINITIONS, - type SortOption, type SortOrder, } from '@/app/workspace/[workspaceId]/knowledge/components/shared' import { @@ -38,10 +37,6 @@ import { GlobalNavbarHeader } from '@/global-navbar' import { useKnowledgeBasesList } from '@/hooks/use-knowledge' import type { KnowledgeBaseData } from '@/stores/knowledge/store' -interface KnowledgeBaseWithDocCount extends KnowledgeBaseData { - docCount?: number -} - export function Knowledge() { const params = useParams() const workspaceId = params.workspaceId as string @@ -54,10 +49,9 @@ export function Knowledge() { const [searchQuery, setSearchQuery] = useState('') const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) - const [sortBy, setSortBy] = useState('updatedAt') - const [sortOrder, setSortOrder] = useState('desc') + const [sortOrder, setSortOrder] = useState('asc') - const currentSortValue = `${sortBy}-${sortOrder}` + const currentSortValue = `name-${sortOrder}` const sortOptions = useMemo( () => SORT_OPTION_DEFINITIONS.map((option) => ({ @@ -67,11 +61,10 @@ export function Knowledge() { [t] ) const currentSortLabel = - sortOptions.find((opt) => opt.value === currentSortValue)?.label || t('sort.lastUpdated') + sortOptions.find((opt) => opt.value === currentSortValue)?.label || t('sort.nameAsc') const handleSortChange = (value: string) => { - const [field, order] = value.split('-') as [SortOption, SortOrder] - setSortBy(field) + const [, order] = value.split('-') as ['name', SortOrder] setSortOrder(order) } @@ -85,16 +78,13 @@ export function Knowledge() { const filteredAndSortedKnowledgeBases = useMemo(() => { const filtered = filterKnowledgeBases(knowledgeBases, searchQuery) - return sortKnowledgeBases(filtered, sortBy, sortOrder) - }, [knowledgeBases, searchQuery, sortBy, sortOrder]) + return sortKnowledgeBases(filtered, sortOrder) + }, [knowledgeBases, searchQuery, sortOrder]) - const formatKnowledgeBaseForDisplay = (kb: KnowledgeBaseWithDocCount) => ({ + const formatKnowledgeBaseForDisplay = (kb: KnowledgeBaseData) => ({ id: kb.id, title: kb.name, - docCount: kb.docCount || 0, description: kb.description || t('defaults.noDescriptionProvided'), - createdAt: kb.createdAt, - updatedAt: kb.updatedAt, }) const headerLeftContent = ( @@ -132,7 +122,7 @@ export function Knowledge() { >
{sortOptions.map((option, index) => ( -
+
handleSortChange(option.value)} className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50' @@ -177,9 +167,7 @@ export function Knowledge() { {/* Error State */} {error && (
-

- {t('errors.load', { error })} -

+

{t('errors.load', { error })}

- {selectedServer.updatedAt ? ( - - {formatTemplate(copy.updated, { - time: formatRelativeTime(selectedServer.updatedAt, copy.relativeTime) ?? '', - })} - - ) : null} {selectedServer.lastToolsRefresh ? ( {formatTemplate(copy.toolsRefreshed, { diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx index 0b0ef63a7..0909ebfd5 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Check, ChevronDown, RefreshCw, X } from 'lucide-react' -import { useLocale } from 'next-intl' +import { useLocale, useMessages } from 'next-intl' import { PackageSearchIcon } from '@/components/icons/icons' import { Button } from '@/components/ui/button' import { @@ -17,9 +17,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import type { SubBlockConfig } from '@/blocks/types' import { fetchKnowledgeBases as fetchWorkspaceKnowledgeBases } from '@/hooks/queries/knowledge' import { translateWorkflowLabel } from '@/i18n/block-editor' -import { useMessages } from 'next-intl' -import { formatTemplate } from '@/i18n/utils' import type { LocaleCode } from '@/i18n/utils' +import { formatTemplate } from '@/i18n/utils' import type { KnowledgeBaseData } from '@/stores/knowledge/store' import { useSubBlockValue } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value' import { useWorkspaceId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' @@ -171,14 +170,6 @@ export function KnowledgeBaseSelector({ } const getKnowledgeBaseDescription = (knowledgeBase: KnowledgeBaseData) => { - const docCount = (knowledgeBase as any).docCount - if (docCount !== undefined) { - const documentLabel = - docCount === 1 - ? translateWorkflowLabel(locale, 'document') - : translateWorkflowLabel(locale, 'documents') - return `${docCount} ${documentLabel}` - } return knowledgeBase.description || translateWorkflowLabel(locale, 'noDescription') } diff --git a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx index ce550140f..a3e960989 100644 --- a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx @@ -53,11 +53,7 @@ import { WidgetStateMessage } from '@/widgets/widgets/editor_indicator/component const DEFAULT_CUSTOM_TOOL_NAME = 'newCustomTool' const sortCustomTools = (tools: CustomToolDefinition[]) => - [...tools].sort((a, b) => { - const aTime = Date.parse(a.updatedAt ?? a.createdAt ?? '') - const bTime = Date.parse(b.updatedAt ?? b.createdAt ?? '') - return (Number.isNaN(bTime) ? 0 : bTime) - (Number.isNaN(aTime) ? 0 : aTime) - }) + [...tools].sort((a, b) => a.title.localeCompare(b.title)) const buildNewCustomToolDraft = (tools: CustomToolDefinition[]) => { const existingTitles = new Set( diff --git a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx index 2d8f68d05..3f3aaaee8 100644 --- a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx @@ -237,11 +237,7 @@ const ListMcpWidgetContent = ({ workspaceId ? servers .filter((server) => server.workspaceId === workspaceId && !server.deletedAt) - .sort((a, b) => { - const aTime = Date.parse(a.updatedAt ?? a.createdAt ?? '') - const bTime = Date.parse(b.updatedAt ?? b.createdAt ?? '') - return (Number.isNaN(bTime) ? 0 : bTime) - (Number.isNaN(aTime) ? 0 : aTime) - }) + .sort((a, b) => getServerName(a, '').localeCompare(getServerName(b, ''))) : [], [servers, workspaceId] ) From c4e9d06bd78d7fb1004ac96ecbf35578fd345982 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 19:04:10 -0600 Subject: [PATCH 213/284] fix(mcp): show visible MCP servers in selectors Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/hooks/use-mcp-tools.ts | 9 ++--- apps/tradinggoose/lib/mcp/service.ts | 33 ++++++------------- apps/tradinggoose/stores/mcp-servers/store.ts | 8 ++--- apps/tradinggoose/stores/mcp-servers/types.ts | 2 +- .../widgets/components/mcp-dropdown.tsx | 7 +--- .../mcp-server-modal/mcp-server-selector.tsx | 12 +++---- 6 files changed, 23 insertions(+), 48 deletions(-) diff --git a/apps/tradinggoose/hooks/use-mcp-tools.ts b/apps/tradinggoose/hooks/use-mcp-tools.ts index 1b3536d7d..6ed94afcb 100644 --- a/apps/tradinggoose/hooks/use-mcp-tools.ts +++ b/apps/tradinggoose/hooks/use-mcp-tools.ts @@ -49,11 +49,8 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { // Create a stable server fingerprint const serversFingerprint = useMemo(() => { return servers - .filter((s) => s.enabled && !s.deletedAt) - .map( - (s) => - `${s.id}:${s.enabled}:${s.transport}:${s.url ?? ''}:${JSON.stringify(s.headers ?? {})}` - ) + .filter((s) => !s.deletedAt) + .map((s) => s.id) .sort() .join('|') }, [servers]) @@ -148,7 +145,7 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { } logger.info('Active servers changed, refreshing MCP tools', { - serverCount: servers.filter((s) => s.enabled && !s.deletedAt).length, + serverCount: servers.filter((s) => !s.deletedAt).length, fingerprint: serversFingerprint, }) diff --git a/apps/tradinggoose/lib/mcp/service.ts b/apps/tradinggoose/lib/mcp/service.ts index 917a5e062..daa506c7a 100644 --- a/apps/tradinggoose/lib/mcp/service.ts +++ b/apps/tradinggoose/lib/mcp/service.ts @@ -19,21 +19,17 @@ import { MCP_CONSTANTS } from '@/lib/mcp/utils' import { generateRequestId } from '@/lib/utils' import { ReviewTargetBootstrapError, + readBootstrappedEntityListMembers, readBootstrappedSavedEntityFields, readBootstrappedSavedEntityListFields, } from '@/lib/yjs/server/bootstrap-review-target' const logger = createLogger('McpService') -type McpServerListItem = Omit & { +type McpServerListItem = { + id: string + name: string workspaceId: string - description: string | null - url: string | null - command: string | null - args: string[] - env: Record - connectionStatus?: 'connected' | 'disconnected' | 'error' - lastError?: string } interface ToolCache { @@ -265,21 +261,12 @@ class McpService { } async listWorkspaceServers(workspaceId: string): Promise { - const servers = await readBootstrappedSavedEntityListFields('mcp_server', workspaceId) - return servers.map((server) => { - const normalized = normalizeEntityFields('mcp_server', server.fields) - const config = this.toServerConfig(server.entityId, normalized) - return { - ...config, - workspaceId, - description: config.description ?? null, - url: config.url ?? null, - command: String(normalized.command ?? '') || null, - args: Array.isArray(normalized.args) ? normalized.args.map(String) : [], - env: normalized.env as Record, - connectionStatus: 'disconnected' as const, - } - }) + const servers = await readBootstrappedEntityListMembers('mcp_server', workspaceId) + return servers.map((server) => ({ + id: server.entityId, + name: server.entityName, + workspaceId, + })) } private async getServerConfig( diff --git a/apps/tradinggoose/stores/mcp-servers/store.ts b/apps/tradinggoose/stores/mcp-servers/store.ts index 9a896b546..84e0d8f67 100644 --- a/apps/tradinggoose/stores/mcp-servers/store.ts +++ b/apps/tradinggoose/stores/mcp-servers/store.ts @@ -71,9 +71,6 @@ export const useMcpServersStore = create()( timeout: requestBody.timeout ?? 30000, retries: requestBody.retries ?? 3, enabled: requestBody.enabled ?? true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - connectionStatus: 'disconnected' as const, } set((state) => ({ servers: [...state.servers, newServer], @@ -114,7 +111,6 @@ export const useMcpServersStore = create()( ? { ...server, ...(updatedServer || updates), - updatedAt: updatedServer?.updatedAt || new Date().toISOString(), } : server ), @@ -185,6 +181,6 @@ export const useMcpServersStore = create()( ) ) -export const useEnabledServers = () => { - return useMcpServersStore((state) => state.servers.filter((s) => s.enabled && !s.deletedAt)) +export const useVisibleServers = () => { + return useMcpServersStore((state) => state.servers.filter((s) => !s.deletedAt)) } diff --git a/apps/tradinggoose/stores/mcp-servers/types.ts b/apps/tradinggoose/stores/mcp-servers/types.ts index 2c365eb9d..9e8bcb5da 100644 --- a/apps/tradinggoose/stores/mcp-servers/types.ts +++ b/apps/tradinggoose/stores/mcp-servers/types.ts @@ -4,7 +4,7 @@ export interface McpServerWithStatus { id: string name: string description?: string | null - transport: McpTransport + transport?: McpTransport url?: string | null headers?: Record command?: string | null diff --git a/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx index 7aad4faf9..7951c8423 100644 --- a/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx @@ -124,12 +124,7 @@ export function McpDropdown({ return workspaceServers.filter((server) => { const name = server.name?.toLowerCase() ?? '' const id = server.id.toLowerCase() - const url = server.url?.toLowerCase() ?? '' - return ( - name.includes(normalizedQuery) || - id.includes(normalizedQuery) || - url.includes(normalizedQuery) - ) + return name.includes(normalizedQuery) || id.includes(normalizedQuery) }) }, [searchQuery, workspaceServers]) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx index 9355c71db..119a4a7ee 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Check, ChevronDown, RefreshCw } from 'lucide-react' +import { useMessages } from 'next-intl' import { Button } from '@/components/ui/button' import { Command, @@ -13,8 +14,7 @@ import { } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import type { SubBlockConfig } from '@/blocks/types' -import { useMessages } from 'next-intl' -import { useEnabledServers, useMcpServersStore } from '@/stores/mcp-servers/store' +import { useMcpServersStore, useVisibleServers } from '@/stores/mcp-servers/store' import { useSubBlockValue } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value' import { useWorkspaceId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' @@ -30,7 +30,7 @@ export function McpServerSelector({ blockId, subBlock, disabled = false }: McpSe const [open, setOpen] = useState(false) const { fetchServers, isLoading, error } = useMcpServersStore() - const enabledServers = useEnabledServers() + const visibleServers = useVisibleServers() const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) @@ -38,7 +38,7 @@ export function McpServerSelector({ blockId, subBlock, disabled = false }: McpSe const selectedServerId = storeValue || '' - const selectedServer = enabledServers.find((server) => server.id === selectedServerId) + const selectedServer = visibleServers.find((server) => server.id === selectedServerId) useEffect(() => { fetchServers(workspaceId) @@ -103,9 +103,9 @@ export function McpServerSelector({ blockId, subBlock, disabled = false }: McpSe
)} - {enabledServers.length > 0 && ( + {visibleServers.length > 0 && ( - {enabledServers.map((server) => ( + {visibleServers.map((server) => ( Date: Fri, 26 Jun 2026 19:27:07 -0600 Subject: [PATCH 214/284] fix(mcp): preserve server state across refreshes Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/stores/mcp-servers/store.ts | 28 +++++++++++++++---- .../widgets/editor_mcp/editor-mcp-body.tsx | 4 +-- .../widgets/widgets/list_mcp/index.tsx | 3 +- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/apps/tradinggoose/stores/mcp-servers/store.ts b/apps/tradinggoose/stores/mcp-servers/store.ts index 84e0d8f67..77ef18ca4 100644 --- a/apps/tradinggoose/stores/mcp-servers/store.ts +++ b/apps/tradinggoose/stores/mcp-servers/store.ts @@ -21,7 +21,24 @@ export const useMcpServersStore = create()( throw new Error(data.error || 'Failed to fetch servers') } - set({ servers: data.data?.servers || [], isLoading: false }) + const listedServers: McpServersState['servers'] = Array.isArray(data.data?.servers) + ? data.data.servers + : [] + set((state) => ({ + servers: listedServers.map((server) => { + const previous = state.servers.find( + (item) => item.id === server.id && item.workspaceId === server.workspaceId + ) + return { + ...server, + connectionStatus: previous?.connectionStatus, + lastError: previous?.lastError, + lastConnected: previous?.lastConnected, + lastToolsRefresh: previous?.lastToolsRefresh, + } + }), + isLoading: false, + })) logger.info( `Fetched ${data.data?.servers?.length || 0} MCP servers for workspace ${workspaceId}` ) @@ -104,14 +121,13 @@ export const useMcpServersStore = create()( } const updatedServer = data.data?.server || null + const nextName = + typeof updatedServer?.name === 'string' ? updatedServer.name : updates.name set((state) => ({ servers: state.servers.map((server) => - server.id === id && server.workspaceId === workspaceId - ? { - ...server, - ...(updatedServer || updates), - } + server.id === id && server.workspaceId === workspaceId && nextName + ? { ...server, name: nextName } : server ), isLoading: false, diff --git a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx index a103acff0..beab7a83e 100644 --- a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx @@ -310,7 +310,7 @@ export function EditorMcpWidgetBody({ try { await serverSession.save() initialFormDataRef.current = formDataState - await fetchServers(workspaceId) + await handleRefreshTools() } catch (error) { console.error('Failed to save MCP server', error) setSaveError(copy.failedToSaveMcpServer) @@ -318,8 +318,8 @@ export function EditorMcpWidgetBody({ }, [ copy.failedToSaveMcpServer, copy.serverNameRequired, - fetchServers, formDataState, + handleRefreshTools, serverSession.doc, serverSession.save, selectedServerId, diff --git a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx index 3f3aaaee8..4bab1ce17 100644 --- a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx @@ -353,8 +353,9 @@ const ListMcpWidgetContent = ({ await updateServer(workspaceId, serverId, { name, }) + await refreshTools(true) }, - [permissions.canEdit, updateServer, workspaceId] + [permissions.canEdit, refreshTools, updateServer, workspaceId] ) const handleDeleteServer = useCallback( From 6770e0c608076f6a6f8018df3c24fd68bb451e4a Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 20:57:44 -0600 Subject: [PATCH 215/284] feat(mcp): propagate enabled state through MCP server lists Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../tradinggoose/app/api/mcp/servers/route.ts | 2 +- .../tools/server/entities/mcp-server.ts | 2 +- apps/tradinggoose/lib/mcp/service.ts | 2 ++ apps/tradinggoose/lib/yjs/entity-session.ts | 28 +++++++++++++++---- .../lib/yjs/server/apply-entity-state.ts | 6 +++- .../lib/yjs/server/entity-loaders.ts | 15 +++++++++- .../lib/yjs/server/snapshot-bridge.ts | 9 ++++-- apps/tradinggoose/stores/mcp-servers/store.ts | 6 ++-- .../widgets/components/mcp-dropdown.tsx | 5 +++- .../user-input/workspace-entity-mentions.ts | 10 +++---- .../mcp-server-modal/mcp-server-selector.tsx | 10 +++---- 11 files changed, 70 insertions(+), 25 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 5fedb7e52..f1aaac7d8 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -102,7 +102,7 @@ export const POST = withMcpAuth('write')( mcpService.clearCache(workspaceId) await notifyEntityListMembersAdded('mcp_server', workspaceId, [ - { id: serverId, name: String(fields.name ?? '') }, + { id: serverId, name: String(fields.name ?? ''), enabled: fields.enabled !== false }, ]) logger.info(`[${requestId}] Successfully registered MCP server: ${fields.name}`) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts index e2eb895ae..6781a5a6a 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -129,7 +129,7 @@ async function createMcpServerEntity( const savedFields = savedEntityRowToFields(ENTITY_KIND_MCP_SERVER, row) mcpService.clearCache(workspaceId) await notifyEntityListMembersAdded('mcp_server', workspaceId, [ - { id: entityId, name: String(normalized.name ?? '') }, + { id: entityId, name: String(normalized.name ?? ''), enabled: normalized.enabled !== false }, ]) return { diff --git a/apps/tradinggoose/lib/mcp/service.ts b/apps/tradinggoose/lib/mcp/service.ts index daa506c7a..a9b5026fd 100644 --- a/apps/tradinggoose/lib/mcp/service.ts +++ b/apps/tradinggoose/lib/mcp/service.ts @@ -29,6 +29,7 @@ const logger = createLogger('McpService') type McpServerListItem = { id: string name: string + enabled: boolean workspaceId: string } @@ -265,6 +266,7 @@ class McpService { return servers.map((server) => ({ id: server.entityId, name: server.entityName, + enabled: server.enabled !== false, workspaceId, })) } diff --git a/apps/tradinggoose/lib/yjs/entity-session.ts b/apps/tradinggoose/lib/yjs/entity-session.ts index 52b8e6273..952b5f4ae 100644 --- a/apps/tradinggoose/lib/yjs/entity-session.ts +++ b/apps/tradinggoose/lib/yjs/entity-session.ts @@ -39,24 +39,30 @@ export function getEntityMetadataMap(doc: Y.Doc): Y.Map { export interface EntityListMember { entityId: string entityName: string + enabled?: boolean } export type EntityListMemberMutation = - | { op: 'add'; entityId: string; name: string } + | { op: 'add'; entityId: string; name: string; enabled?: boolean } | { op: 'remove'; entityId: string } -function getEntityListMembersMap(doc: Y.Doc): Y.Map<{ name: string; deleted?: boolean }> { +function getEntityListMembersMap( + doc: Y.Doc +): Y.Map<{ name: string; enabled?: boolean; deleted?: boolean }> { return doc.getMap('members') } export function seedEntityListSession( doc: Y.Doc, - members: Array<{ id: string; name: string }> + members: Array<{ id: string; name: string; enabled?: boolean }> ): void { doc.transact(() => { const listMembers = getEntityListMembersMap(doc) for (const member of members) { - listMembers.set(member.id, { name: member.name }) + listMembers.set(member.id, { + name: member.name, + ...(typeof member.enabled === 'boolean' ? { enabled: member.enabled } : {}), + }) } }, YJS_ORIGINS.SYSTEM) } @@ -65,7 +71,13 @@ function applyEntityListMutation(doc: Y.Doc, mutation: EntityListMemberMutation) doc.transact(() => { getEntityListMembersMap(doc).set( mutation.entityId, - mutation.op === 'add' ? { name: mutation.name, deleted: false } : { name: '', deleted: true } + mutation.op === 'add' + ? { + name: mutation.name, + deleted: false, + ...(typeof mutation.enabled === 'boolean' ? { enabled: mutation.enabled } : {}), + } + : { name: '', deleted: true } ) }, YJS_ORIGINS.SYSTEM) } @@ -88,7 +100,11 @@ export function getEntityListMembers(doc: Y.Doc): EntityListMember[] { const entries: EntityListMember[] = [] getEntityListMembersMap(doc).forEach((value, entityId) => { if (value?.deleted) return - entries.push({ entityId, entityName: typeof value?.name === 'string' ? value.name : '' }) + entries.push({ + entityId, + entityName: typeof value?.name === 'string' ? value.name : '', + ...(typeof value?.enabled === 'boolean' ? { enabled: value.enabled } : {}), + }) }) entries.sort((a, b) => a.entityName.localeCompare(b.entityName)) return entries diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index ef17ae873..e2974590f 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -212,6 +212,10 @@ export async function saveSavedEntityYjsDocToDb( } await persistSavedEntityState(entityKind, entityId, yjsFields, workspaceId) await notifyEntityListMembersAdded(entityKind, workspaceId, [ - { id: entityId, name: getEntityDocumentName(entityKind, yjsFields) }, + { + id: entityId, + name: getEntityDocumentName(entityKind, yjsFields), + ...(entityKind === 'mcp_server' ? { enabled: yjsFields.enabled !== false } : {}), + }, ]) } diff --git a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts index 85f2b3e44..6af4af6a3 100644 --- a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts +++ b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts @@ -56,7 +56,20 @@ export async function resolveEntityWorkspaceId( export async function readEntityListMembersFromDb( entityKind: SavedEntityKind, workspaceId: string -): Promise> { +): Promise> { + if (entityKind === 'mcp_server') { + const rows = await db + .select({ id: mcpServers.id, name: mcpServers.name, enabled: mcpServers.enabled }) + .from(mcpServers) + .where(entityCondition(entityKind, [eq(mcpServers.workspaceId, workspaceId)])) + + return rows.map((row) => ({ + id: row.id, + name: row.name ?? '', + enabled: row.enabled !== false, + })) + } + const { table, name } = entityConfig(entityKind) const rows: Array<{ id: string; name: string | null }> = await db .select({ id: table.id, name }) diff --git a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts index 05666ee33..b999810ec 100644 --- a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts +++ b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts @@ -177,12 +177,17 @@ async function applyEntityListUpdateInSocketServer( export async function notifyEntityListMembersAdded( entityKind: SavedEntityKind, workspaceId: string, - members: Array<{ id: string; name: string }> + members: Array<{ id: string; name: string; enabled?: boolean }> ): Promise { await applyEntityListUpdateInSocketServer( entityKind, workspaceId, - members.map((member) => ({ op: 'add', entityId: member.id, name: member.name })) + members.map((member) => ({ + op: 'add', + entityId: member.id, + name: member.name, + ...(typeof member.enabled === 'boolean' ? { enabled: member.enabled } : {}), + })) ).catch(() => undefined) } diff --git a/apps/tradinggoose/stores/mcp-servers/store.ts b/apps/tradinggoose/stores/mcp-servers/store.ts index 77ef18ca4..9f43e52fb 100644 --- a/apps/tradinggoose/stores/mcp-servers/store.ts +++ b/apps/tradinggoose/stores/mcp-servers/store.ts @@ -197,6 +197,8 @@ export const useMcpServersStore = create()( ) ) -export const useVisibleServers = () => { - return useMcpServersStore((state) => state.servers.filter((s) => !s.deletedAt)) +export const useEnabledServers = () => { + return useMcpServersStore((state) => + state.servers.filter((server) => !server.deletedAt && server.enabled !== false) + ) } diff --git a/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx index 7951c8423..ea9745ccc 100644 --- a/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx @@ -76,7 +76,10 @@ export function McpDropdown({ if (!workspaceId) return [] return servers - .filter((server) => server.workspaceId === workspaceId && !server.deletedAt) + .filter( + (server) => + server.workspaceId === workspaceId && !server.deletedAt && server.enabled !== false + ) .sort((a, b) => getServerLabel(a).localeCompare(getServerLabel(b))) }, [servers, workspaceId]) diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/workspace-entity-mentions.ts b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/workspace-entity-mentions.ts index e1ac7b49f..4d9baa8c9 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/workspace-entity-mentions.ts +++ b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/workspace-entity-mentions.ts @@ -78,16 +78,16 @@ export async function loadWorkspaceEntityMentionItems( description: toTrimmedString(item.schema?.function?.description), })) case 'mcp_server': - return sortByRecent(Array.isArray(data?.data?.servers) ? data.data.servers : []) + return (Array.isArray(data?.data?.servers) ? data.data.servers : []) .filter((item: any) => item.id) + .sort((left: any, right: any) => + toTrimmedString(left.name).localeCompare(toTrimmedString(right.name)) + ) .map((item: any) => ({ entityKind, id: item.id, name: toTrimmedString(item.name), - description: toTrimmedString(item.description), - transport: toTrimmedString(item.transport), - enabled: item.enabled, - connectionStatus: item.connectionStatus, + enabled: item.enabled !== false, })) } } diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx index 119a4a7ee..69e4d62c0 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx @@ -14,7 +14,7 @@ import { } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import type { SubBlockConfig } from '@/blocks/types' -import { useMcpServersStore, useVisibleServers } from '@/stores/mcp-servers/store' +import { useEnabledServers, useMcpServersStore } from '@/stores/mcp-servers/store' import { useSubBlockValue } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value' import { useWorkspaceId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' @@ -30,7 +30,7 @@ export function McpServerSelector({ blockId, subBlock, disabled = false }: McpSe const [open, setOpen] = useState(false) const { fetchServers, isLoading, error } = useMcpServersStore() - const visibleServers = useVisibleServers() + const enabledServers = useEnabledServers() const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) @@ -38,7 +38,7 @@ export function McpServerSelector({ blockId, subBlock, disabled = false }: McpSe const selectedServerId = storeValue || '' - const selectedServer = visibleServers.find((server) => server.id === selectedServerId) + const selectedServer = enabledServers.find((server) => server.id === selectedServerId) useEffect(() => { fetchServers(workspaceId) @@ -103,9 +103,9 @@ export function McpServerSelector({ blockId, subBlock, disabled = false }: McpSe
)} - {visibleServers.length > 0 && ( + {enabledServers.length > 0 && ( - {visibleServers.map((server) => ( + {enabledServers.map((server) => ( Date: Fri, 26 Jun 2026 20:58:05 -0600 Subject: [PATCH 216/284] fix(workflows): use bootstrap state for deploy and status checks Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../api/workflows/[id]/deploy/route.test.ts | 2 +- .../app/api/workflows/[id]/deploy/route.ts | 6 ++---- .../api/workflows/[id]/status/route.test.ts | 2 +- .../app/api/workflows/[id]/status/route.ts | 18 ++++-------------- 4 files changed, 8 insertions(+), 20 deletions(-) diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts index bb2a4abd6..9aa3d5f2d 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts @@ -37,7 +37,7 @@ describe('Workflow Deploy API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ deployWorkflow: vi.fn(), - requireEditableWorkflowState: (...args: unknown[]) => mockLoadWorkflowState(...args), + loadWorkflowBootstrapStateFromDb: (...args: unknown[]) => mockLoadWorkflowState(...args), })) vi.doMock('@/lib/chat/published-deployment', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts index 286d3dcd9..29b4115e9 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts @@ -7,7 +7,7 @@ import { } from '@/lib/chat/published-deployment' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { deployWorkflow, requireEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { deployWorkflow, loadWorkflowBootstrapStateFromDb } from '@/lib/workflows/db-helpers' import { hasWorkflowChanged, validateWorkflowPermissions } from '@/lib/workflows/utils' import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' import { pauseMonitorsMissingDeployedTrigger } from '@/app/api/monitors/shared' @@ -103,7 +103,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .limit(1) if (active?.state) { - const currentState = await requireEditableWorkflowState(id) + const currentState = await loadWorkflowBootstrapStateFromDb(id) if (currentState) { needsRedeployment = hasWorkflowChanged(currentState, active.state as any) } @@ -123,8 +123,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ }) } catch (error: any) { logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error) - const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) - if (realtimeResponse) return realtimeResponse return createErrorResponse(error.message || 'Failed to fetch deployment information', 500) } } diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts index a9ed4937e..131b3a3a9 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts @@ -56,7 +56,7 @@ describe('Workflow Status API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', isWorkflowRealtimeRequiredError: vi.fn(() => false), - requireEditableWorkflowState: mockLoadWorkflowState, + loadWorkflowBootstrapStateFromDb: mockLoadWorkflowState, })) vi.doMock('@/lib/workflows/utils', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts index 65621d813..afc4501e5 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts @@ -3,14 +3,10 @@ import { and, desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { requireEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { loadWorkflowBootstrapStateFromDb } from '@/lib/workflows/db-helpers' import { hasWorkflowChanged } from '@/lib/workflows/utils' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' -import { - createErrorResponse, - createSuccessResponse, - createWorkflowRealtimeRequiredResponse, -} from '@/app/api/workflows/utils' +import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' const logger = createLogger('WorkflowStatusAPI') @@ -32,7 +28,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ if (validation.workflow.isDeployed) { // Load current workflow state and the active deployment version in parallel. const [currentState, [active]] = await Promise.all([ - requireEditableWorkflowState(id), + loadWorkflowBootstrapStateFromDb(id), db .select({ state: workflowDeploymentVersion.state }) .from(workflowDeploymentVersion) @@ -46,11 +42,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .limit(1), ]) - if (!currentState) { - return createErrorResponse('Failed to load workflow state', 500) - } - - if (active?.state) { + if (currentState && active?.state) { needsRedeployment = hasWorkflowChanged(currentState as any, active.state as any) } } @@ -63,8 +55,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ }) } catch (error) { logger.error(`[${requestId}] Error getting status for workflow: ${(await params).id}`, error) - const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) - if (realtimeResponse) return realtimeResponse return createErrorResponse('Failed to get status', 500) } } From 275e0223f8ebc6aa863bd288436fddd3897d6b8f Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 21:31:16 -0600 Subject: [PATCH 217/284] fix(mcp): synchronize server refresh and membership state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../tradinggoose/app/api/mcp/servers/route.ts | 23 +++++++++++-------- apps/tradinggoose/hooks/use-mcp-tools.ts | 4 ++-- .../tools/server/entities/mcp-server.ts | 12 +++++++--- .../lib/yjs/server/snapshot-bridge.ts | 4 ++-- apps/tradinggoose/stores/mcp-servers/store.ts | 6 ++++- apps/tradinggoose/stores/mcp-servers/types.ts | 11 ++++++++- .../widgets/editor_mcp/editor-mcp-body.tsx | 8 +++++-- 7 files changed, 48 insertions(+), 20 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index f1aaac7d8..9473c1a02 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -100,10 +100,16 @@ export const POST = withMcpAuth('write')( updatedAt: new Date(), }) + try { + await notifyEntityListMembersAdded('mcp_server', workspaceId, [ + { id: serverId, name: String(fields.name ?? ''), enabled: fields.enabled !== false }, + ]) + } catch (error) { + await db.delete(mcpServers).where(eq(mcpServers.id, serverId)) + throw error + } + mcpService.clearCache(workspaceId) - await notifyEntityListMembersAdded('mcp_server', workspaceId, [ - { id: serverId, name: String(fields.name ?? ''), enabled: fields.enabled !== false }, - ]) logger.info(`[${requestId}] Successfully registered MCP server: ${fields.name}`) @@ -158,17 +164,16 @@ export const DELETE = withMcpAuth('write')( .limit(1) if (!server) { - return createMcpErrorResponse( - new Error('Server not found or access denied'), - 'Server not found', - 404 - ) + await deleteYjsSessionInSocketServer(serverId) + await notifyEntityListMemberRemoved('mcp_server', workspaceId, serverId) + mcpService.clearCache(workspaceId) + return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) } await db .delete(mcpServers) .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - await deleteYjsSessionInSocketServer(serverId).catch(() => undefined) + await deleteYjsSessionInSocketServer(serverId) await notifyEntityListMemberRemoved('mcp_server', workspaceId, serverId) mcpService.clearCache(workspaceId) diff --git a/apps/tradinggoose/hooks/use-mcp-tools.ts b/apps/tradinggoose/hooks/use-mcp-tools.ts index 6ed94afcb..340ec9c1c 100644 --- a/apps/tradinggoose/hooks/use-mcp-tools.ts +++ b/apps/tradinggoose/hooks/use-mcp-tools.ts @@ -50,7 +50,7 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { const serversFingerprint = useMemo(() => { return servers .filter((s) => !s.deletedAt) - .map((s) => s.id) + .map((s) => `${s.id}:${s.enabled !== false ? '1' : '0'}`) .sort() .join('|') }, [servers]) @@ -145,7 +145,7 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { } logger.info('Active servers changed, refreshing MCP tools', { - serverCount: servers.filter((s) => !s.deletedAt).length, + serverCount: servers.filter((s) => !s.deletedAt && s.enabled !== false).length, fingerprint: serversFingerprint, }) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts index 6781a5a6a..8750c8938 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -1,5 +1,6 @@ import { db } from '@tradinggoose/db' import { mcpServers } from '@tradinggoose/db/schema' +import { eq } from 'drizzle-orm' import { ENTITY_SECRET_PLACEHOLDER, normalizeEntityFields, @@ -127,10 +128,15 @@ async function createMcpServerEntity( } const savedFields = savedEntityRowToFields(ENTITY_KIND_MCP_SERVER, row) + try { + await notifyEntityListMembersAdded('mcp_server', workspaceId, [ + { id: entityId, name: String(normalized.name ?? ''), enabled: normalized.enabled !== false }, + ]) + } catch (error) { + await db.delete(mcpServers).where(eq(mcpServers.id, entityId)) + throw error + } mcpService.clearCache(workspaceId) - await notifyEntityListMembersAdded('mcp_server', workspaceId, [ - { id: entityId, name: String(normalized.name ?? ''), enabled: normalized.enabled !== false }, - ]) return { entityId, diff --git a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts index b999810ec..9ce95a3ae 100644 --- a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts +++ b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts @@ -188,7 +188,7 @@ export async function notifyEntityListMembersAdded( name: member.name, ...(typeof member.enabled === 'boolean' ? { enabled: member.enabled } : {}), })) - ).catch(() => undefined) + ) } export async function notifyEntityListMemberRemoved( @@ -199,7 +199,7 @@ export async function notifyEntityListMemberRemoved( await applyEntityListUpdateInSocketServer(entityKind, workspaceId, { op: 'remove', entityId, - }).catch(() => undefined) + }) } export async function deleteYjsSessionInSocketServer(sessionId: string): Promise { diff --git a/apps/tradinggoose/stores/mcp-servers/store.ts b/apps/tradinggoose/stores/mcp-servers/store.ts index 9f43e52fb..a54897b0f 100644 --- a/apps/tradinggoose/stores/mcp-servers/store.ts +++ b/apps/tradinggoose/stores/mcp-servers/store.ts @@ -174,7 +174,7 @@ export const useMcpServersStore = create()( } }, - refreshServer: async (workspaceId: string, id: string) => { + refreshServer: async (workspaceId: string, id: string, result) => { const refreshedAt = new Date().toISOString() set((state) => ({ @@ -183,6 +183,10 @@ export const useMcpServersStore = create()( ? { ...server, lastToolsRefresh: refreshedAt, + ...(result?.status ? { connectionStatus: result.status } : {}), + ...(typeof result?.toolCount === 'number' ? { toolCount: result.toolCount } : {}), + ...(result?.lastConnected ? { lastConnected: result.lastConnected } : {}), + ...(result ? { lastError: result.error || undefined } : {}), } : server ), diff --git a/apps/tradinggoose/stores/mcp-servers/types.ts b/apps/tradinggoose/stores/mcp-servers/types.ts index 9e8bcb5da..310dc69ce 100644 --- a/apps/tradinggoose/stores/mcp-servers/types.ts +++ b/apps/tradinggoose/stores/mcp-servers/types.ts @@ -58,7 +58,16 @@ export interface McpServersActions { updates: Partial ) => Promise deleteServer: (workspaceId: string, id: string) => Promise - refreshServer: (workspaceId: string, id: string) => Promise + refreshServer: ( + workspaceId: string, + id: string, + result?: { + status?: McpServerWithStatus['connectionStatus'] + toolCount?: number + lastConnected?: string | null + error?: string | null + } + ) => Promise } export const initialState: McpServersState = { diff --git a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx index beab7a83e..6fcc50e0b 100644 --- a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx @@ -280,8 +280,12 @@ export function EditorMcpWidgetBody({ if (!workspaceId || !selectedServerId) return try { - await refreshServerApi(selectedServerId, workspaceId, copy.failedToRefreshMcpServer) - await refreshServer(workspaceId, selectedServerId) + const refreshResult = await refreshServerApi( + selectedServerId, + workspaceId, + copy.failedToRefreshMcpServer + ) + await refreshServer(workspaceId, selectedServerId, refreshResult?.data) await refreshTools(true) await fetchServers(workspaceId) } catch (refreshError) { From 14ac1a481471395114374aa5b9ccc0af26827c24 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 21:31:35 -0600 Subject: [PATCH 218/284] fix(workflows): clarify editable realtime requirement Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/workflows/utils.ts | 2 +- apps/tradinggoose/lib/workflows/db-helpers.ts | 6 +++++- .../lib/yjs/server/apply-workflow-state.test.ts | 6 +++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/tradinggoose/app/api/workflows/utils.ts b/apps/tradinggoose/app/api/workflows/utils.ts index cb2bae1a2..36610ba66 100644 --- a/apps/tradinggoose/app/api/workflows/utils.ts +++ b/apps/tradinggoose/app/api/workflows/utils.ts @@ -21,7 +21,7 @@ export function createSuccessResponse(data: any) { export function createWorkflowRealtimeRequiredResponse(error: unknown) { if (!isWorkflowRealtimeRequiredError(error)) return null return createErrorResponse( - 'Workflow realtime orchestration is required', + 'Editable workflow realtime orchestration is required', 503, WORKFLOW_REALTIME_REQUIRED_CODE ) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 157cfea4f..3eee6d7cf 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -97,7 +97,11 @@ export class WorkflowRealtimeRequiredError extends Error { readonly code = WORKFLOW_REALTIME_REQUIRED_CODE constructor(cause: unknown) { - super(cause instanceof Error ? cause.message : 'Workflow realtime orchestration is required') + super( + cause instanceof Error + ? cause.message + : 'Editable workflow realtime orchestration is required' + ) this.name = 'WorkflowRealtimeRequiredError' } } diff --git a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts index bcebe8100..0c0a5eaba 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts @@ -47,7 +47,11 @@ vi.mock('@/lib/workflows/db-helpers', () => ({ readonly code = 'WORKFLOW_REALTIME_REQUIRED' constructor(cause: unknown) { - super(cause instanceof Error ? cause.message : 'Workflow realtime orchestration is required') + super( + cause instanceof Error + ? cause.message + : 'Editable workflow realtime orchestration is required' + ) this.name = 'WorkflowRealtimeRequiredError' } }, From b6d8f2c7879f586866532e29efdb75b90ab926c7 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 21:31:50 -0600 Subject: [PATCH 219/284] test: align mocks with current imports Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/chat/[identifier]/route.test.ts | 6 ++---- .../[workspaceId]/monitor/monitor.test.tsx | 1 + apps/tradinggoose/tools/index.test.ts | 17 +++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/tradinggoose/app/api/chat/[identifier]/route.test.ts b/apps/tradinggoose/app/api/chat/[identifier]/route.test.ts index b5e76b0ec..41b3fec77 100644 --- a/apps/tradinggoose/app/api/chat/[identifier]/route.test.ts +++ b/apps/tradinggoose/app/api/chat/[identifier]/route.test.ts @@ -125,6 +125,8 @@ vi.mock('@/lib/utils', () => ({ }, })) +import { GET, POST } from './route' + const chatParams = () => ({ params: Promise.resolve({ identifier: 'test-chat' }) }) const postChatRequest = (body: Record) => new NextRequest('https://example.com/api/chat/test-chat', { @@ -234,7 +236,6 @@ describe('/api/chat/[identifier]', () => { }) it('returns chat metadata for a valid identifier', async () => { - const { GET } = await import('./route') const response = await GET( new NextRequest('https://example.com/api/chat/test-chat'), chatParams() @@ -249,7 +250,6 @@ describe('/api/chat/[identifier]', () => { }) it('queues chat workflow messages and returns an SSE response from queued result', async () => { - const { POST } = await import('./route') const response = await POST( postChatRequest({ input: 'Hello', @@ -306,7 +306,6 @@ describe('/api/chat/[identifier]', () => { }) try { - const { POST } = await import('./route') const response = await POST( postChatRequest({ input: 'Hello', @@ -332,7 +331,6 @@ describe('/api/chat/[identifier]', () => { it('requires a pinned API key owner for queued chat execution attribution', async () => { getApiKeyOwnerUserIdMock.mockResolvedValueOnce(null) - const { POST } = await import('./route') const response = await POST(postChatRequest({ input: 'Hello' }), chatParams()) expect(response.status).toBe(503) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.test.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.test.tsx index 2a9f6962b..3b11c174b 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.test.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.test.tsx @@ -267,6 +267,7 @@ vi.mock('@/hooks/queries/oauth-provider-availability', () => ({ })) vi.mock('@/app/workspace/[workspaceId]/monitor/components/data/api', () => ({ + MONITOR_DATA_CHANGED_EVENT: 'tradinggoose:monitor-data-changed', createMonitorView: vi.fn(), createMonitorRecord: vi.fn(), deleteMonitorRecord: vi.fn(), diff --git a/apps/tradinggoose/tools/index.test.ts b/apps/tradinggoose/tools/index.test.ts index 147f32b1c..820fe7113 100644 --- a/apps/tradinggoose/tools/index.test.ts +++ b/apps/tradinggoose/tools/index.test.ts @@ -32,6 +32,7 @@ const dbMocks = vi.hoisted(() => { return { from, limit, select, setRows, where } }) +const listSkillsMock = vi.hoisted(() => vi.fn()) vi.mock('@tradinggoose/db', () => ({ db: { @@ -39,6 +40,10 @@ vi.mock('@tradinggoose/db', () => ({ }, })) +vi.mock('@/lib/skills/operations', () => ({ + listSkills: listSkillsMock, +})) + vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: vi.fn().mockResolvedValue('mock-internal-token'), })) @@ -180,6 +185,7 @@ describe('executeTool Function', () => { Promise.resolve([]).then(resolve, reject), })) dbMocks.limit.mockImplementation(() => Promise.resolve([])) + listSkillsMock.mockResolvedValue([]) // Mock fetch global.fetch = Object.assign( @@ -506,19 +512,14 @@ describe('executeTool Function', () => { description: 'Research the market before acting', }, ]) - const skillRows = [ + listSkillsMock.mockResolvedValueOnce([ { id: 'skill-1', name: 'market-research', + description: 'Research the market before acting', content: 'Investigate the market and summarize the setup.', }, - ] - dbMocks.where.mockImplementationOnce(() => ({ - orderBy: vi.fn().mockResolvedValueOnce(skillRows), - limit: vi.fn().mockResolvedValueOnce(skillRows), - then: (resolve: (value: unknown[]) => unknown, reject?: (reason: unknown) => unknown) => - Promise.resolve(skillRows).then(resolve, reject), - })) + ]) global.fetch = Object.assign( vi.fn().mockResolvedValue({ From 959425edf73faee4448bfa1a07071d2359c5d1c7 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 22:04:21 -0600 Subject: [PATCH 220/284] fix(custom-tools): allow workflow-scoped lookup Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/tools/custom/route.test.ts | 27 +++++++++++- .../app/api/tools/custom/route.ts | 41 ++++++++++++++----- apps/tradinggoose/tools/utils.test.ts | 39 ++++++++++++++++++ apps/tradinggoose/tools/utils.ts | 7 ++-- 4 files changed, 98 insertions(+), 16 deletions(-) diff --git a/apps/tradinggoose/app/api/tools/custom/route.test.ts b/apps/tradinggoose/app/api/tools/custom/route.test.ts index ee013950d..9fdcc31e6 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.test.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.test.ts @@ -9,6 +9,7 @@ const mockGetUserEntityPermissions = vi.fn() const mockCreateCustomTools = vi.fn() const mockSaveCustomTool = vi.fn() const mockListCustomTools = vi.fn() +const mockReadWorkflowAccessContext = vi.fn() vi.mock('@/lib/auth/hybrid', () => ({ checkHybridAuth: mockCheckHybridAuth, @@ -24,6 +25,10 @@ vi.mock('@/lib/custom-tools/operations', () => ({ listCustomTools: mockListCustomTools, })) +vi.mock('@/lib/workflows/utils', () => ({ + readWorkflowAccessContext: mockReadWorkflowAccessContext, +})) + vi.mock('@tradinggoose/db', () => ({ db: { select: vi.fn().mockReturnValue({ @@ -52,6 +57,7 @@ describe('Custom Tools API Routes', () => { mockCreateCustomTools.mockResolvedValue([]) mockSaveCustomTool.mockResolvedValue([]) mockListCustomTools.mockResolvedValue([]) + mockReadWorkflowAccessContext.mockResolvedValue(null) }) afterEach(() => { @@ -70,14 +76,31 @@ describe('Custom Tools API Routes', () => { expect(body.error).toBe('Unauthorized') }) - it('GET should require workspaceId', async () => { + it('GET should require workspaceId or workflowId', async () => { const req = new NextRequest('http://localhost:3000/api/tools/custom') const { GET } = await import('@/app/api/tools/custom/route') const res = await GET(req) const body = await res.json() expect(res.status).toBe(400) - expect(body.error).toBe('workspaceId is required') + expect(body.error).toBe('workspaceId or workflowId is required') + }) + + it('GET should resolve workspace from workflowId', async () => { + mockReadWorkflowAccessContext.mockResolvedValue({ + workflow: { workspaceId: 'ws-1' }, + isOwner: false, + isWorkspaceOwner: false, + workspacePermission: 'read', + }) + + const req = new NextRequest('http://localhost:3000/api/tools/custom?workflowId=workflow-1') + const { GET } = await import('@/app/api/tools/custom/route') + const res = await GET(req) + + expect(res.status).toBe(200) + expect(mockReadWorkflowAccessContext).toHaveBeenCalledWith('workflow-1', 'user-123') + expect(mockListCustomTools).toHaveBeenCalledWith({ workspaceId: 'ws-1' }) }) it('POST should require workspaceId in body', async () => { diff --git a/apps/tradinggoose/app/api/tools/custom/route.ts b/apps/tradinggoose/app/api/tools/custom/route.ts index 96f5079ab..aacbd5fcd 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.ts @@ -9,6 +9,7 @@ import { CustomToolWriteRequestSchema } from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' +import { readWorkflowAccessContext } from '@/lib/workflows/utils' import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' import { deleteYjsSessionInSocketServer, @@ -21,7 +22,8 @@ const logger = createLogger('CustomToolsAPI') export async function GET(request: NextRequest) { const requestId = generateRequestId() const searchParams = request.nextUrl.searchParams - const workspaceId = searchParams.get('workspaceId') + const queryWorkspaceId = searchParams.get('workspaceId')?.trim() ?? '' + const workflowId = searchParams.get('workflowId')?.trim() ?? '' try { const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) @@ -31,15 +33,34 @@ export async function GET(request: NextRequest) { } const userId = authResult.userId - if (!workspaceId) { - logger.warn(`[${requestId}] Missing workspaceId for custom tools fetch`) - return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) - } - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!permission) { - logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + let workspaceId = queryWorkspaceId + if (!workspaceId && workflowId) { + const accessContext = await readWorkflowAccessContext(workflowId, userId) + if (!accessContext) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + if ( + !accessContext.isOwner && + !accessContext.isWorkspaceOwner && + !accessContext.workspacePermission + ) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + if (!accessContext.workflow.workspaceId) { + return NextResponse.json({ error: 'Workflow workspace is missing' }, { status: 404 }) + } + workspaceId = accessContext.workflow.workspaceId + } else if (!workspaceId) { + logger.warn(`[${requestId}] Missing workspaceId or workflowId for custom tools fetch`) + return NextResponse.json({ error: 'workspaceId or workflowId is required' }, { status: 400 }) + } else { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) + if (!permission) { + logger.warn( + `[${requestId}] User ${userId} does not have access to workspace ${workspaceId}` + ) + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } } return NextResponse.json({ data: await listCustomTools({ workspaceId }) }, { status: 200 }) diff --git a/apps/tradinggoose/tools/utils.test.ts b/apps/tradinggoose/tools/utils.test.ts index 6df949b0b..ba94a6726 100644 --- a/apps/tradinggoose/tools/utils.test.ts +++ b/apps/tradinggoose/tools/utils.test.ts @@ -780,6 +780,45 @@ describe('createCustomToolRequestBody', () => { } }) + it('uses workspaceId for server-side custom tool lookup when workflowId is also present', async () => { + const serverWindow = global.window + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + data: [ + { + id: 'custom-tool-123', + title: 'Custom Weather Tool', + code: 'return params', + schema: { + function: { + description: 'Get weather information', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + ], + }), + { status: 200 } + ) as any + ) + + try { + ;(global as any).window = undefined + + await expect( + getToolAsync('custom_custom-tool-123', 'workflow-123', 'workspace-456', 'user-123') + ).resolves.toBeDefined() + + const requestUrl = new URL(String(fetchSpy.mock.calls[0]?.[0])) + expect(requestUrl.searchParams.get('workspaceId')).toBe('workspace-456') + expect(requestUrl.searchParams.has('workflowId')).toBe(false) + } finally { + global.window = serverWindow + fetchSpy.mockRestore() + } + }) + it('does not resolve server-side custom tools by title', async () => { const serverWindow = global.window const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( diff --git a/apps/tradinggoose/tools/utils.ts b/apps/tradinggoose/tools/utils.ts index d212ee5a7..2b99c709f 100644 --- a/apps/tradinggoose/tools/utils.ts +++ b/apps/tradinggoose/tools/utils.ts @@ -399,11 +399,10 @@ async function getCustomTool( const baseUrl = getBaseUrl() const url = new URL('/api/tools/custom', baseUrl) - // Add identifiers as query parameters if available - if (workflowId) { - url.searchParams.append('workflowId', workflowId) - } else if (workspaceId) { + if (workspaceId) { url.searchParams.append('workspaceId', workspaceId) + } else if (workflowId) { + url.searchParams.append('workflowId', workflowId) } const headers: Record = {} From fd2a473e7ee9dfc08617ed4a03a09239e2d2b002 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 22:04:39 -0600 Subject: [PATCH 221/284] feat(mcp): refresh discovered tools after server mutations Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/hooks/use-mcp-tools.ts | 16 +++++++++- .../stores/copilot/tool-registry.test.ts | 31 +++++++++++++++++++ .../stores/copilot/tool-registry.ts | 7 ++++- apps/tradinggoose/stores/mcp-servers/store.ts | 2 ++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/tradinggoose/hooks/use-mcp-tools.ts b/apps/tradinggoose/hooks/use-mcp-tools.ts index 340ec9c1c..61e003182 100644 --- a/apps/tradinggoose/hooks/use-mcp-tools.ts +++ b/apps/tradinggoose/hooks/use-mcp-tools.ts @@ -11,7 +11,7 @@ import { WrenchIcon } from 'lucide-react' import { createLogger } from '@/lib/logs/console/logger' import type { McpTool } from '@/lib/mcp/types' import { createMcpToolId } from '@/lib/mcp/utils' -import { useMcpServersStore } from '@/stores/mcp-servers/store' +import { MCP_TOOLS_CHANGED_EVENT, useMcpServersStore } from '@/stores/mcp-servers/store' const logger = createLogger('useMcpTools') @@ -153,6 +153,20 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { refreshTools() }, [normalizedWorkspaceId, serversFingerprint, refreshTools, servers]) + useEffect(() => { + if (!normalizedWorkspaceId) return + + const handleToolsChanged = (event: Event) => { + const workspaceId = (event as CustomEvent<{ workspaceId?: string }>).detail?.workspaceId + if (workspaceId === normalizedWorkspaceId) { + void refreshTools(true) + } + } + + window.addEventListener(MCP_TOOLS_CHANGED_EVENT, handleToolsChanged) + return () => window.removeEventListener(MCP_TOOLS_CHANGED_EVENT, handleToolsChanged) + }, [normalizedWorkspaceId, refreshTools]) + // Auto-refresh every 5 minutes useEffect(() => { const interval = setInterval( diff --git a/apps/tradinggoose/stores/copilot/tool-registry.test.ts b/apps/tradinggoose/stores/copilot/tool-registry.test.ts index 36618e613..79ab03603 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.test.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.test.ts @@ -13,6 +13,7 @@ import { isGatedTool, prepareCopilotToolArgs, } from '@/stores/copilot/tool-registry' +import { MCP_TOOLS_CHANGED_EVENT, useMcpServersStore } from '@/stores/mcp-servers/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' describe('tool-registry', () => { @@ -313,6 +314,36 @@ describe('tool-registry', () => { } }) + it('refreshes MCP servers and notifies MCP tool discovery after server-managed MCP mutations', async () => { + class TestCustomEvent { + type: string + detail: T | undefined + + constructor(type: string, init?: CustomEventInit) { + this.type = type + this.detail = init?.detail + } + } + const dispatchEvent = vi.fn() + const fetchServers = vi + .spyOn(useMcpServersStore.getState(), 'fetchServers') + .mockResolvedValue(undefined) + vi.stubGlobal('CustomEvent', TestCustomEvent) + vi.stubGlobal('window', { dispatchEvent }) + + try { + await handleCopilotServerToolSuccess('edit_mcp_server', { workspaceId: 'workspace-1' }) + + const event = dispatchEvent.mock.calls[0]?.[0] as TestCustomEvent<{ workspaceId: string }> + expect(fetchServers).toHaveBeenCalledWith('workspace-1') + expect(event.type).toBe(MCP_TOOLS_CHANGED_EVENT) + expect(event.detail).toEqual({ workspaceId: 'workspace-1' }) + } finally { + vi.unstubAllGlobals() + fetchServers.mockRestore() + } + }) + it('invalidates the matching environment query after server-managed environment mutations', async () => { const invalidateQueries = vi .spyOn(getQueryClient(), 'invalidateQueries') diff --git a/apps/tradinggoose/stores/copilot/tool-registry.ts b/apps/tradinggoose/stores/copilot/tool-registry.ts index 54cb4476e..b274b6d5e 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.ts @@ -26,7 +26,7 @@ import { knowledgeKeys } from '@/hooks/queries/knowledge' import { skillsKeys } from '@/hooks/queries/skills' import { workflowKeys } from '@/hooks/queries/workflows' import type { CopilotToolExecutionProvenance } from '@/stores/copilot/types' -import { useMcpServersStore } from '@/stores/mcp-servers/store' +import { MCP_TOOLS_CHANGED_EVENT, useMcpServersStore } from '@/stores/mcp-servers/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('CopilotToolRegistry') @@ -358,6 +358,11 @@ export async function handleCopilotServerToolSuccess( ]) } else if (toolName.endsWith('_mcp_server')) { await useMcpServersStore.getState().fetchServers(workspaceId) + window.dispatchEvent( + new CustomEvent(MCP_TOOLS_CHANGED_EVENT, { + detail: { workspaceId }, + }) + ) } else if (toolName === CopilotTool.edit_monitor) { window.dispatchEvent( new CustomEvent(MONITOR_DATA_CHANGED_EVENT, { diff --git a/apps/tradinggoose/stores/mcp-servers/store.ts b/apps/tradinggoose/stores/mcp-servers/store.ts index a54897b0f..099ef99b1 100644 --- a/apps/tradinggoose/stores/mcp-servers/store.ts +++ b/apps/tradinggoose/stores/mcp-servers/store.ts @@ -5,6 +5,8 @@ import { initialState, type McpServersActions, type McpServersState } from './ty const logger = createLogger('McpServersStore') +export const MCP_TOOLS_CHANGED_EVENT = 'tradinggoose:mcp-tools-changed' + export const useMcpServersStore = create()( devtools( (set) => ({ From 5cb00fdac41b9a2f22a695392fd87f8abc8290c4 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Fri, 26 Jun 2026 22:04:52 -0600 Subject: [PATCH 222/284] test(socket-server): cover failed skill update on connected drafts Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/socket-server/index.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 1d759a168..0318a5d39 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -590,6 +590,14 @@ describe('Socket Server Index Integration', () => { content: 'Original content', }, }) + seedEntitySession(liveDoc, { + entityKind: 'skill', + payload: { + name: 'Unsaved Skill', + description: 'Draft', + content: 'Draft content', + }, + }) mockSaveSavedEntityYjsDocToDb.mockRejectedValueOnce(new Error('database unavailable')) const response = await applySkillSessionUpdate( From f47e6318c0f71e1432ada66e75ceb2793e16d7d9 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 12:10:59 -0600 Subject: [PATCH 223/284] fix(mcp): return 404 when servers are missing Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/mcp/servers/[id]/refresh/route.ts | 8 ++++- .../tradinggoose/app/api/mcp/servers/route.ts | 31 +++++++++---------- apps/tradinggoose/lib/mcp/service.ts | 13 ++++++-- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts index 443a4b9d7..1e05a0d9f 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { withMcpAuth } from '@/lib/mcp/middleware' -import { mcpService } from '@/lib/mcp/service' +import { McpServerNotFoundError, mcpService } from '@/lib/mcp/service' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' const logger = createLogger('McpServerRefreshAPI') @@ -39,6 +39,9 @@ export const POST = withMcpAuth('read')( `[${requestId}] Successfully connected to server ${serverId}, discovered ${toolCount} tools` ) } catch (error) { + if (error instanceof McpServerNotFoundError) { + throw error + } connectionStatus = 'error' lastError = error instanceof Error ? error.message : 'Connection test failed' logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error) @@ -53,6 +56,9 @@ export const POST = withMcpAuth('read')( }) } catch (error) { logger.error(`[${requestId}] Error refreshing MCP server:`, error) + if (error instanceof McpServerNotFoundError) { + return createMcpErrorResponse(error, 'Server not found', error.status) + } return createMcpErrorResponse( error instanceof Error ? error : new Error('Failed to refresh MCP server'), 'Failed to refresh MCP server', diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 9473c1a02..bd71eceaf 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -157,29 +157,28 @@ export const DELETE = withMcpAuth('write')( logger.info(`[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}`) - const [server] = await db - .select({ id: mcpServers.id }) - .from(mcpServers) + const [deletedServer] = await db + .delete(mcpServers) .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - .limit(1) + .returning({ id: mcpServers.id }) - if (!server) { - await deleteYjsSessionInSocketServer(serverId) - await notifyEntityListMemberRemoved('mcp_server', workspaceId, serverId) - mcpService.clearCache(workspaceId) - return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) + if (!deletedServer) { + return createMcpErrorResponse( + new Error('Server not found or access denied'), + 'Server not found', + 404 + ) } - await db - .delete(mcpServers) - .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - await deleteYjsSessionInSocketServer(serverId) - await notifyEntityListMemberRemoved('mcp_server', workspaceId, serverId) + await deleteYjsSessionInSocketServer(deletedServer.id) + await notifyEntityListMemberRemoved('mcp_server', workspaceId, deletedServer.id) mcpService.clearCache(workspaceId) - logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) - return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) + logger.info(`[${requestId}] Successfully deleted MCP server: ${deletedServer.id}`) + return createMcpSuccessResponse({ + message: `Server ${deletedServer.id} deleted successfully`, + }) } catch (error) { logger.error(`[${requestId}] Error deleting MCP server:`, error) return createMcpErrorResponse( diff --git a/apps/tradinggoose/lib/mcp/service.ts b/apps/tradinggoose/lib/mcp/service.ts index a9b5026fd..cb14f03fe 100644 --- a/apps/tradinggoose/lib/mcp/service.ts +++ b/apps/tradinggoose/lib/mcp/service.ts @@ -33,6 +33,15 @@ type McpServerListItem = { workspaceId: string } +export class McpServerNotFoundError extends Error { + readonly status = 404 + + constructor(serverId: string) { + super(`Server ${serverId} not found or not accessible`) + this.name = 'McpServerNotFoundError' + } +} + interface ToolCache { tools: McpTool[] expiry: Date @@ -331,7 +340,7 @@ class McpService { const config = await this.getServerConfig(serverId, workspaceId) if (!config) { - throw new Error(`Server ${serverId} not found or not accessible`) + throw new McpServerNotFoundError(serverId) } const resolvedConfig = await this.resolveConfigEnvVars(config, userId, workspaceId) @@ -439,7 +448,7 @@ class McpService { const config = await this.getServerConfig(serverId, workspaceId) if (!config) { - throw new Error(`Server ${serverId} not found or not accessible`) + throw new McpServerNotFoundError(serverId) } const resolvedConfig = await this.resolveConfigEnvVars(config, userId, workspaceId) From 48499e9d0d18c44fe51c4c9dfadfd83ceb17b17d Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 12:11:10 -0600 Subject: [PATCH 224/284] fix(workflows): handle realtime-required workflow state errors Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/workflows/[id]/deploy/route.test.ts | 2 +- .../app/api/workflows/[id]/deploy/route.ts | 6 ++++-- .../app/api/workflows/[id]/status/route.test.ts | 3 ++- .../app/api/workflows/[id]/status/route.ts | 12 +++++++++--- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts index 9aa3d5f2d..bb2a4abd6 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts @@ -37,7 +37,7 @@ describe('Workflow Deploy API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ deployWorkflow: vi.fn(), - loadWorkflowBootstrapStateFromDb: (...args: unknown[]) => mockLoadWorkflowState(...args), + requireEditableWorkflowState: (...args: unknown[]) => mockLoadWorkflowState(...args), })) vi.doMock('@/lib/chat/published-deployment', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts index 29b4115e9..286d3dcd9 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts @@ -7,7 +7,7 @@ import { } from '@/lib/chat/published-deployment' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { deployWorkflow, loadWorkflowBootstrapStateFromDb } from '@/lib/workflows/db-helpers' +import { deployWorkflow, requireEditableWorkflowState } from '@/lib/workflows/db-helpers' import { hasWorkflowChanged, validateWorkflowPermissions } from '@/lib/workflows/utils' import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' import { pauseMonitorsMissingDeployedTrigger } from '@/app/api/monitors/shared' @@ -103,7 +103,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .limit(1) if (active?.state) { - const currentState = await loadWorkflowBootstrapStateFromDb(id) + const currentState = await requireEditableWorkflowState(id) if (currentState) { needsRedeployment = hasWorkflowChanged(currentState, active.state as any) } @@ -123,6 +123,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ }) } catch (error: any) { logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error) + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return createErrorResponse(error.message || 'Failed to fetch deployment information', 500) } } diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts index 131b3a3a9..5bc014c4f 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts @@ -51,12 +51,13 @@ describe('Workflow Status API Route', () => { createErrorResponse: vi.fn((error, status) => Response.json({ success: false, error }, { status }) ), + createWorkflowRealtimeRequiredResponse: vi.fn(() => null), })) vi.doMock('@/lib/workflows/db-helpers', () => ({ WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', isWorkflowRealtimeRequiredError: vi.fn(() => false), - loadWorkflowBootstrapStateFromDb: mockLoadWorkflowState, + requireEditableWorkflowState: mockLoadWorkflowState, })) vi.doMock('@/lib/workflows/utils', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts index afc4501e5..17fa1a090 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts @@ -3,10 +3,14 @@ import { and, desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { loadWorkflowBootstrapStateFromDb } from '@/lib/workflows/db-helpers' +import { requireEditableWorkflowState } from '@/lib/workflows/db-helpers' import { hasWorkflowChanged } from '@/lib/workflows/utils' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { + createErrorResponse, + createSuccessResponse, + createWorkflowRealtimeRequiredResponse, +} from '@/app/api/workflows/utils' const logger = createLogger('WorkflowStatusAPI') @@ -28,7 +32,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ if (validation.workflow.isDeployed) { // Load current workflow state and the active deployment version in parallel. const [currentState, [active]] = await Promise.all([ - loadWorkflowBootstrapStateFromDb(id), + requireEditableWorkflowState(id), db .select({ state: workflowDeploymentVersion.state }) .from(workflowDeploymentVersion) @@ -55,6 +59,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ }) } catch (error) { logger.error(`[${requestId}] Error getting status for workflow: ${(await params).id}`, error) + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return createErrorResponse('Failed to get status', 500) } } From eff7328b47304a67057487cd69916c19a3b9a982 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 12:41:29 -0600 Subject: [PATCH 225/284] fix(yjs): stabilize saved-entity list sync Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../lib/yjs/server/apply-entity-state.ts | 14 ++--------- .../lib/yjs/server/bootstrap-review-target.ts | 23 ++++++++++++++----- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index e2974590f..769ce1520 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -8,14 +8,11 @@ import { } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import type * as Y from 'yjs' -import { getEntityDocumentName, normalizeEntityFields } from '@/lib/copilot/entity-documents' +import { normalizeEntityFields } from '@/lib/copilot/entity-documents' import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' import { getEntityFields, getEntityWorkspaceId } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' -import { - applyEntityStateInSocketServer, - notifyEntityListMembersAdded, -} from '@/lib/yjs/server/snapshot-bridge' +import { applyEntityStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' const SAVED_ENTITY_REALTIME_REQUIRED_CODE = 'SAVED_ENTITY_REALTIME_REQUIRED' @@ -211,11 +208,4 @@ export async function saveSavedEntityYjsDocToDb( ) } await persistSavedEntityState(entityKind, entityId, yjsFields, workspaceId) - await notifyEntityListMembersAdded(entityKind, workspaceId, [ - { - id: entityId, - name: getEntityDocumentName(entityKind, yjsFields), - ...(entityKind === 'mcp_server' ? { enabled: yjsFields.enabled !== false } : {}), - }, - ]) } diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 6122f5e63..7d8e20bda 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -88,12 +88,23 @@ export async function readBootstrappedSavedEntityListFields( workspaceId: string ): Promise }>> { const members = await readBootstrappedEntityListMembers(entityKind, workspaceId) - return Promise.all( - members.map(async (member) => ({ - ...member, - fields: await readBootstrappedSavedEntityFields(entityKind, member.entityId, workspaceId), - })) - ) + const entries: Array }> = [] + + for (const member of members) { + try { + entries.push({ + ...member, + fields: await readBootstrappedSavedEntityFields(entityKind, member.entityId, workspaceId), + }) + } catch (error) { + if (error instanceof ReviewTargetBootstrapError && error.status === 404) { + continue + } + throw error + } + } + + return entries } export async function readBootstrappedSavedEntityFields( From 4e140f5ad6afe71f192a0f6d150deff9baffbe74 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 13:12:51 -0600 Subject: [PATCH 226/284] feat(api-key): require encrypted API-key storage Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- README.md | 2 +- apps/tradinggoose/.env.example | 2 +- apps/tradinggoose/.env.example.docker | 2 + .../app/api/users/me/api-keys/route.ts | 8 +- .../app/api/workspaces/[id]/api-keys/route.ts | 8 +- apps/tradinggoose/lib/api-key/service.test.ts | 49 +++- apps/tradinggoose/lib/api-key/service.ts | 257 +++++++----------- apps/tradinggoose/lib/env.ts | 2 +- apps/tradinggoose/lib/mcp/auth.test.ts | 8 +- apps/tradinggoose/lib/mcp/auth.ts | 8 +- docker-compose.local.yml | 2 +- helm/tradinggoose/README.md | 6 +- helm/tradinggoose/examples/values-aws.yaml | 4 +- helm/tradinggoose/examples/values-azure.yaml | 4 +- .../examples/values-development.yaml | 6 +- .../examples/values-external-db.yaml | 4 +- helm/tradinggoose/examples/values-gcp.yaml | 4 +- .../examples/values-production.yaml | 4 +- .../examples/values-whitelabeled.yaml | 4 +- helm/tradinggoose/values.yaml | 6 +- packages/db/schema/workspaces.ts | 4 +- 21 files changed, 183 insertions(+), 211 deletions(-) diff --git a/README.md b/README.md index 1ab1ee07a..6f5565c0e 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ compose manifests. The `.env` must include `POSTGRES_*`, `NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_SOCKET_URL`, `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, `API_ENCRYPTION_KEY`, and `INTERNAL_API_SECRET`. The `ENCRYPTION_KEY` value is shared by both the app and realtime containers, and -`API_ENCRYPTION_KEY` enables encrypted API-key storage in the app container. +`API_ENCRYPTION_KEY` encrypts API keys at rest in the app container. `NEXT_PUBLIC_SOCKET_URL` should point at `http://localhost:3002` for local Compose runs; production deployments must override it with a browser-reachable public URL. The prod and Ollama compose files also require `IMAGE_TAG` and diff --git a/apps/tradinggoose/.env.example b/apps/tradinggoose/.env.example index c12be0f53..919d469f3 100644 --- a/apps/tradinggoose/.env.example +++ b/apps/tradinggoose/.env.example @@ -43,7 +43,7 @@ BETTER_AUTH_SECRET="replace-with-64-hex-characters" # Generate a secure 64-character hex secret. ENCRYPTION_KEY="replace-with-64-hex-characters" -# Recommended: dedicated encryption key for stored API credentials. +# Required: dedicated encryption key for stored API credentials. # Generate a secure 64-character hex secret. API_ENCRYPTION_KEY="replace-with-64-hex-characters" diff --git a/apps/tradinggoose/.env.example.docker b/apps/tradinggoose/.env.example.docker index 21191cd1e..ede27047b 100644 --- a/apps/tradinggoose/.env.example.docker +++ b/apps/tradinggoose/.env.example.docker @@ -25,6 +25,8 @@ OLLAMA_IMAGE_TAG=latest # Security (Required) # Use `openssl rand -hex 32` to generate, used to encrypt environment variables ENCRYPTION_KEY=generate-the-key +# Use `openssl rand -hex 32` to generate, used to encrypt API keys +API_ENCRYPTION_KEY=generate-the-api-key-encryption-key # Use `openssl rand -hex 32` to generate, used to encrypt internal api routes INTERNAL_API_SECRET=generate-the-secret diff --git a/apps/tradinggoose/app/api/users/me/api-keys/route.ts b/apps/tradinggoose/app/api/users/me/api-keys/route.ts index 19943e175..b1ce88f06 100644 --- a/apps/tradinggoose/app/api/users/me/api-keys/route.ts +++ b/apps/tradinggoose/app/api/users/me/api-keys/route.ts @@ -82,9 +82,9 @@ export async function POST(request: NextRequest) { ) } - const { key: plainKey, encryptedKey } = await createApiKey(true) - if (!encryptedKey) { - throw new Error('Failed to encrypt API key for storage') + const { key: plainKey, storedKey } = await createApiKey(true) + if (!storedKey) { + throw new Error('Failed to prepare API key for storage') } const [newKey] = await db @@ -94,7 +94,7 @@ export async function POST(request: NextRequest) { userId, workspaceId: null, name, - key: encryptedKey, + key: storedKey, type: 'personal', createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts b/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts index e370d8e07..b9da4bc8a 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts @@ -118,9 +118,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ) } - const { key: plainKey, encryptedKey } = await createApiKey(true) - if (!encryptedKey) { - throw new Error('Failed to encrypt API key for storage') + const { key: plainKey, storedKey } = await createApiKey(true) + if (!storedKey) { + throw new Error('Failed to prepare API key for storage') } const [newKey] = await db @@ -131,7 +131,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ userId: userId, createdBy: userId, name, - key: encryptedKey, + key: storedKey, type: 'workspace', createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/tradinggoose/lib/api-key/service.test.ts b/apps/tradinggoose/lib/api-key/service.test.ts index 01fd522e9..b6de414b3 100644 --- a/apps/tradinggoose/lib/api-key/service.test.ts +++ b/apps/tradinggoose/lib/api-key/service.test.ts @@ -31,9 +31,10 @@ vi.mock('drizzle-orm', () => ({ and: vi.fn((...conditions) => ({ conditions })), eq: vi.fn((field, value) => ({ field, value })), inArray: vi.fn((field, values) => ({ field, values })), + like: vi.fn((field, value) => ({ field, value })), })) -vi.mock('@/lib/env', () => ({ env: {} })) +vi.mock('@/lib/env', () => ({ env: { API_ENCRYPTION_KEY: 'a'.repeat(64) } })) vi.mock('@/lib/logs/console/logger', () => ({ createLogger: () => ({ debug: vi.fn(), @@ -46,7 +47,9 @@ vi.mock('@/lib/logs/console/logger', () => ({ function mockApiKeyRows(rows: unknown[]) { mockDbSelect.mockReturnValue({ from: vi.fn(() => ({ - where: vi.fn().mockResolvedValue(rows), + where: vi.fn(() => ({ + limit: vi.fn().mockResolvedValue(rows), + })), })), }) } @@ -68,14 +71,48 @@ describe('API key service', () => { expect(mockDbSelect).not.toHaveBeenCalled() }) - it('uses eq for a single key type and inArray for the default set', async () => { + it('rejects retired plaintext API-key prefixes before reading key records', async () => { const { authenticateApiKeyFromHeader } = await import('./service') - const { eq, inArray } = await import('drizzle-orm') - await authenticateApiKeyFromHeader(`tradinggoose_${'a'.repeat(32)}`) + await expect( + authenticateApiKeyFromHeader(`tradinggoose_${'a'.repeat(32)}`) + ).resolves.toMatchObject({ + success: false, + error: 'Invalid API key', + }) + expect(mockDbSelect).not.toHaveBeenCalled() + }) + + it('looks up the stored key by stable encrypted-storage prefix and scopes by key type', async () => { + const { authenticateApiKeyFromHeader, getStoredApiKey } = await import('./service') + const { eq, inArray, like } = await import('drizzle-orm') + + const apiKey = `sk-tradinggoose-${'a'.repeat(32)}` + await authenticateApiKeyFromHeader(apiKey) + const [displayKey, lookupDigest] = getStoredApiKey(apiKey).split(':') + expect(like).toHaveBeenCalledWith('apiKey.key', `${displayKey}:${lookupDigest}:%`) expect(inArray).toHaveBeenCalledWith('apiKey.type', ['personal', 'workspace']) - await authenticateApiKeyFromHeader(`tradinggoose_${'a'.repeat(32)}`, { keyTypes: ['personal'] }) + await authenticateApiKeyFromHeader(apiKey, { keyTypes: ['personal'] }) expect(eq).toHaveBeenCalledWith('apiKey.type', 'personal') }) + + it('stores encrypted API keys with stable lookup prefixes', async () => { + const { getStoredApiKey, storedApiKeyMatches } = await import('./service') + const apiKey = `sk-tradinggoose-${'b'.repeat(32)}` + const firstStoredKey = getStoredApiKey(apiKey) + const secondStoredKey = getStoredApiKey(apiKey) + + expect(firstStoredKey).not.toBe(secondStoredKey) + expect(firstStoredKey.split(':').slice(0, 2)).toEqual(secondStoredKey.split(':').slice(0, 2)) + await expect(storedApiKeyMatches(apiKey, firstStoredKey)).resolves.toBe(true) + }) + + it('rejects retired stored API-key formats without fallback decryption', async () => { + const { storedApiKeyMatches } = await import('./service') + + await expect( + storedApiKeyMatches(`sk-tradinggoose-${'b'.repeat(32)}`, 'iv:ciphertext:authTag') + ).resolves.toBe(false) + }) }) diff --git a/apps/tradinggoose/lib/api-key/service.ts b/apps/tradinggoose/lib/api-key/service.ts index 595d70bdb..6b3876b1b 100644 --- a/apps/tradinggoose/lib/api-key/service.ts +++ b/apps/tradinggoose/lib/api-key/service.ts @@ -1,14 +1,18 @@ -import { createCipheriv, createDecipheriv, randomBytes } from 'crypto' +import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from 'crypto' import { db } from '@tradinggoose/db' import { apiKey as apiKeyTable } from '@tradinggoose/db/schema' -import { and, eq, inArray, type SQL } from 'drizzle-orm' +import { and, eq, inArray, like, type SQL } from 'drizzle-orm' import { nanoid } from 'nanoid' import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('ApiKeyService') const API_KEY_SECRET_PATTERN = /^[A-Za-z0-9_-]{32}$/ +const API_KEY_PREFIX = 'sk-tradinggoose-' +const STORED_API_KEY_SEPARATOR = ':' const DEFAULT_API_KEY_AUTH_TYPES: ApiKeyType[] = ['personal', 'workspace'] +// Canonical stored shape: display:lookupDigest:iv:ciphertext:authTag. +// Retired plaintext/encrypted rows are intentionally not authenticated. export type ApiKeyType = 'personal' | 'workspace' @@ -29,15 +33,13 @@ export interface ApiKeyAuthResult { export async function createApiKey(useStorage = true): Promise<{ key: string - encryptedKey?: string + storedKey?: string }> { try { - const plainKey = - env.API_ENCRYPTION_KEY !== undefined ? generateEncryptedApiKey() : generateApiKey() + const plainKey = generateApiKey() if (useStorage) { - const encryptedKey = await encryptApiKeyForStorage(plainKey) - return { key: plainKey, encryptedKey } + return { key: plainKey, storedKey: getStoredApiKey(plainKey) } } return { key: plainKey } @@ -63,7 +65,7 @@ export async function authenticateApiKeyFromHeader( } try { - const conditions: SQL[] = [] + const conditions: SQL[] = [like(apiKeyTable.key, `${getStoredApiKeyLookupPrefix(apiKey)}%`)] if (options.userId) { conditions.push(eq(apiKeyTable.userId, options.userId)) @@ -91,28 +93,22 @@ export async function authenticateApiKeyFromHeader( }) .from(apiKeyTable) - const keyRecords = conditions.length ? await query.where(and(...conditions)) : await query - - for (const storedKey of keyRecords) { - if (storedKey.expiresAt && storedKey.expiresAt < new Date()) { - continue - } - - const isValid = await authenticateApiKey(apiKey, storedKey.key) - if (!isValid) { - continue - } - - return { - success: true, - userId: storedKey.userId, - keyId: storedKey.id, - keyType: storedKey.type as ApiKeyType, - workspaceId: storedKey.workspaceId || undefined, - } + const [storedKey] = await query.where(and(...conditions)).limit(1) + if (!storedKey || (storedKey.expiresAt && storedKey.expiresAt < new Date())) { + return { success: false, error: 'Invalid API key' } } - return { success: false, error: 'Invalid API key' } + if (!(await storedApiKeyMatches(apiKey, storedKey.key))) { + return { success: false, error: 'Invalid API key' } + } + + return { + success: true, + userId: storedKey.userId, + keyId: storedKey.id, + keyType: storedKey.type as ApiKeyType, + workspaceId: storedKey.workspaceId || undefined, + } } catch (error) { logger.error('API key authentication error:', error) return { success: false, error: 'Authentication failed' } @@ -151,173 +147,110 @@ export async function getApiKeyOwnerUserId( } } -function getApiEncryptionKey(): Buffer | null { - const key = env.API_ENCRYPTION_KEY - if (!key) { - logger.warn( - 'API_ENCRYPTION_KEY not set - API keys will be stored in plain text. Consider setting this for better security.' - ) - return null - } - if (key.length !== 64) { - throw new Error('API_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)') - } - return Buffer.from(key, 'hex') +export function generateApiKey(): string { + return `${API_KEY_PREFIX}${nanoid(32)}` } -export async function encryptApiKey(apiKey: string): Promise<{ encrypted: string; iv: string }> { - const key = getApiEncryptionKey() - if (!key) { - return { encrypted: apiKey, iv: '' } +export function isApiKeyFormat(apiKey: string): boolean { + if (isCurrentApiKeyFormat(apiKey)) { + return API_KEY_SECRET_PATTERN.test(apiKey.slice(API_KEY_PREFIX.length)) } + return false +} - const iv = randomBytes(16) - const cipher = createCipheriv('aes-256-gcm', key, iv) - let encrypted = cipher.update(apiKey, 'utf8', 'hex') - encrypted += cipher.final('hex') +export function isCurrentApiKeyFormat(apiKey: string): boolean { + return apiKey.startsWith(API_KEY_PREFIX) +} - const authTag = cipher.getAuthTag() - return { - encrypted: `${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`, - iv: iv.toString('hex'), +export function formatApiKeyForDisplay(apiKey: string): string { + const last4 = apiKey.slice(-4) + if (isCurrentApiKeyFormat(apiKey)) { + return `${API_KEY_PREFIX}...${last4}` } + return `...${last4}` } -export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted: string }> { - if (!isEncryptedKey(encryptedValue)) { - return { decrypted: encryptedValue } - } +function getApiKeyLookupDigest(apiKey: string): string { + return createHmac('sha256', getApiEncryptionKey()).update(apiKey).digest('hex') +} - const key = getApiEncryptionKey() +function getApiEncryptionKey(): Buffer { + const key = env.API_ENCRYPTION_KEY if (!key) { - return { decrypted: encryptedValue } + throw new Error('API_ENCRYPTION_KEY is required for API key storage') } - - const [ivHex, encrypted, authTagHex] = encryptedValue.split(':') - if (!ivHex || !encrypted || !authTagHex) { - throw new Error('Invalid encrypted API key format. Expected "iv:encrypted:authTag"') - } - - try { - const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivHex, 'hex')) - decipher.setAuthTag(Buffer.from(authTagHex, 'hex')) - - let decrypted = decipher.update(encrypted, 'hex', 'utf8') - decrypted += decipher.final('utf8') - - return { decrypted } - } catch (error: unknown) { - logger.error('API key decryption error:', { - error: error instanceof Error ? error.message : 'Unknown error', - }) - throw error + if (!/^[a-fA-F0-9]{64}$/.test(key)) { + throw new Error('API_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)') } + return Buffer.from(key, 'hex') } -export function isEncryptedKey(storedKey: string): boolean { - return storedKey.includes(':') && storedKey.split(':').length === 3 -} - -export async function authenticateApiKey(inputKey: string, storedKey: string): Promise { - try { - if (isEncryptedApiKeyFormat(inputKey)) { - if (!isEncryptedKey(storedKey)) { - return false - } - try { - const { decrypted } = await decryptApiKey(storedKey) - return inputKey === decrypted - } catch (decryptError) { - logger.error('Failed to decrypt stored API key:', { error: decryptError }) - return false - } - } - - if (isPlainApiKeyFormat(inputKey)) { - if (isEncryptedKey(storedKey)) { - try { - const { decrypted } = await decryptApiKey(storedKey) - return inputKey === decrypted - } catch (decryptError) { - logger.error('Failed to decrypt stored API key:', { error: decryptError }) - } - } - return inputKey === storedKey - } - - if (isEncryptedKey(storedKey)) { - try { - const { decrypted } = await decryptApiKey(storedKey) - return inputKey === decrypted - } catch (decryptError) { - logger.error('Failed to decrypt stored API key:', { error: decryptError }) - } - } - - return inputKey === storedKey - } catch (error) { - logger.error('API key authentication error:', { error }) - return false - } +function encryptApiKeyForStorage(apiKey: string): string { + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', getApiEncryptionKey(), iv) + let encrypted = cipher.update(apiKey, 'utf8', 'hex') + encrypted += cipher.final('hex') + return [ + formatApiKeyForDisplay(apiKey), + getApiKeyLookupDigest(apiKey), + iv.toString('hex'), + encrypted, + cipher.getAuthTag().toString('hex'), + ].join(STORED_API_KEY_SEPARATOR) } -export async function encryptApiKeyForStorage(apiKey: string): Promise { - try { - const { encrypted } = await encryptApiKey(apiKey) - return encrypted - } catch (error) { - logger.error('API key encryption error:', { error }) - throw new Error('Failed to encrypt API key') +function decryptStoredApiKey(storedApiKey: string): string { + const [, , ivHex, encrypted, authTagHex] = storedApiKey.split(STORED_API_KEY_SEPARATOR) + if (!ivHex || !encrypted || !authTagHex) { + throw new Error('Invalid stored API key format') } -} -export function generateApiKey(): string { - return `tradinggoose_${nanoid(32)}` + const decipher = createDecipheriv('aes-256-gcm', getApiEncryptionKey(), Buffer.from(ivHex, 'hex')) + decipher.setAuthTag(Buffer.from(authTagHex, 'hex')) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + return decrypted } -export function generateEncryptedApiKey(): string { - return `sk-tradinggoose-${nanoid(32)}` +function getStoredApiKeyLookupPrefix(apiKey: string): string { + return [formatApiKeyForDisplay(apiKey), getApiKeyLookupDigest(apiKey), ''].join( + STORED_API_KEY_SEPARATOR + ) } -export function isApiKeyFormat(apiKey: string): boolean { - if (isEncryptedApiKeyFormat(apiKey)) { - return API_KEY_SECRET_PATTERN.test(apiKey.slice('sk-tradinggoose-'.length)) - } - if (isPlainApiKeyFormat(apiKey)) { - return API_KEY_SECRET_PATTERN.test(apiKey.slice('tradinggoose_'.length)) - } - return false +export function getStoredApiKey(apiKey: string): string { + return encryptApiKeyForStorage(apiKey) } -export function isEncryptedApiKeyFormat(apiKey: string): boolean { - return apiKey.startsWith('sk-tradinggoose-') +function isStoredApiKeyFormat(storedApiKey: string): boolean { + const [displayKey, lookupDigest, iv, encrypted, authTag, extra] = + storedApiKey.split(STORED_API_KEY_SEPARATOR) + return Boolean(displayKey && lookupDigest?.length === 64 && iv && encrypted && authTag && !extra) } -export function isPlainApiKeyFormat(apiKey: string): boolean { - return apiKey.startsWith('tradinggoose_') && !apiKey.startsWith('sk-tradinggoose-') +function constantTimeEqual(left: string, right: string): boolean { + return left.length === right.length && timingSafeEqual(Buffer.from(left), Buffer.from(right)) } -export function formatApiKeyForDisplay(apiKey: string): string { - const last4 = apiKey.slice(-4) - if (isEncryptedApiKeyFormat(apiKey)) { - return `sk-tradinggoose-...${last4}` +export async function storedApiKeyMatches(apiKey: string, storedApiKey: string): Promise { + if (!isApiKeyFormat(apiKey) || !isStoredApiKeyFormat(storedApiKey)) { + return false } - if (isPlainApiKeyFormat(apiKey)) { - return `tradinggoose_...${last4}` + const [, lookupDigest] = storedApiKey.split(STORED_API_KEY_SEPARATOR) + if (!lookupDigest || !constantTimeEqual(getApiKeyLookupDigest(apiKey), lookupDigest)) { + return false + } + try { + return constantTimeEqual(apiKey, decryptStoredApiKey(storedApiKey)) + } catch (error) { + logger.error('Failed to decrypt stored API key:', { error }) + return false } - return `...${last4}` -} - -export async function storedApiKeyMatches(apiKey: string, storedApiKey: string): Promise { - return authenticateApiKey(apiKey, storedApiKey) } export async function getApiKeyDisplayFormat(storedApiKey: string): Promise { - try { - const { decrypted } = await decryptApiKey(storedApiKey) - return formatApiKeyForDisplay(decrypted) - } catch (error) { - logger.error('Failed to format API key for display:', { error }) + if (!isStoredApiKeyFormat(storedApiKey)) { return '****' } + return storedApiKey.split(STORED_API_KEY_SEPARATOR)[0] } diff --git a/apps/tradinggoose/lib/env.ts b/apps/tradinggoose/lib/env.ts index 19d71907b..4b7b2c3a8 100644 --- a/apps/tradinggoose/lib/env.ts +++ b/apps/tradinggoose/lib/env.ts @@ -68,7 +68,7 @@ function safeCreateEnv() { ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data - API_ENCRYPTION_KEY: z.string().min(32).optional(), // Dedicated key for encrypting API keys (optional for OSS) + API_ENCRYPTION_KEY: z.string().regex(/^[a-fA-F0-9]{64}$/), // Dedicated key for encrypting API keys INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication // Database & Storage diff --git a/apps/tradinggoose/lib/mcp/auth.test.ts b/apps/tradinggoose/lib/mcp/auth.test.ts index c54a761f9..2faec2cea 100644 --- a/apps/tradinggoose/lib/mcp/auth.test.ts +++ b/apps/tradinggoose/lib/mcp/auth.test.ts @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { db, mockEncryptApiKeyForStorage, mockIsApiKeyFormat } = vi.hoisted(() => ({ +const { db, mockGetStoredApiKey, mockIsApiKeyFormat } = vi.hoisted(() => ({ db: { select: vi.fn(), insert: vi.fn(), @@ -12,7 +12,7 @@ const { db, mockEncryptApiKeyForStorage, mockIsApiKeyFormat } = vi.hoisted(() => update: vi.fn(), transaction: vi.fn(), }, - mockEncryptApiKeyForStorage: vi.fn(), + mockGetStoredApiKey: vi.fn(), mockIsApiKeyFormat: vi.fn(), })) @@ -32,7 +32,7 @@ vi.mock('drizzle-orm', () => ({ lte: vi.fn((field, value) => ({ field, value })), })) vi.mock('@/lib/api-key/service', () => ({ - encryptApiKeyForStorage: mockEncryptApiKeyForStorage, + getStoredApiKey: mockGetStoredApiKey, isApiKeyFormat: mockIsApiKeyFormat, })) vi.mock('@/lib/env', () => ({ env: { INTERNAL_API_SECRET: '12345678901234567890123456789012' } })) @@ -80,7 +80,7 @@ describe('MCP device login auth', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-06-19T12:00:00.000Z')) vi.clearAllMocks() - mockEncryptApiKeyForStorage.mockResolvedValue('encrypted-api-key') + mockGetStoredApiKey.mockReturnValue('stored-api-key') mockIsApiKeyFormat.mockReturnValue(true) mockDelete() selectRows() diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 3d3e643a2..635b13c1f 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -3,7 +3,7 @@ import { db } from '@tradinggoose/db' import { apiKey, verification } from '@tradinggoose/db/schema' import { and, eq, like, lte } from 'drizzle-orm' import { nanoid } from 'nanoid' -import { encryptApiKeyForStorage, isApiKeyFormat } from '@/lib/api-key/service' +import { getStoredApiKey, isApiKeyFormat } from '@/lib/api-key/service' import { env } from '@/lib/env' import { getBaseUrl } from '@/lib/urls/utils' @@ -92,7 +92,7 @@ function createDeviceLoginApiKey(code: string, verificationKey: string): string .update(`mcp-api-key.${buildDeviceLoginId(code)}.${hashValue(verificationKey)}`) .digest('base64url') .slice(0, 32) - return `${env.API_ENCRYPTION_KEY !== undefined ? 'sk-tradinggoose-' : 'tradinggoose_'}${secret}` + return `sk-tradinggoose-${secret}` } function approvalTokenMatches(code: string, userId: string, approvalToken: string): boolean { @@ -455,7 +455,7 @@ export async function acknowledgeMcpDeviceLogin({ const now = new Date() const { apiKeyHash: _apiKeyHash, ...approvedState } = login.state - const encryptedKey = await encryptApiKeyForStorage(plainApiKey) + const storedKey = getStoredApiKey(plainApiKey) const delivered = await db.transaction(async (tx) => { const [updated] = await tx .update(verification) @@ -476,7 +476,7 @@ export async function acknowledgeMcpDeviceLogin({ userId: approvedState.userId, workspaceId: null, name: `TradingGoose MCP Access ${now.toISOString()}`, - key: encryptedKey, + key: storedKey, type: 'personal', createdAt: now, updatedAt: now, diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 81cf11965..03e27c064 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -25,7 +25,7 @@ services: - COPILOT_API_URL=${COPILOT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002} - - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} + - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:?set API_ENCRYPTION_KEY in .env} depends_on: redis: condition: service_healthy diff --git a/helm/tradinggoose/README.md b/helm/tradinggoose/README.md index c2e561344..5f43e1312 100644 --- a/helm/tradinggoose/README.md +++ b/helm/tradinggoose/README.md @@ -641,7 +641,7 @@ For production deployments, make sure to: **Optional Security (Recommended for Production):** - `CRON_SECRET`: Authenticates scheduled job requests to API endpoints (required only if `cronjobs.enabled=true`) -- `API_ENCRYPTION_KEY`: Encrypts API keys at rest in database (must be exactly 64 hex characters). If not set, API keys are stored in plain text. Generate using: `openssl rand -hex 32` (outputs 64 hex chars representing 32 bytes) +- `API_ENCRYPTION_KEY`: Encrypts API keys at rest in database (must be exactly 64 hex characters). Generate using: `openssl rand -hex 32` (outputs 64 hex chars representing 32 bytes) ### Example secure values: @@ -652,7 +652,7 @@ app: ENCRYPTION_KEY: "your-secure-encryption-key-here" INTERNAL_API_SECRET: "your-secure-internal-api-secret-here" CRON_SECRET: "your-secure-cron-secret-here" - API_ENCRYPTION_KEY: "your-64-char-hex-string-for-api-key-encryption" # Optional but recommended + API_ENCRYPTION_KEY: "your-64-char-hex-string-for-api-key-encryption" postgresql: auth: @@ -704,4 +704,4 @@ kubectl logs job/-migrations - Documentation: https://docs.tradinggoose.ai - GitHub Issues: https://github.com/TradingGoose/TradingGoose-Studio/issues -- Discord: https://discord.gg/wavf5JWhuT \ No newline at end of file +- Discord: https://discord.gg/wavf5JWhuT diff --git a/helm/tradinggoose/examples/values-aws.yaml b/helm/tradinggoose/examples/values-aws.yaml index f4472ea87..f84e573b3 100644 --- a/helm/tradinggoose/examples/values-aws.yaml +++ b/helm/tradinggoose/examples/values-aws.yaml @@ -37,9 +37,9 @@ app: INTERNAL_API_SECRET: "your-secure-production-internal-api-secret-here" CRON_SECRET: "your-secure-production-cron-secret-here" - # Optional: API Key Encryption (RECOMMENDED for production) + # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" # Optional but recommended + API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" NODE_ENV: "production" NEXT_TELEMETRY_DISABLED: "1" diff --git a/helm/tradinggoose/examples/values-azure.yaml b/helm/tradinggoose/examples/values-azure.yaml index bab801160..6d7f0d735 100644 --- a/helm/tradinggoose/examples/values-azure.yaml +++ b/helm/tradinggoose/examples/values-azure.yaml @@ -35,9 +35,9 @@ app: INTERNAL_API_SECRET: "your-secure-production-internal-api-secret-here" CRON_SECRET: "your-secure-production-cron-secret-here" - # Optional: API Key Encryption (RECOMMENDED for production) + # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" # Optional but recommended + API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" NODE_ENV: "production" NEXT_TELEMETRY_DISABLED: "1" diff --git a/helm/tradinggoose/examples/values-development.yaml b/helm/tradinggoose/examples/values-development.yaml index 7572dd7e2..2e489a074 100644 --- a/helm/tradinggoose/examples/values-development.yaml +++ b/helm/tradinggoose/examples/values-development.yaml @@ -32,9 +32,9 @@ app: INTERNAL_API_SECRET: "dev-32-char-internal-secret-not-secure" CRON_SECRET: "dev-32-char-cron-secret-not-for-prod" - # Optional: API Key Encryption (leave empty for dev, encrypts API keys at rest) - # For production, generate 64-char hex using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "" # Optional - if not set, API keys stored in plain text + # API Key Encryption + # Generate 64-character hex string using: openssl rand -hex 32 + API_ENCRYPTION_KEY: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # Realtime service realtime: diff --git a/helm/tradinggoose/examples/values-external-db.yaml b/helm/tradinggoose/examples/values-external-db.yaml index a4a79d351..d9ad50fb0 100644 --- a/helm/tradinggoose/examples/values-external-db.yaml +++ b/helm/tradinggoose/examples/values-external-db.yaml @@ -31,9 +31,9 @@ app: INTERNAL_API_SECRET: "" # Set via --set flag or external secret manager CRON_SECRET: "" # Set via --set flag or external secret manager - # Optional: API Key Encryption (RECOMMENDED for production) + # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "" # Optional but recommended - encrypts API keys at rest + API_ENCRYPTION_KEY: "" # Required; set via --set flag or external secret manager NODE_ENV: "production" NEXT_TELEMETRY_DISABLED: "1" diff --git a/helm/tradinggoose/examples/values-gcp.yaml b/helm/tradinggoose/examples/values-gcp.yaml index 2746abea5..6a6fd72a4 100644 --- a/helm/tradinggoose/examples/values-gcp.yaml +++ b/helm/tradinggoose/examples/values-gcp.yaml @@ -37,9 +37,9 @@ app: INTERNAL_API_SECRET: "your-secure-production-internal-api-secret-here" CRON_SECRET: "your-secure-production-cron-secret-here" - # Optional: API Key Encryption (RECOMMENDED for production) + # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" # Optional but recommended + API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" NODE_ENV: "production" NEXT_TELEMETRY_DISABLED: "1" diff --git a/helm/tradinggoose/examples/values-production.yaml b/helm/tradinggoose/examples/values-production.yaml index 21319e423..cc9e7fa71 100644 --- a/helm/tradinggoose/examples/values-production.yaml +++ b/helm/tradinggoose/examples/values-production.yaml @@ -32,9 +32,9 @@ app: INTERNAL_API_SECRET: "your-production-internal-api-secret-here" CRON_SECRET: "your-production-cron-secret-here" - # Optional: API Key Encryption (RECOMMENDED for production) + # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" # Optional but recommended + API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" # Email verification (set to true if you want to require email verification) EMAIL_VERIFICATION_ENABLED: "false" diff --git a/helm/tradinggoose/examples/values-whitelabeled.yaml b/helm/tradinggoose/examples/values-whitelabeled.yaml index cc07a0dda..3f13cc10a 100644 --- a/helm/tradinggoose/examples/values-whitelabeled.yaml +++ b/helm/tradinggoose/examples/values-whitelabeled.yaml @@ -25,9 +25,9 @@ app: INTERNAL_API_SECRET: "your-production-internal-api-secret-here" CRON_SECRET: "your-production-cron-secret-here" - # Optional: API Key Encryption (RECOMMENDED for production) + # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" # Optional but recommended + API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" # UI Branding & Whitelabeling Configuration NEXT_PUBLIC_BRAND_NAME: "Acme AI Studio" diff --git a/helm/tradinggoose/values.yaml b/helm/tradinggoose/values.yaml index b5432b470..09555d2d8 100644 --- a/helm/tradinggoose/values.yaml +++ b/helm/tradinggoose/values.yaml @@ -68,10 +68,10 @@ app: # Generate using: openssl rand -hex 32 CRON_SECRET: "" # OPTIONAL - required only if cronjobs.enabled=true, authenticates scheduled job requests - # Optional: API Key Encryption (RECOMMENDED for production) + # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 (outputs 64 hex chars = 32 bytes) - API_ENCRYPTION_KEY: "" # OPTIONAL - encrypts API keys at rest, must be exactly 64 hex characters, if not set keys stored in plain text - + API_ENCRYPTION_KEY: "" # REQUIRED - encrypts API keys at rest + # Email & Communication EMAIL_VERIFICATION_ENABLED: "false" # Enable email verification for user registration and login (defaults to false) RESEND_API_KEY: "" # Resend API key for transactional emails diff --git a/packages/db/schema/workspaces.ts b/packages/db/schema/workspaces.ts index da51ee31d..b4dec8a00 100644 --- a/packages/db/schema/workspaces.ts +++ b/packages/db/schema/workspaces.ts @@ -79,7 +79,7 @@ export const apiKey = pgTable( createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), // Who created the workspace key name: text('name').notNull(), key: text('key').notNull().unique(), - type: text('type').notNull().default('personal'), // 'personal', 'workspace', or 'mcp' + type: text('type').notNull().default('personal'), // 'personal' or 'workspace' lastUsed: timestamp('last_used'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), @@ -89,7 +89,7 @@ export const apiKey = pgTable( // Ensure only workspace keys have a workspace_id. workspaceTypeCheck: check( 'workspace_type_check', - sql`(type = 'workspace' AND workspace_id IS NOT NULL) OR (type IN ('personal', 'mcp') AND workspace_id IS NULL)` + sql`(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)` ), }) ) From 6684fb66f962cd2dc381fb2a0eb1d19053172654 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 13:58:32 -0600 Subject: [PATCH 227/284] feat(yjs): sync entity lists from saved entity writes Keep list sessions updated through explicit member mutations and synchronize entity renames and enabled flags after persistence.\n\nCo-authored-by: Codex \nCo-authored-by: BWJ2310 \nCo-authored-by: BWJ2310-backup --- .../tradinggoose/app/api/mcp/servers/route.ts | 4 +- .../tools/server/entities/mcp-server.ts | 4 +- .../lib/custom-tools/operations.ts | 6 +- .../lib/indicators/custom/operations.ts | 6 +- apps/tradinggoose/lib/knowledge/service.ts | 6 +- apps/tradinggoose/lib/skills/operations.ts | 6 +- apps/tradinggoose/lib/yjs/entity-session.ts | 32 +++-- .../lib/yjs/server/apply-entity-state.test.ts | 2 - .../lib/yjs/server/snapshot-bridge.ts | 41 ++---- apps/tradinggoose/socket-server/index.test.ts | 46 ++++--- .../tradinggoose/socket-server/routes/http.ts | 117 +++++++++++++++--- 11 files changed, 179 insertions(+), 91 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index bd71eceaf..d92f82b48 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -10,7 +10,7 @@ import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/util import { deleteYjsSessionInSocketServer, notifyEntityListMemberRemoved, - notifyEntityListMembersAdded, + notifyEntityListMembersUpserted, } from '@/lib/yjs/server/snapshot-bridge' import { CreateMcpServerSchema } from './schema' @@ -101,7 +101,7 @@ export const POST = withMcpAuth('write')( }) try { - await notifyEntityListMembersAdded('mcp_server', workspaceId, [ + await notifyEntityListMembersUpserted('mcp_server', workspaceId, [ { id: serverId, name: String(fields.name ?? ''), enabled: fields.enabled !== false }, ]) } catch (error) { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts index 8750c8938..5a50779d1 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -12,7 +12,7 @@ import { mcpService } from '@/lib/mcp/service' import type { McpTransport } from '@/lib/mcp/types' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' -import { notifyEntityListMembersAdded } from '@/lib/yjs/server/snapshot-bridge' +import { notifyEntityListMembersUpserted } from '@/lib/yjs/server/snapshot-bridge' import { buildDocumentEnvelope, buildSavedEntityListInfo, @@ -129,7 +129,7 @@ async function createMcpServerEntity( const savedFields = savedEntityRowToFields(ENTITY_KIND_MCP_SERVER, row) try { - await notifyEntityListMembersAdded('mcp_server', workspaceId, [ + await notifyEntityListMembersUpserted('mcp_server', workspaceId, [ { id: entityId, name: String(normalized.name ?? ''), enabled: normalized.enabled !== false }, ]) } catch (error) { diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index 74fd09ba8..28d9cec87 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -11,7 +11,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' import { readBootstrappedSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' -import { notifyEntityListMembersAdded } from '@/lib/yjs/server/snapshot-bridge' +import { notifyEntityListMembersUpserted } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('CustomToolsOperations') @@ -105,7 +105,7 @@ export async function createCustomTools({ return createdTools }) - await notifyEntityListMembersAdded( + await notifyEntityListMembersUpserted( 'custom_tool', workspaceId, created.map((createdTool) => ({ id: createdTool.id, name: createdTool.title })) @@ -183,7 +183,7 @@ export async function importCustomTools({ } }) - await notifyEntityListMembersAdded( + await notifyEntityListMembersUpserted( 'custom_tool', workspaceId, result.tools.map((importedTool) => ({ id: importedTool.id, name: importedTool.title })) diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index 499e8e152..1b48b1370 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -11,7 +11,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' import { readBootstrappedSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' -import { notifyEntityListMembersAdded } from '@/lib/yjs/server/snapshot-bridge' +import { notifyEntityListMembersUpserted } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('IndicatorsOperations') @@ -99,7 +99,7 @@ export async function createIndicators({ return createdIndicators }) - await notifyEntityListMembersAdded( + await notifyEntityListMembersUpserted( 'indicator', workspaceId, created.map((createdIndicator) => ({ id: createdIndicator.id, name: createdIndicator.name })) @@ -187,7 +187,7 @@ export async function importIndicators({ } }) - await notifyEntityListMembersAdded( + await notifyEntityListMembersUpserted( 'indicator', workspaceId, result.indicators.map((imported) => ({ id: imported.id, name: imported.name })) diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index c77c6ea79..edf1e2dc7 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -26,7 +26,7 @@ import { readBootstrappedSavedEntityListFields } from '@/lib/yjs/server/bootstra import { deleteYjsSessionInSocketServer, notifyEntityListMemberRemoved, - notifyEntityListMembersAdded, + notifyEntityListMembersUpserted, } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('KnowledgeBaseService') @@ -104,7 +104,7 @@ export async function createKnowledgeBase( docCount: 0, } - await notifyEntityListMembersAdded('knowledge_base', data.workspaceId, [ + await notifyEntityListMembersUpserted('knowledge_base', data.workspaceId, [ { id: created.id, name: created.name }, ]) return created @@ -343,7 +343,7 @@ export async function copyKnowledgeBaseToWorkspace( `[${requestId}] Copied knowledge base ${sourceKnowledgeBaseId} to workspace ${targetWorkspaceId} as ${newKnowledgeBaseId}` ) - await notifyEntityListMembersAdded('knowledge_base', targetWorkspaceId, [ + await notifyEntityListMembersUpserted('knowledge_base', targetWorkspaceId, [ { id: copied.id, name: copied.name }, ]) return copied diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index 277069c02..fb81e5d43 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -14,7 +14,7 @@ import { readBootstrappedSavedEntityListFields } from '@/lib/yjs/server/bootstra import { deleteYjsSessionInSocketServer, notifyEntityListMemberRemoved, - notifyEntityListMembersAdded, + notifyEntityListMembersUpserted, } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('SkillsOperations') @@ -137,7 +137,7 @@ export async function createSkills({ return createdSkills }) - await notifyEntityListMembersAdded( + await notifyEntityListMembersUpserted( 'skill', workspaceId, created.map((createdSkill) => ({ id: createdSkill.id, name: createdSkill.name })) @@ -228,7 +228,7 @@ export async function importSkills({ } }) - await notifyEntityListMembersAdded( + await notifyEntityListMembersUpserted( 'skill', workspaceId, result.skills.map((importedSkill) => ({ id: importedSkill.id, name: importedSkill.name })) diff --git a/apps/tradinggoose/lib/yjs/entity-session.ts b/apps/tradinggoose/lib/yjs/entity-session.ts index 952b5f4ae..8b50c0f62 100644 --- a/apps/tradinggoose/lib/yjs/entity-session.ts +++ b/apps/tradinggoose/lib/yjs/entity-session.ts @@ -43,7 +43,7 @@ export interface EntityListMember { } export type EntityListMemberMutation = - | { op: 'add'; entityId: string; name: string; enabled?: boolean } + | { op: 'upsert'; entityId: string; name: string; enabled?: boolean } | { op: 'remove'; entityId: string } function getEntityListMembersMap( @@ -52,6 +52,19 @@ function getEntityListMembersMap( return doc.getMap('members') } +export function getEntityListMemberFromFields( + entityKind: Exclude, + entityId: string, + fields: Record +): { id: string; name: string; enabled?: boolean } { + const nameKey = entityKind === 'custom_tool' ? 'title' : 'name' + return { + id: entityId, + name: String(fields[nameKey] ?? ''), + ...(entityKind === 'mcp_server' ? { enabled: fields.enabled !== false } : {}), + } +} + export function seedEntityListSession( doc: Y.Doc, members: Array<{ id: string; name: string; enabled?: boolean }> @@ -71,7 +84,7 @@ function applyEntityListMutation(doc: Y.Doc, mutation: EntityListMemberMutation) doc.transact(() => { getEntityListMembersMap(doc).set( mutation.entityId, - mutation.op === 'add' + mutation.op === 'upsert' ? { name: mutation.name, deleted: false, @@ -82,17 +95,12 @@ function applyEntityListMutation(doc: Y.Doc, mutation: EntityListMemberMutation) }, YJS_ORIGINS.SYSTEM) } -export function createEntityListMemberUpdate( +export function applyEntityListMutations( + doc: Y.Doc, mutations: EntityListMemberMutation | EntityListMemberMutation[] -): Uint8Array { - const doc = new Y.Doc() - try { - for (const mutation of Array.isArray(mutations) ? mutations : [mutations]) { - applyEntityListMutation(doc, mutation) - } - return Y.encodeStateAsUpdate(doc) - } finally { - doc.destroy() +): void { + for (const mutation of Array.isArray(mutations) ? mutations : [mutations]) { + applyEntityListMutation(doc, mutation) } } diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts index ee04d1d87..d6094c456 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts @@ -42,7 +42,6 @@ vi.mock('drizzle-orm', () => ({ vi.mock('@/lib/copilot/entity-documents', () => ({ normalizeEntityFields: vi.fn((_entityKind, fields) => fields), - getEntityDocumentName: vi.fn((_entityKind, fields) => String(fields?.name ?? '')), })) vi.mock('@/lib/custom-tools/schema', () => ({ @@ -51,7 +50,6 @@ vi.mock('@/lib/custom-tools/schema', () => ({ vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ applyEntityStateInSocketServer: mockApplyEntityStateInSocketServer, - notifyEntityListMembersAdded: vi.fn(), })) function buildDoc(fields: Record, workspaceId: string | null = 'workspace-1') { diff --git a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts index 9ce95a3ae..7fdf4a6db 100644 --- a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts +++ b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts @@ -1,17 +1,9 @@ -import { - buildEntityListDescriptor, - buildYjsTransportEnvelope, - serializeYjsTransportEnvelope, -} from '@/lib/copilot/review-sessions/identity' +import { buildEntityListDescriptor } from '@/lib/copilot/review-sessions/identity' import type { ReviewTargetDescriptor, ReviewTargetRuntimeState, } from '@/lib/copilot/review-sessions/types' import { env, getInternalRealtimeUrl } from '@/lib/env' -import { - createEntityListMemberUpdate, - type EntityListMemberMutation, -} from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import type { WorkflowMetadataPatch, WorkflowSnapshot } from '@/lib/yjs/workflow-session' @@ -159,36 +151,24 @@ export async function applyYjsUpdateInSocketServer( ) } -async function applyEntityListUpdateInSocketServer( +async function postEntityListMembersToSocketServer( entityKind: SavedEntityKind, workspaceId: string, - mutation: EntityListMemberMutation | EntityListMemberMutation[] + body: unknown ): Promise { const descriptor = buildEntityListDescriptor(entityKind, workspaceId) - const params = serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor)) - const search = new URLSearchParams(params).toString() - await applyYjsUpdateInSocketServer( - descriptor.yjsSessionId, - `?${search}`, - Buffer.from(createEntityListMemberUpdate(mutation)).toString('base64') + await postJsonToSocketServer( + `/internal/yjs/sessions/${encodeURIComponent(descriptor.yjsSessionId)}/members`, + body ) } -export async function notifyEntityListMembersAdded( +export async function notifyEntityListMembersUpserted( entityKind: SavedEntityKind, workspaceId: string, members: Array<{ id: string; name: string; enabled?: boolean }> ): Promise { - await applyEntityListUpdateInSocketServer( - entityKind, - workspaceId, - members.map((member) => ({ - op: 'add', - entityId: member.id, - name: member.name, - ...(typeof member.enabled === 'boolean' ? { enabled: member.enabled } : {}), - })) - ) + await postEntityListMembersToSocketServer(entityKind, workspaceId, { members }) } export async function notifyEntityListMemberRemoved( @@ -196,10 +176,7 @@ export async function notifyEntityListMemberRemoved( workspaceId: string, entityId: string ): Promise { - await applyEntityListUpdateInSocketServer(entityKind, workspaceId, { - op: 'remove', - entityId, - }) + await postEntityListMembersToSocketServer(entityKind, workspaceId, { remove: entityId }) } export async function deleteYjsSessionInSocketServer(sessionId: string): Promise { diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 0318a5d39..0360ba646 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -3,12 +3,18 @@ * * @vitest-environment node */ +import { EventEmitter } from 'node:events' import { createServer, request as httpRequest } from 'http' import { io as createClient } from 'socket.io-client' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import * as Y from 'yjs' import { createLogger } from '@/lib/logs/console/logger' -import { getEntityFields, seedEntitySession } from '@/lib/yjs/entity-session' +import { + getEntityFields, + getEntityListMembers, + seedEntityListSession, + seedEntitySession, +} from '@/lib/yjs/entity-session' import { extractPersistedStateFromDoc, setVariables, @@ -240,6 +246,16 @@ function applySkillSessionUpdate(port: number, sessionId: string, updateBase64: ) } +async function connectTestDocument(docId: string) { + const conn = new EventEmitter() as any + conn.readyState = 1 + conn.send = vi.fn((_message, _options, callback) => callback?.()) + conn.ping = vi.fn() + conn.close = vi.fn() + setupWSConnection(conn, {} as any, { docId }) + return { conn, doc: (await getExistingDocument(docId))! } +} + describe('Socket Server Index Integration', () => { let httpServer: any let io: any @@ -446,6 +462,9 @@ describe('Socket Server Index Integration', () => { }) it('should apply saved entity state through Yjs', async () => { + const { conn, doc: listDoc } = await connectTestDocument('list:skill:workspace-1') + seedEntityListSession(listDoc, [{ id: 'skill-1', name: 'Old Skill' }]) + const response = await sendHttpRequestWithOptions( PORT, '/internal/yjs/entities/skill-1/apply-state', @@ -479,6 +498,15 @@ describe('Socket Server Index Integration', () => { }, ]) expect(await getExistingDocument('skill-1')).toBeNull() + expect(getEntityListMembers(listDoc)).toEqual([ + { + entityId: 'skill-1', + entityName: 'Risk Skill', + }, + ]) + + conn.emit('close') + await new Promise((resolve) => setImmediate(resolve)) }) it('should discard an idle workflow document when materialization fails', async () => { @@ -511,13 +539,7 @@ describe('Socket Server Index Integration', () => { }) it('does not mutate a connected live workflow session when persistence fails', async () => { - const conn = new (await import('node:events')).EventEmitter() as any - conn.readyState = 1 - conn.send = vi.fn((_message, _options, callback) => callback?.()) - conn.ping = vi.fn() - conn.close = vi.fn() - setupWSConnection(conn, {} as any, { docId: 'workflow-connected' }) - const liveDoc = (await getExistingDocument('workflow-connected'))! + const { conn, doc: liveDoc } = await connectTestDocument('workflow-connected') setWorkflowState( liveDoc, { blocks: { keep: { id: 'keep' } as any }, edges: [], loops: {}, parallels: {} }, @@ -575,13 +597,7 @@ describe('Socket Server Index Integration', () => { }) it('keeps a connected saved entity draft when update materialization fails', async () => { - const conn = new (await import('node:events')).EventEmitter() as any - conn.readyState = 1 - conn.send = vi.fn((_message, _options, callback) => callback?.()) - conn.ping = vi.fn() - conn.close = vi.fn() - setupWSConnection(conn, {} as any, { docId: 'skill-update-connected' }) - const liveDoc = (await getExistingDocument('skill-update-connected'))! + const { conn, doc: liveDoc } = await connectTestDocument('skill-update-connected') seedEntitySession(liveDoc, { entityKind: 'skill', payload: { diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index 26be48add..7130cc4f7 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -1,6 +1,7 @@ import type { IncomingMessage, ServerResponse } from 'http' import * as Y from 'yjs' import { + buildEntityListDescriptor, buildReviewTargetDescriptorFromEnvelope, buildSavedEntityDescriptor, isEntityListSessionId, @@ -9,7 +10,14 @@ import { import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' import { env } from '@/lib/env' import { saveWorkflowYjsDocToDb } from '@/lib/workflows/db-helpers' -import { seedEntitySession } from '@/lib/yjs/entity-session' +import { + applyEntityListMutations, + type EntityListMemberMutation, + getEntityFields, + getEntityListMemberFromFields, + getEntityWorkspaceId, + seedEntitySession, +} from '@/lib/yjs/entity-session' import { SavedEntityPersistenceError, saveSavedEntityYjsDocToDb, @@ -57,6 +65,7 @@ const INTERNAL_YJS_ENTITY_APPLY_PATH = /^\/internal\/yjs\/entities\/([^/]+)\/app const INTERNAL_YJS_SNAPSHOT_PATH = /^\/internal\/yjs\/sessions\/([^/]+)\/snapshot$/ const INTERNAL_YJS_SESSION_DELETE_PATH = /^\/internal\/yjs\/sessions\/([^/]+)$/ const INTERNAL_YJS_SESSION_APPLY_UPDATE_PATH = /^\/internal\/yjs\/sessions\/([^/]+)\/apply-update$/ +const INTERNAL_YJS_ENTITY_LIST_MEMBERS_PATH = /^\/internal\/yjs\/sessions\/([^/]+)\/members$/ type ApplyWorkflowStateRequest = { workflowState?: WorkflowSnapshot @@ -311,6 +320,86 @@ function applyWorkflowApplyRequest(doc: Y.Doc, body: ApplyWorkflowStateRequest): if (body.metadata) setWorkflowEntityMetadata(doc, body.metadata) } +async function syncEntityListMemberFromDoc( + entityKind: SavedEntityKind, + entityId: string, + entityDoc: Y.Doc +): Promise { + const workspaceId = getEntityWorkspaceId(entityDoc) + if (!workspaceId) { + return + } + + const descriptor = buildEntityListDescriptor(entityKind, workspaceId) + const listDoc = await getExistingDocument(descriptor.yjsSessionId) + if (!listDoc) { + return + } + + const member = getEntityListMemberFromFields( + entityKind, + entityId, + getEntityFields(entityDoc, entityKind) + ) + applyEntityListMutations(listDoc, { + op: 'upsert', + entityId: member.id, + name: member.name, + ...(typeof member.enabled === 'boolean' ? { enabled: member.enabled } : {}), + }) + markDocumentPersisted(listDoc) + discardDocumentIfIdle(descriptor.yjsSessionId) +} + +async function handleInternalYjsEntityListMembersRequest( + req: IncomingMessage, + res: ServerResponse, + logger: Logger, + sessionId: string +): Promise { + try { + if (!isEntityListSessionId(sessionId)) { + throw new InvalidInternalYjsRequestError('Entity-list session ID is required') + } + + const liveDoc = await getExistingDocument(sessionId) + if (!liveDoc) { + sendJson(res, 200, { success: true, applied: false }) + return + } + + const body = (await readJsonBody(req)) as { + members?: Array<{ id?: unknown; name?: unknown; enabled?: unknown }> + remove?: unknown + } + const mutations: EntityListMemberMutation[] = [ + ...(body.members ?? []).map((member) => { + if (typeof member.id !== 'string' || typeof member.name !== 'string') { + throw new InvalidInternalYjsRequestError('Entity-list member id and name are required') + } + return { + op: 'upsert' as const, + entityId: member.id, + name: member.name, + ...(typeof member.enabled === 'boolean' ? { enabled: member.enabled } : {}), + } + }), + ...(typeof body.remove === 'string' + ? [{ op: 'remove' as const, entityId: body.remove }] + : []), + ] + applyEntityListMutations(liveDoc, mutations) + markDocumentPersisted(liveDoc) + discardDocumentIfIdle(sessionId) + sendJson(res, 200, { success: true, applied: true }) + } catch (error) { + logger.error('Error applying entity-list members', { error, sessionId }) + sendJson(res, error instanceof InvalidInternalYjsRequestError ? 400 : 500, { + error: error instanceof Error ? error.message : 'Failed to apply entity-list members', + }) + } +} + async function handleInternalYjsWorkflowApplyRequest( req: IncomingMessage, res: ServerResponse, @@ -363,6 +452,7 @@ async function handleInternalYjsEntityApplyRequest( }, (staged) => saveSavedEntityYjsDocToDb(body.entityKind, entityId, staged) ) + await syncEntityListMemberFromDoc(body.entityKind, entityId, doc) sendJson(res, 200, { success: true }) } catch (error) { @@ -402,19 +492,6 @@ async function handleInternalYjsSessionApplyUpdateRequest( if (typeof updateBase64 !== 'string' || !updateBase64) { throw new InvalidInternalYjsRequestError('updateBase64 is required') } - if (isEntityListSessionId(descriptor.yjsSessionId)) { - const liveDoc = await getExistingDocument(descriptor.yjsSessionId) - if (!liveDoc) { - sendJson(res, 200, { success: true, applied: false }) - return - } - Y.applyUpdate(liveDoc, Buffer.from(updateBase64, 'base64'), YJS_ORIGINS.SYSTEM) - markDocumentPersisted(liveDoc) - discardDocumentIfIdle(sessionId) - sendJson(res, 200, { success: true, applied: true }) - return - } - const doc = await getBootstrappedApplyDocument(descriptor) try { @@ -424,6 +501,7 @@ async function handleInternalYjsSessionApplyUpdateRequest( clearSessionReseededFromCanonical(doc) if (descriptor.entityKind !== 'workflow' && descriptor.entityId) { await saveSavedEntityYjsDocToDb(descriptor.entityKind, descriptor.entityId, doc) + await syncEntityListMemberFromDoc(descriptor.entityKind, descriptor.entityId, doc) markDocumentPersisted(doc) discardDocumentIfIdle(sessionId) } @@ -583,6 +661,17 @@ async function handleInternalYjsRequest( return true } + const memberListId = matchInternalRoute( + parsedUrl.pathname, + INTERNAL_YJS_ENTITY_LIST_MEMBERS_PATH, + 'POST', + req.method + ) + if (memberListId) { + await handleInternalYjsEntityListMembersRequest(req, res, logger, memberListId) + return true + } + const deleteSessionId = matchInternalRoute( parsedUrl.pathname, INTERNAL_YJS_SESSION_DELETE_PATH, From c9324f100d27bec0d5128549a4d80da969374e3c Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 13:58:57 -0600 Subject: [PATCH 228/284] build(config): relax API encryption key requirement Allow the app and deployment manifests to omit API_ENCRYPTION_KEY unless API-key access or MCP token issuance is in use.\n\nCo-authored-by: Codex \nCo-authored-by: BWJ2310 \nCo-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/env.ts | 2 +- docker-compose.local.yml | 2 +- docker-compose.ollama.yml | 1 + docker-compose.prod.yml | 1 + helm/tradinggoose/templates/NOTES.txt | 4 ++-- helm/tradinggoose/templates/_helpers.tpl | 3 +++ helm/tradinggoose/values.schema.json | 5 +++++ helm/tradinggoose/values.yaml | 3 ++- 8 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/tradinggoose/lib/env.ts b/apps/tradinggoose/lib/env.ts index 4b7b2c3a8..18409b5e8 100644 --- a/apps/tradinggoose/lib/env.ts +++ b/apps/tradinggoose/lib/env.ts @@ -68,7 +68,7 @@ function safeCreateEnv() { ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data - API_ENCRYPTION_KEY: z.string().regex(/^[a-fA-F0-9]{64}$/), // Dedicated key for encrypting API keys + API_ENCRYPTION_KEY: z.string().regex(/^[a-fA-F0-9]{64}$/).optional(), // Required only when API-key access is used INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication // Database & Storage diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 03e27c064..81cf11965 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -25,7 +25,7 @@ services: - COPILOT_API_URL=${COPILOT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002} - - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:?set API_ENCRYPTION_KEY in .env} + - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} depends_on: redis: condition: service_healthy diff --git a/docker-compose.ollama.yml b/docker-compose.ollama.yml index 3082834a7..dac58c390 100644 --- a/docker-compose.ollama.yml +++ b/docker-compose.ollama.yml @@ -21,6 +21,7 @@ services: - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env} + - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:?set INTERNAL_API_SECRET in .env} - REDIS_URL=redis://redis:6379 - COPILOT_API_KEY=${COPILOT_API_KEY:-} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 25a33a315..b80653d2b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -18,6 +18,7 @@ services: - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:?set NEXT_PUBLIC_APP_URL in .env} - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env} + - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:?set INTERNAL_API_SECRET in .env} - REDIS_URL=redis://redis:6379 - COPILOT_API_KEY=${COPILOT_API_KEY:-} diff --git a/helm/tradinggoose/templates/NOTES.txt b/helm/tradinggoose/templates/NOTES.txt index 65f2d43b9..b86df8aea 100644 --- a/helm/tradinggoose/templates/NOTES.txt +++ b/helm/tradinggoose/templates/NOTES.txt @@ -45,7 +45,7 @@ WARNING: You have disabled the internal PostgreSQL database. Make sure to configure an external database connection in your values.yaml file. {{- end }} -{{- if not .Values.app.env.BETTER_AUTH_SECRET }} +{{- if or (not .Values.app.env.BETTER_AUTH_SECRET) (not .Values.app.env.ENCRYPTION_KEY) }} ⚠️ SECURITY WARNING: Required secrets are not configured! @@ -64,4 +64,4 @@ Generate secure secrets using: For more information and configuration options, see: - Chart documentation: https://github.com/TradingGoose/TradingGoose-Studio/tree/main/helm/tradinggoose -- TradingGoose Documentation: https://docs.tradinggoose.ai \ No newline at end of file +- TradingGoose Documentation: https://docs.tradinggoose.ai diff --git a/helm/tradinggoose/templates/_helpers.tpl b/helm/tradinggoose/templates/_helpers.tpl index ac926fd74..615f95f5f 100644 --- a/helm/tradinggoose/templates/_helpers.tpl +++ b/helm/tradinggoose/templates/_helpers.tpl @@ -195,6 +195,9 @@ Validate required secrets and reject default placeholder values {{- if and .Values.app.enabled (eq .Values.app.env.ENCRYPTION_KEY "CHANGE-ME-32-CHAR-ENCRYPTION-KEY-FOR-PROD") }} {{- fail "app.env.ENCRYPTION_KEY must not use the default placeholder value. Generate a secure key with: openssl rand -hex 32" }} {{- end }} +{{- if and .Values.app.enabled .Values.app.env.API_ENCRYPTION_KEY (not (regexMatch "^[a-fA-F0-9]{64}$" .Values.app.env.API_ENCRYPTION_KEY)) }} +{{- fail "app.env.API_ENCRYPTION_KEY must be exactly 64 hex characters. Generate it with: openssl rand -hex 32" }} +{{- end }} {{- if and .Values.realtime.enabled (eq .Values.realtime.env.BETTER_AUTH_SECRET "CHANGE-ME-32-CHAR-SECRET-FOR-PRODUCTION-USE") }} {{- fail "realtime.env.BETTER_AUTH_SECRET must not use the default placeholder value. Generate a secure secret with: openssl rand -hex 32" }} {{- end }} diff --git a/helm/tradinggoose/values.schema.json b/helm/tradinggoose/values.schema.json index f7350ab01..d1573a4d4 100644 --- a/helm/tradinggoose/values.schema.json +++ b/helm/tradinggoose/values.schema.json @@ -94,6 +94,11 @@ "minLength": 32, "description": "Encryption key (minimum 32 characters required)" }, + "API_ENCRYPTION_KEY": { + "type": "string", + "pattern": "^$|^[a-fA-F0-9]{64}$", + "description": "Dedicated API-key encryption key; required only when API-key access or MCP token issuance is used" + }, "NEXT_PUBLIC_APP_URL": { "type": "string", "format": "uri", diff --git a/helm/tradinggoose/values.yaml b/helm/tradinggoose/values.yaml index 09555d2d8..7f1cffea5 100644 --- a/helm/tradinggoose/values.yaml +++ b/helm/tradinggoose/values.yaml @@ -69,8 +69,9 @@ app: CRON_SECRET: "" # OPTIONAL - required only if cronjobs.enabled=true, authenticates scheduled job requests # API Key Encryption + # Required only when API-key access or MCP token issuance is used. # Generate 64-character hex string using: openssl rand -hex 32 (outputs 64 hex chars = 32 bytes) - API_ENCRYPTION_KEY: "" # REQUIRED - encrypts API keys at rest + API_ENCRYPTION_KEY: "" # Email & Communication EMAIL_VERIFICATION_ENABLED: "false" # Enable email verification for user registration and login (defaults to false) From a1c69ad2a5487ec7fa4e5aae8fca64c4addbf5c8 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 13:59:16 -0600 Subject: [PATCH 229/284] docs(config): update API encryption key guidance Clarify when API_ENCRYPTION_KEY is required and update the README and Helm examples to match the new optional behavior.\n\nCo-authored-by: Codex \nCo-authored-by: BWJ2310 \nCo-authored-by: BWJ2310-backup --- README.md | 6 +++--- apps/tradinggoose/.env.example | 4 ++-- apps/tradinggoose/.env.example.docker | 3 ++- helm/tradinggoose/README.md | 2 +- helm/tradinggoose/examples/values-aws.yaml | 2 +- helm/tradinggoose/examples/values-azure.yaml | 2 +- helm/tradinggoose/examples/values-external-db.yaml | 2 +- helm/tradinggoose/examples/values-gcp.yaml | 2 +- helm/tradinggoose/examples/values-production.yaml | 2 +- helm/tradinggoose/examples/values-whitelabeled.yaml | 2 +- 10 files changed, 14 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6f5565c0e..e0db1f359 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,9 @@ If you use Docker Compose, copy `apps/tradinggoose/.env.example.docker` to `apps/tradinggoose/.env` and set the required secrets before running the compose manifests. The `.env` must include `POSTGRES_*`, `NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_SOCKET_URL`, `BETTER_AUTH_SECRET`, -`ENCRYPTION_KEY`, `API_ENCRYPTION_KEY`, and `INTERNAL_API_SECRET`. The -`ENCRYPTION_KEY` value is shared by both the app and realtime containers, and -`API_ENCRYPTION_KEY` encrypts API keys at rest in the app container. +`ENCRYPTION_KEY`, and `INTERNAL_API_SECRET`. Set `API_ENCRYPTION_KEY` when +API-key access or MCP token issuance is used; it encrypts API keys at rest in +the app container. `NEXT_PUBLIC_SOCKET_URL` should point at `http://localhost:3002` for local Compose runs; production deployments must override it with a browser-reachable public URL. The prod and Ollama compose files also require `IMAGE_TAG` and diff --git a/apps/tradinggoose/.env.example b/apps/tradinggoose/.env.example index 919d469f3..b0b75a108 100644 --- a/apps/tradinggoose/.env.example +++ b/apps/tradinggoose/.env.example @@ -43,9 +43,9 @@ BETTER_AUTH_SECRET="replace-with-64-hex-characters" # Generate a secure 64-character hex secret. ENCRYPTION_KEY="replace-with-64-hex-characters" -# Required: dedicated encryption key for stored API credentials. +# Optional unless API-key access or MCP token issuance is used. # Generate a secure 64-character hex secret. -API_ENCRYPTION_KEY="replace-with-64-hex-characters" +API_ENCRYPTION_KEY="" # Required: internal server-to-server auth secret used by app routes, sockets, # cron endpoints, and other internal calls. diff --git a/apps/tradinggoose/.env.example.docker b/apps/tradinggoose/.env.example.docker index ede27047b..50777078d 100644 --- a/apps/tradinggoose/.env.example.docker +++ b/apps/tradinggoose/.env.example.docker @@ -25,8 +25,9 @@ OLLAMA_IMAGE_TAG=latest # Security (Required) # Use `openssl rand -hex 32` to generate, used to encrypt environment variables ENCRYPTION_KEY=generate-the-key +# Optional unless API-key access or MCP token issuance is used. # Use `openssl rand -hex 32` to generate, used to encrypt API keys -API_ENCRYPTION_KEY=generate-the-api-key-encryption-key +API_ENCRYPTION_KEY= # Use `openssl rand -hex 32` to generate, used to encrypt internal api routes INTERNAL_API_SECRET=generate-the-secret diff --git a/helm/tradinggoose/README.md b/helm/tradinggoose/README.md index 5f43e1312..4f267950b 100644 --- a/helm/tradinggoose/README.md +++ b/helm/tradinggoose/README.md @@ -641,7 +641,7 @@ For production deployments, make sure to: **Optional Security (Recommended for Production):** - `CRON_SECRET`: Authenticates scheduled job requests to API endpoints (required only if `cronjobs.enabled=true`) -- `API_ENCRYPTION_KEY`: Encrypts API keys at rest in database (must be exactly 64 hex characters). Generate using: `openssl rand -hex 32` (outputs 64 hex chars representing 32 bytes) +- `API_ENCRYPTION_KEY`: Required for API-key access and MCP token issuance; encrypts API keys at rest in database (must be exactly 64 hex characters). Generate using: `openssl rand -hex 32` ### Example secure values: diff --git a/helm/tradinggoose/examples/values-aws.yaml b/helm/tradinggoose/examples/values-aws.yaml index f84e573b3..c8a6c240a 100644 --- a/helm/tradinggoose/examples/values-aws.yaml +++ b/helm/tradinggoose/examples/values-aws.yaml @@ -39,7 +39,7 @@ app: # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" + API_ENCRYPTION_KEY: "" NODE_ENV: "production" NEXT_TELEMETRY_DISABLED: "1" diff --git a/helm/tradinggoose/examples/values-azure.yaml b/helm/tradinggoose/examples/values-azure.yaml index 6d7f0d735..7edb41d3a 100644 --- a/helm/tradinggoose/examples/values-azure.yaml +++ b/helm/tradinggoose/examples/values-azure.yaml @@ -37,7 +37,7 @@ app: # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" + API_ENCRYPTION_KEY: "" NODE_ENV: "production" NEXT_TELEMETRY_DISABLED: "1" diff --git a/helm/tradinggoose/examples/values-external-db.yaml b/helm/tradinggoose/examples/values-external-db.yaml index d9ad50fb0..db51346b9 100644 --- a/helm/tradinggoose/examples/values-external-db.yaml +++ b/helm/tradinggoose/examples/values-external-db.yaml @@ -33,7 +33,7 @@ app: # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "" # Required; set via --set flag or external secret manager + API_ENCRYPTION_KEY: "" # Optional unless API-key access or MCP token issuance is used NODE_ENV: "production" NEXT_TELEMETRY_DISABLED: "1" diff --git a/helm/tradinggoose/examples/values-gcp.yaml b/helm/tradinggoose/examples/values-gcp.yaml index 6a6fd72a4..4aebb63d7 100644 --- a/helm/tradinggoose/examples/values-gcp.yaml +++ b/helm/tradinggoose/examples/values-gcp.yaml @@ -39,7 +39,7 @@ app: # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" + API_ENCRYPTION_KEY: "" NODE_ENV: "production" NEXT_TELEMETRY_DISABLED: "1" diff --git a/helm/tradinggoose/examples/values-production.yaml b/helm/tradinggoose/examples/values-production.yaml index cc9e7fa71..829520969 100644 --- a/helm/tradinggoose/examples/values-production.yaml +++ b/helm/tradinggoose/examples/values-production.yaml @@ -34,7 +34,7 @@ app: # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" + API_ENCRYPTION_KEY: "" # Email verification (set to true if you want to require email verification) EMAIL_VERIFICATION_ENABLED: "false" diff --git a/helm/tradinggoose/examples/values-whitelabeled.yaml b/helm/tradinggoose/examples/values-whitelabeled.yaml index 3f13cc10a..5226982fd 100644 --- a/helm/tradinggoose/examples/values-whitelabeled.yaml +++ b/helm/tradinggoose/examples/values-whitelabeled.yaml @@ -27,7 +27,7 @@ app: # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" + API_ENCRYPTION_KEY: "" # UI Branding & Whitelabeling Configuration NEXT_PUBLIC_BRAND_NAME: "Acme AI Studio" From 020b92b9a183b1b8feee5e1b36a932ea7e52cfe4 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 14:30:46 -0600 Subject: [PATCH 230/284] feat(mcp): persist server status and refresh metadata Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/mcp/servers/[id]/refresh/route.ts | 40 ++++++++++++- .../tradinggoose/app/api/mcp/servers/route.ts | 58 +++++++++++++++---- apps/tradinggoose/lib/mcp/service.ts | 22 ------- apps/tradinggoose/stores/mcp-servers/store.ts | 18 +----- apps/tradinggoose/stores/mcp-servers/types.ts | 1 + 5 files changed, 90 insertions(+), 49 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts index 1e05a0d9f..7ea21403d 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts @@ -1,3 +1,6 @@ +import { db } from '@tradinggoose/db' +import { mcpServers } from '@tradinggoose/db/schema' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { withMcpAuth } from '@/lib/mcp/middleware' @@ -27,6 +30,26 @@ export const POST = withMcpAuth('read')( } ) + const [server] = await db + .select({ lastConnected: mcpServers.lastConnected }) + .from(mcpServers) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + + if (!server) { + return createMcpErrorResponse( + new Error('Server not found or access denied'), + 'Server not found', + 404 + ) + } + let connectionStatus: 'connected' | 'disconnected' | 'error' = 'error' let toolCount = 0 let lastError: string | null = null @@ -47,11 +70,26 @@ export const POST = withMcpAuth('read')( logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error) } + const now = new Date() + const lastConnected = connectionStatus === 'connected' ? now : server.lastConnected + await db + .update(mcpServers) + .set({ + lastToolsRefresh: now, + connectionStatus, + lastError, + lastConnected, + toolCount, + updatedAt: now, + }) + .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) + logger.info(`[${requestId}] Successfully refreshed MCP server: ${serverId}`) return createMcpSuccessResponse({ status: connectionStatus, toolCount, - lastConnected: connectionStatus === 'connected' ? new Date().toISOString() : null, + lastConnected: lastConnected?.toISOString() ?? null, + lastToolsRefresh: now.toISOString(), error: lastError, }) } catch (error) { diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index d92f82b48..f49284a1e 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -1,12 +1,13 @@ import { db } from '@tradinggoose/db' import { mcpServers } from '@tradinggoose/db/schema' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { normalizeEntityFields } from '@/lib/copilot/entity-documents' import { createLogger } from '@/lib/logs/console/logger' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +import { readBootstrappedEntityListMembers } from '@/lib/yjs/server/bootstrap-review-target' import { deleteYjsSessionInSocketServer, notifyEntityListMemberRemoved, @@ -26,7 +27,40 @@ export const GET = withMcpAuth('read')( try { logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) - const servers = await mcpService.listWorkspaceServers(workspaceId) + const listMembers = await readBootstrappedEntityListMembers('mcp_server', workspaceId) + const statusById = new Map( + ( + await db + .select({ + id: mcpServers.id, + connectionStatus: mcpServers.connectionStatus, + lastError: mcpServers.lastError, + toolCount: mcpServers.toolCount, + lastConnected: mcpServers.lastConnected, + lastToolsRefresh: mcpServers.lastToolsRefresh, + }) + .from(mcpServers) + .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) + ).map((row) => [row.id, row]) + ) + const servers = listMembers.map((server) => { + const status = statusById.get(server.entityId) + return { + id: server.entityId, + name: server.entityName, + enabled: server.enabled !== false, + workspaceId, + ...(status + ? { + connectionStatus: status.connectionStatus, + lastError: status.lastError, + toolCount: status.toolCount, + lastConnected: status.lastConnected?.toISOString(), + lastToolsRefresh: status.lastToolsRefresh?.toISOString(), + } + : {}), + } + }) logger.info( `[${requestId}] Listed ${servers.length} MCP servers for workspace ${workspaceId}` @@ -157,12 +191,13 @@ export const DELETE = withMcpAuth('write')( logger.info(`[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}`) - const [deletedServer] = await db - .delete(mcpServers) + const [existingServer] = await db + .select({ id: mcpServers.id }) + .from(mcpServers) .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - .returning({ id: mcpServers.id }) + .limit(1) - if (!deletedServer) { + if (!existingServer) { return createMcpErrorResponse( new Error('Server not found or access denied'), 'Server not found', @@ -170,14 +205,17 @@ export const DELETE = withMcpAuth('write')( ) } - await deleteYjsSessionInSocketServer(deletedServer.id) - await notifyEntityListMemberRemoved('mcp_server', workspaceId, deletedServer.id) + await deleteYjsSessionInSocketServer(existingServer.id) + await notifyEntityListMemberRemoved('mcp_server', workspaceId, existingServer.id) + await db + .delete(mcpServers) + .where(and(eq(mcpServers.id, existingServer.id), eq(mcpServers.workspaceId, workspaceId))) mcpService.clearCache(workspaceId) - logger.info(`[${requestId}] Successfully deleted MCP server: ${deletedServer.id}`) + logger.info(`[${requestId}] Successfully deleted MCP server: ${existingServer.id}`) return createMcpSuccessResponse({ - message: `Server ${deletedServer.id} deleted successfully`, + message: `Server ${existingServer.id} deleted successfully`, }) } catch (error) { logger.error(`[${requestId}] Error deleting MCP server:`, error) diff --git a/apps/tradinggoose/lib/mcp/service.ts b/apps/tradinggoose/lib/mcp/service.ts index cb14f03fe..e4f787325 100644 --- a/apps/tradinggoose/lib/mcp/service.ts +++ b/apps/tradinggoose/lib/mcp/service.ts @@ -1,7 +1,3 @@ -/** - * MCP Service - Clean stateless service for MCP operations - */ - import { normalizeEntityFields } from '@/lib/copilot/entity-documents' import { isTest } from '@/lib/environment' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' @@ -19,20 +15,12 @@ import { MCP_CONSTANTS } from '@/lib/mcp/utils' import { generateRequestId } from '@/lib/utils' import { ReviewTargetBootstrapError, - readBootstrappedEntityListMembers, readBootstrappedSavedEntityFields, readBootstrappedSavedEntityListFields, } from '@/lib/yjs/server/bootstrap-review-target' const logger = createLogger('McpService') -type McpServerListItem = { - id: string - name: string - enabled: boolean - workspaceId: string -} - export class McpServerNotFoundError extends Error { readonly status = 404 @@ -270,16 +258,6 @@ class McpService { } } - async listWorkspaceServers(workspaceId: string): Promise { - const servers = await readBootstrappedEntityListMembers('mcp_server', workspaceId) - return servers.map((server) => ({ - id: server.entityId, - name: server.entityName, - enabled: server.enabled !== false, - workspaceId, - })) - } - private async getServerConfig( serverId: string, workspaceId: string diff --git a/apps/tradinggoose/stores/mcp-servers/store.ts b/apps/tradinggoose/stores/mcp-servers/store.ts index 099ef99b1..15a2d1714 100644 --- a/apps/tradinggoose/stores/mcp-servers/store.ts +++ b/apps/tradinggoose/stores/mcp-servers/store.ts @@ -26,21 +26,7 @@ export const useMcpServersStore = create()( const listedServers: McpServersState['servers'] = Array.isArray(data.data?.servers) ? data.data.servers : [] - set((state) => ({ - servers: listedServers.map((server) => { - const previous = state.servers.find( - (item) => item.id === server.id && item.workspaceId === server.workspaceId - ) - return { - ...server, - connectionStatus: previous?.connectionStatus, - lastError: previous?.lastError, - lastConnected: previous?.lastConnected, - lastToolsRefresh: previous?.lastToolsRefresh, - } - }), - isLoading: false, - })) + set({ servers: listedServers, isLoading: false }) logger.info( `Fetched ${data.data?.servers?.length || 0} MCP servers for workspace ${workspaceId}` ) @@ -177,7 +163,7 @@ export const useMcpServersStore = create()( }, refreshServer: async (workspaceId: string, id: string, result) => { - const refreshedAt = new Date().toISOString() + const refreshedAt = result?.lastToolsRefresh ?? new Date().toISOString() set((state) => ({ servers: state.servers.map((server) => diff --git a/apps/tradinggoose/stores/mcp-servers/types.ts b/apps/tradinggoose/stores/mcp-servers/types.ts index 310dc69ce..7aeee5cdf 100644 --- a/apps/tradinggoose/stores/mcp-servers/types.ts +++ b/apps/tradinggoose/stores/mcp-servers/types.ts @@ -65,6 +65,7 @@ export interface McpServersActions { status?: McpServerWithStatus['connectionStatus'] toolCount?: number lastConnected?: string | null + lastToolsRefresh?: string | null error?: string | null } ) => Promise From 373c47e4274f19f2eaa78b0cdc04e6f1ac598aba Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 14:31:05 -0600 Subject: [PATCH 231/284] fix(yjs): clean up sessions before deleting entities Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/indicators/custom/route.ts | 4 ++-- apps/tradinggoose/app/api/tools/custom/route.ts | 4 ++-- apps/tradinggoose/lib/knowledge/service.ts | 8 ++++---- apps/tradinggoose/lib/skills/operations.ts | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/tradinggoose/app/api/indicators/custom/route.ts b/apps/tradinggoose/app/api/indicators/custom/route.ts index 84f399637..c3fd6f874 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -241,11 +241,11 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Indicator not found' }, { status: 404 }) } + await deleteYjsSessionInSocketServer(indicatorId) + await notifyEntityListMemberRemoved('indicator', workspaceId, indicatorId) await db .delete(pineIndicators) .where(and(eq(pineIndicators.id, indicatorId), eq(pineIndicators.workspaceId, workspaceId))) - await deleteYjsSessionInSocketServer(indicatorId).catch(() => undefined) - await notifyEntityListMemberRemoved('indicator', workspaceId, indicatorId) logger.info(`[${requestId}] Deleted indicator ${indicatorId}`) return NextResponse.json({ success: true }, { status: 200 }) diff --git a/apps/tradinggoose/app/api/tools/custom/route.ts b/apps/tradinggoose/app/api/tools/custom/route.ts index aacbd5fcd..d39c9f724 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.ts @@ -221,11 +221,11 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Tool not found' }, { status: 404 }) } + await deleteYjsSessionInSocketServer(toolId) + await notifyEntityListMemberRemoved('custom_tool', workspaceId, toolId) await db .delete(customTools) .where(and(eq(customTools.id, toolId), eq(customTools.workspaceId, workspaceId))) - await deleteYjsSessionInSocketServer(toolId).catch(() => undefined) - await notifyEntityListMemberRemoved('custom_tool', workspaceId, toolId) logger.info(`[${requestId}] Deleted tool: ${toolId}`) return NextResponse.json({ success: true }) diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index edf1e2dc7..63a6aca41 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -425,6 +425,10 @@ export async function deleteKnowledgeBase( .where(eq(knowledgeBase.id, knowledgeBaseId)) .limit(1) + await deleteYjsSessionInSocketServer(knowledgeBaseId) + if (existing?.workspaceId) { + await notifyEntityListMemberRemoved('knowledge_base', existing.workspaceId, knowledgeBaseId) + } await db .update(knowledgeBase) .set({ @@ -432,10 +436,6 @@ export async function deleteKnowledgeBase( updatedAt: now, }) .where(eq(knowledgeBase.id, knowledgeBaseId)) - await deleteYjsSessionInSocketServer(knowledgeBaseId).catch(() => undefined) - if (existing?.workspaceId) { - await notifyEntityListMemberRemoved('knowledge_base', existing.workspaceId, knowledgeBaseId) - } logger.info(`[${requestId}] Soft deleted knowledge base: ${knowledgeBaseId}`) } diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index fb81e5d43..64bce1908 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -74,11 +74,11 @@ export async function deleteSkill(params: { return false } + await deleteYjsSessionInSocketServer(params.skillId) + await notifyEntityListMemberRemoved('skill', params.workspaceId, params.skillId) await db .delete(skill) .where(and(eq(skill.id, params.skillId), eq(skill.workspaceId, params.workspaceId))) - await deleteYjsSessionInSocketServer(params.skillId).catch(() => undefined) - await notifyEntityListMemberRemoved('skill', params.workspaceId, params.skillId) logger.info(`Deleted skill ${params.skillId}`) return true From 341055703261c15a143d1f4585336d2d8bf3d1f3 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 14:31:22 -0600 Subject: [PATCH 232/284] test(mcp): cover config writer targets Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../mcp/local-config-writer-script.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts b/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts index 7cd03bc7f..c04fb032d 100644 --- a/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts +++ b/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts @@ -106,4 +106,41 @@ describe('MCP local config writer script', () => { }, }) }) + + it.each([ + [ + 'claude', + ['.claude.json'], + { + mcpServers: { + TradingGoose: { + type: 'http', + url: 'http://localhost:3000/api/copilot/mcp', + headers: { Authorization: 'Bearer mcp-token' }, + }, + }, + }, + ], + [ + 'opencode', + ['.config', 'opencode', 'opencode.json'], + { + mcp: { + TradingGoose: { + type: 'remote', + url: 'http://localhost:3000/api/copilot/mcp', + enabled: true, + headers: { Authorization: 'Bearer mcp-token' }, + }, + }, + }, + ], + ])('writes %s config', (target, pathParts, expectedConfig) => { + const home = mkdtempSync(join(tmpdir(), `tg-mcp-${target}-`)) + const configPath = join(home, ...pathParts) + + runWriter(home, [target, 'http://localhost:3000/api/copilot/mcp', 'mcp-token']) + + expect(JSON.parse(readFileSync(configPath, 'utf8'))).toEqual(expectedConfig) + }) }) From 21030e4e1a729644aa3c42bee8cf5b759354e28d Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 15:09:12 -0600 Subject: [PATCH 233/284] feat(mcp): sync MCP server API with database records Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/mcp/servers/[id]/route.ts | 78 ++++++++++++------- .../tradinggoose/app/api/mcp/servers/route.ts | 63 +++++++++------ .../app/api/mcp/servers/schema.ts | 5 +- apps/tradinggoose/lib/mcp/service.ts | 29 +++++++ 4 files changed, 120 insertions(+), 55 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts index 4ab72ff3a..164e918c9 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts @@ -1,24 +1,20 @@ +import { db } from '@tradinggoose/db' +import { mcpServers } from '@tradinggoose/db/schema' +import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { - applySavedEntityState, - SavedEntityPersistenceError, -} from '@/lib/yjs/server/apply-entity-state' -import { - ReviewTargetBootstrapError, - readBootstrappedSavedEntityFields, -} from '@/lib/yjs/server/bootstrap-review-target' -import { UpdateMcpServerSchema } from '../schema' +import { notifyEntityListMembersUpserted } from '@/lib/yjs/server/snapshot-bridge' +import { RenameMcpServerSchema } from '../schema' const logger = createLogger('McpServerAPI') export const dynamic = 'force-dynamic' /** - * PATCH - Update an MCP server in the workspace (requires write or admin permission) + * PATCH - Rename an MCP server in the workspace (requires write permission) */ export const PATCH = withMcpAuth('write')( async ( @@ -31,7 +27,7 @@ export const PATCH = withMcpAuth('write')( try { const rawBody = getParsedBody(request) || (await request.json()) - const parseResult = UpdateMcpServerSchema.safeParse(rawBody) + const parseResult = RenameMcpServerSchema.safeParse(rawBody) if (!parseResult.success) { return createMcpErrorResponse( new Error(`Invalid request body: ${parseResult.error.message}`), @@ -47,30 +43,58 @@ export const PATCH = withMcpAuth('write')( updates: Object.keys(body).filter((k) => k !== 'workspaceId'), }) - const currentFields = await readBootstrappedSavedEntityFields( - 'mcp_server', - serverId, - workspaceId - ) - const { workspaceId: _, ...updateData } = body - const nextServer = { ...currentFields, ...updateData, id: serverId, workspaceId } + const [updatedServer] = await db + .update(mcpServers) + .set({ + name: body.name.trim(), + updatedAt: new Date(), + }) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) + ) + ) + .returning({ id: mcpServers.id, name: mcpServers.name, enabled: mcpServers.enabled }) + + if (!updatedServer) { + return createMcpErrorResponse( + new Error('Server not found or access denied'), + 'Server not found', + 404 + ) + } - await applySavedEntityState('mcp_server', serverId, { ...currentFields, ...updateData }) + try { + await notifyEntityListMembersUpserted('mcp_server', workspaceId, [ + { + id: serverId, + name: updatedServer.name, + enabled: updatedServer.enabled !== false, + }, + ]) + } catch (error) { + logger.warn(`[${requestId}] Updated MCP server but failed list projection update`, { + error, + serverId, + }) + } // Clear MCP service cache after update mcpService.clearCache(workspaceId) logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`) - return createMcpSuccessResponse({ server: nextServer }) + return createMcpSuccessResponse({ + server: { + id: serverId, + workspaceId, + name: updatedServer.name, + enabled: updatedServer.enabled !== false, + }, + }) } catch (error) { logger.error(`[${requestId}] Error updating MCP server:`, error) - if ( - error instanceof SavedEntityPersistenceError || - error instanceof ReviewTargetBootstrapError - ) { - return createMcpErrorResponse(error, error.message, error.status) - } - return createMcpErrorResponse( error instanceof Error ? error : new Error('Failed to update MCP server'), 'Failed to update MCP server', diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index f49284a1e..23d4b9a19 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -33,6 +33,8 @@ export const GET = withMcpAuth('read')( await db .select({ id: mcpServers.id, + name: mcpServers.name, + enabled: mcpServers.enabled, connectionStatus: mcpServers.connectionStatus, lastError: mcpServers.lastError, toolCount: mcpServers.toolCount, @@ -43,22 +45,22 @@ export const GET = withMcpAuth('read')( .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) ).map((row) => [row.id, row]) ) - const servers = listMembers.map((server) => { + const servers = listMembers.flatMap((server) => { const status = statusById.get(server.entityId) + if (!status) { + return [] + } + return { id: server.entityId, - name: server.entityName, - enabled: server.enabled !== false, + name: status.name, + enabled: status.enabled !== false, workspaceId, - ...(status - ? { - connectionStatus: status.connectionStatus, - lastError: status.lastError, - toolCount: status.toolCount, - lastConnected: status.lastConnected?.toISOString(), - lastToolsRefresh: status.lastToolsRefresh?.toISOString(), - } - : {}), + connectionStatus: status.connectionStatus, + lastError: status.lastError, + toolCount: status.toolCount, + lastConnected: status.lastConnected?.toISOString(), + lastToolsRefresh: status.lastToolsRefresh?.toISOString(), } }) @@ -191,13 +193,18 @@ export const DELETE = withMcpAuth('write')( logger.info(`[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}`) - const [existingServer] = await db - .select({ id: mcpServers.id }) - .from(mcpServers) - .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - .limit(1) + const [deletedServer] = await db + .delete(mcpServers) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) + ) + ) + .returning({ id: mcpServers.id }) - if (!existingServer) { + if (!deletedServer) { return createMcpErrorResponse( new Error('Server not found or access denied'), 'Server not found', @@ -205,17 +212,23 @@ export const DELETE = withMcpAuth('write')( ) } - await deleteYjsSessionInSocketServer(existingServer.id) - await notifyEntityListMemberRemoved('mcp_server', workspaceId, existingServer.id) - await db - .delete(mcpServers) - .where(and(eq(mcpServers.id, existingServer.id), eq(mcpServers.workspaceId, workspaceId))) + const cleanupResults = await Promise.allSettled([ + deleteYjsSessionInSocketServer(deletedServer.id), + notifyEntityListMemberRemoved('mcp_server', workspaceId, deletedServer.id), + ]) + const cleanupFailure = cleanupResults.find((result) => result.status === 'rejected') + if (cleanupFailure) { + logger.warn(`[${requestId}] Deleted MCP server but failed realtime cleanup`, { + error: cleanupFailure.reason, + serverId: deletedServer.id, + }) + } mcpService.clearCache(workspaceId) - logger.info(`[${requestId}] Successfully deleted MCP server: ${existingServer.id}`) + logger.info(`[${requestId}] Successfully deleted MCP server: ${deletedServer.id}`) return createMcpSuccessResponse({ - message: `Server ${existingServer.id} deleted successfully`, + message: `Server ${deletedServer.id} deleted successfully`, }) } catch (error) { logger.error(`[${requestId}] Error deleting MCP server:`, error) diff --git a/apps/tradinggoose/app/api/mcp/servers/schema.ts b/apps/tradinggoose/app/api/mcp/servers/schema.ts index 16ae75277..3d29685ee 100644 --- a/apps/tradinggoose/app/api/mcp/servers/schema.ts +++ b/apps/tradinggoose/app/api/mcp/servers/schema.ts @@ -16,8 +16,7 @@ const McpServerBaseSchema = z.object({ export const CreateMcpServerSchema = McpServerBaseSchema -export const UpdateMcpServerSchema = McpServerBaseSchema.partial().extend({ - description: z.string().optional().nullable(), - command: z.string().optional().nullable(), +export const RenameMcpServerSchema = z.object({ + name: z.string().trim().min(1), workspaceId: z.string().optional(), }) diff --git a/apps/tradinggoose/lib/mcp/service.ts b/apps/tradinggoose/lib/mcp/service.ts index e4f787325..6a264302f 100644 --- a/apps/tradinggoose/lib/mcp/service.ts +++ b/apps/tradinggoose/lib/mcp/service.ts @@ -1,3 +1,6 @@ +import { db } from '@tradinggoose/db' +import { mcpServers } from '@tradinggoose/db/schema' +import { and, eq, isNull } from 'drizzle-orm' import { normalizeEntityFields } from '@/lib/copilot/entity-documents' import { isTest } from '@/lib/environment' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' @@ -263,6 +266,21 @@ class McpService { workspaceId: string ): Promise { try { + const [server] = await db + .select({ id: mcpServers.id }) + .from(mcpServers) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) + ) + ) + .limit(1) + if (!server) { + return null + } + const fields = normalizeEntityFields( 'mcp_server', await readBootstrappedSavedEntityFields('mcp_server', serverId, workspaceId) @@ -277,8 +295,19 @@ class McpService { } private async getWorkspaceServers(workspaceId: string): Promise { + const activeServerIds = new Set( + ( + await db + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) + ).map((server) => server.id) + ) const servers = await readBootstrappedSavedEntityListFields('mcp_server', workspaceId) return servers.flatMap((server) => { + if (!activeServerIds.has(server.entityId)) { + return [] + } const normalized = normalizeEntityFields('mcp_server', server.fields) return normalized.enabled === false ? [] : [this.toServerConfig(server.entityId, normalized)] }) From 88362331d8d6cfff154e7478f2637350c3b6cb81 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 15:42:17 -0600 Subject: [PATCH 234/284] fix(auth): gate API key and MCP routes on storage availability Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../tradinggoose/app/api/auth/mcp/poll/route.test.ts | 7 +++++++ apps/tradinggoose/app/api/auth/mcp/poll/route.ts | 5 +++++ .../app/api/auth/mcp/start/route.test.ts | 12 +++++++++++- apps/tradinggoose/app/api/auth/mcp/start/route.ts | 4 ++++ apps/tradinggoose/app/api/users/me/api-keys/route.ts | 10 +++++++++- .../app/api/workspaces/[id]/api-keys/route.ts | 10 +++++++++- apps/tradinggoose/lib/api-key/service.ts | 4 ++++ 7 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts index 23aaed3d0..f2239464c 100644 --- a/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts @@ -8,10 +8,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockAcknowledgeMcpDeviceLogin, mockCheckPublicApiEndpointRateLimit, + mockIsApiKeyStorageAvailable, mockPollMcpDeviceLogin, } = vi.hoisted(() => ({ mockAcknowledgeMcpDeviceLogin: vi.fn(), mockCheckPublicApiEndpointRateLimit: vi.fn(), + mockIsApiKeyStorageAvailable: vi.fn(), mockPollMcpDeviceLogin: vi.fn(), })) @@ -20,6 +22,10 @@ vi.mock('@/lib/api/rate-limit', () => ({ mockCheckPublicApiEndpointRateLimit(...args), })) +vi.mock('@/lib/api-key/service', () => ({ + isApiKeyStorageAvailable: (...args: unknown[]) => mockIsApiKeyStorageAvailable(...args), +})) + vi.mock('@/lib/mcp/auth', () => ({ acknowledgeMcpDeviceLogin: (...args: unknown[]) => mockAcknowledgeMcpDeviceLogin(...args), pollMcpDeviceLogin: (...args: unknown[]) => mockPollMcpDeviceLogin(...args), @@ -34,6 +40,7 @@ describe('MCP login poll route', () => { resetAt: new Date('2026-06-19T12:01:00.000Z'), limit: 120, }) + mockIsApiKeyStorageAvailable.mockReturnValue(true) mockPollMcpDeviceLogin.mockResolvedValue({ status: 'approved', apiKey: 'sk-tradinggoose-token', diff --git a/apps/tradinggoose/app/api/auth/mcp/poll/route.ts b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts index c9cd92661..019b61ca2 100644 --- a/apps/tradinggoose/app/api/auth/mcp/poll/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts @@ -1,6 +1,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkPublicApiEndpointRateLimit } from '@/lib/api/rate-limit' +import { isApiKeyStorageAvailable } from '@/lib/api-key/service' import { acknowledgeMcpDeviceLogin, pollMcpDeviceLogin } from '@/lib/mcp/auth' export const dynamic = 'force-dynamic' @@ -25,6 +26,10 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Invalid MCP login poll request' }, { status: 400 }) } + if (!isApiKeyStorageAvailable()) { + return NextResponse.json({ error: 'API key access is not configured' }, { status: 503 }) + } + const result = parsed.data.ackApiKey !== undefined ? await acknowledgeMcpDeviceLogin({ diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts index 3b39ad89a..6a103951e 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts @@ -5,8 +5,13 @@ import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckPublicApiEndpointRateLimit, mockStartMcpDeviceLogin } = vi.hoisted(() => ({ +const { + mockCheckPublicApiEndpointRateLimit, + mockIsApiKeyStorageAvailable, + mockStartMcpDeviceLogin, +} = vi.hoisted(() => ({ mockCheckPublicApiEndpointRateLimit: vi.fn(), + mockIsApiKeyStorageAvailable: vi.fn(), mockStartMcpDeviceLogin: vi.fn(), })) @@ -15,6 +20,10 @@ vi.mock('@/lib/api/rate-limit', () => ({ mockCheckPublicApiEndpointRateLimit(...args), })) +vi.mock('@/lib/api-key/service', () => ({ + isApiKeyStorageAvailable: (...args: unknown[]) => mockIsApiKeyStorageAvailable(...args), +})) + vi.mock('@/lib/mcp/auth', () => ({ startMcpDeviceLogin: (...args: unknown[]) => mockStartMcpDeviceLogin(...args), })) @@ -29,6 +38,7 @@ describe('MCP login start route', () => { resetAt: new Date('2026-06-19T12:01:00.000Z'), limit: 20, }) + mockIsApiKeyStorageAvailable.mockReturnValue(true) mockStartMcpDeviceLogin.mockResolvedValue({ code: 'login-code', verificationKey: 'verification-key', diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.ts index d66003040..54b53299a 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { checkPublicApiEndpointRateLimit } from '@/lib/api/rate-limit' +import { isApiKeyStorageAvailable } from '@/lib/api-key/service' import { startMcpDeviceLogin } from '@/lib/mcp/auth' import { getBaseUrl } from '@/lib/urls/utils' @@ -11,6 +12,9 @@ export async function POST(request: NextRequest) { const status = rateLimit.failureKind === 'dependency' ? 503 : 429 return NextResponse.json({ error: rateLimit.error || 'Rate limit exceeded' }, { status }) } + if (!isApiKeyStorageAvailable()) { + return NextResponse.json({ error: 'API key access is not configured' }, { status: 503 }) + } const baseUrl = getBaseUrl() const login = await startMcpDeviceLogin() diff --git a/apps/tradinggoose/app/api/users/me/api-keys/route.ts b/apps/tradinggoose/app/api/users/me/api-keys/route.ts index b1ce88f06..b20080142 100644 --- a/apps/tradinggoose/app/api/users/me/api-keys/route.ts +++ b/apps/tradinggoose/app/api/users/me/api-keys/route.ts @@ -3,7 +3,11 @@ import { apiKey } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import { type NextRequest, NextResponse } from 'next/server' -import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/service' +import { + createApiKey, + getApiKeyDisplayFormat, + isApiKeyStorageAvailable, +} from '@/lib/api-key/service' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' @@ -82,6 +86,10 @@ export async function POST(request: NextRequest) { ) } + if (!isApiKeyStorageAvailable()) { + return NextResponse.json({ error: 'API key access is not configured' }, { status: 503 }) + } + const { key: plainKey, storedKey } = await createApiKey(true) if (!storedKey) { throw new Error('Failed to prepare API key for storage') diff --git a/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts b/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts index b9da4bc8a..7fee9dc3c 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts @@ -4,7 +4,11 @@ import { and, eq, inArray } from 'drizzle-orm' import { nanoid } from 'nanoid' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/service' +import { + createApiKey, + getApiKeyDisplayFormat, + isApiKeyStorageAvailable, +} from '@/lib/api-key/service' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' @@ -118,6 +122,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ) } + if (!isApiKeyStorageAvailable()) { + return NextResponse.json({ error: 'API key access is not configured' }, { status: 503 }) + } + const { key: plainKey, storedKey } = await createApiKey(true) if (!storedKey) { throw new Error('Failed to prepare API key for storage') diff --git a/apps/tradinggoose/lib/api-key/service.ts b/apps/tradinggoose/lib/api-key/service.ts index 6b3876b1b..ad85c62df 100644 --- a/apps/tradinggoose/lib/api-key/service.ts +++ b/apps/tradinggoose/lib/api-key/service.ts @@ -185,6 +185,10 @@ function getApiEncryptionKey(): Buffer { return Buffer.from(key, 'hex') } +export function isApiKeyStorageAvailable(): boolean { + return Boolean(env.API_ENCRYPTION_KEY) +} + function encryptApiKeyForStorage(apiKey: string): string { const iv = randomBytes(12) const cipher = createCipheriv('aes-256-gcm', getApiEncryptionKey(), iv) From 5d9d7ca7bc1e1eb563a99410fbf4ef7a660de88d Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 15:42:53 -0600 Subject: [PATCH 235/284] fix(copilot): base review hashes on current entity state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/copilot/mcp/route.test.ts | 2 +- .../tradinggoose/app/api/copilot/mcp/route.ts | 2 +- .../tools/server/entities/shared.test.ts | 4 ++++ .../copilot/tools/server/entities/shared.ts | 16 +++++++++++++- .../copilot/tools/server/entities/workflow.ts | 21 ++++++++++++++++++- 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index f368ce38b..82ba9c635 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -134,7 +134,7 @@ describe('Copilot MCP route', () => { }) expect(mockUpdateApiKeyLastUsed).toHaveBeenCalledWith('key-1') expect(mockCheckApiEndpointRateLimit).toHaveBeenCalledWith('user-1', 'copilot-mcp') - expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ userId: 'user-1' }) + expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ userId: 'user-1', autoCreate: false }) expect(body.result.capabilities).toEqual({ tools: {} }) expect(body.result.serverInfo).toEqual({ name: 'TradingGoose', version: '0.1.0' }) expect(body.result.instructions).toContain('workspaceId=workspace-1, permissions=admin') diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index 137b51936..544734e17 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -111,7 +111,7 @@ async function authenticateCopilotMcpRequest( } async function buildInstructions(userId: string) { - const workspaces = await getUserWorkspaces({ userId }) + const workspaces = await getUserWorkspaces({ userId, autoCreate: false }) const workspaceLines = workspaces.length > 0 ? workspaces.map( diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts index afb6521b4..c1e8c2364 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts @@ -16,6 +16,7 @@ const { mockApplySavedEntityState } = vi.hoisted(() => ({ mockApplySavedEntityState: vi.fn(), })) const mockCheckWorkspaceAccess = vi.hoisted(() => vi.fn()) +const mockReadBootstrappedEntityListMembers = vi.hoisted(() => vi.fn()) const mockReadBootstrappedSavedEntityFields = vi.hoisted(() => vi.fn()) const mockVerifyReviewTargetAccess = vi.hoisted(() => vi.fn()) @@ -32,6 +33,8 @@ vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ + readBootstrappedEntityListMembers: (...args: unknown[]) => + mockReadBootstrappedEntityListMembers(...args), readBootstrappedSavedEntityFields: (...args: unknown[]) => mockReadBootstrappedSavedEntityFields(...args), })) @@ -48,6 +51,7 @@ describe('entity document mutation helpers', () => { hasAccess: true, workspaceId: 'workspace-1', }) + mockReadBootstrappedEntityListMembers.mockResolvedValue([]) }) it('applies full-access updates without building a review preview', async () => { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index 3efac2987..09d89490e 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -208,6 +208,17 @@ export function buildSavedEntityListInfo( return readBootstrappedEntityListMembers(entityKind, workspaceId) } +async function hashCreateEntityReviewBase( + kind: SavedEntityDocumentKind, + workspaceId: string +): Promise { + return hashServerToolReviewBase({ + kind, + workspaceId, + entities: await buildSavedEntityListInfo(kind as SavedEntityKind, workspaceId), + }) +} + export async function executeCreateEntityDocumentMutation( kind: SavedEntityDocumentKind, args: EntityDocumentArgs, @@ -229,7 +240,7 @@ export async function executeCreateEntityDocumentMutation( requiresReview: true, success: true, workspaceId, - reviewBaseStateHash: hashServerToolReviewBase({ kind, workspaceId }), + reviewBaseStateHash: await hashCreateEntityReviewBase(kind, workspaceId), ...buildDocumentEnvelope(kind, undefined, fields), preview: { documentDiff: { @@ -240,6 +251,9 @@ export async function executeCreateEntityDocumentMutation( } } + if (context?.acceptedReviewBaseStateHash) { + assertAcceptedServerToolReviewBase(context, await hashCreateEntityReviewBase(kind, workspaceId)) + } const created = await create(fields, scopedContext) return { success: true, diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index a5025ea41..8a123377b 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -79,6 +79,22 @@ type WorkflowVariableDocumentEntry = { value?: unknown } +async function hashWorkflowCreateReviewBase(workspaceId: string): Promise { + const rows = await db + .select({ entityId: workflow.id, entityName: workflow.name }) + .from(workflow) + .where(eq(workflow.workspaceId, workspaceId)) + rows.sort( + (left, right) => + left.entityName.localeCompare(right.entityName) || left.entityId.localeCompare(right.entityId) + ) + return hashServerToolReviewBase({ + kind: ENTITY_KIND_WORKFLOW, + workspaceId, + entities: rows, + }) +} + const WorkflowVariableDocumentSchema = z .object({ variables: z.array( @@ -477,10 +493,13 @@ export const createWorkflowServerTool: BaseServerTool< entityKind: ENTITY_KIND_WORKFLOW, entityName: name, workspaceId, - reviewBaseStateHash: hashServerToolReviewBase({ workspaceId }), + reviewBaseStateHash: await hashWorkflowCreateReviewBase(workspaceId), } } + if (context?.acceptedReviewBaseStateHash) { + assertAcceptedServerToolReviewBase(context, await hashWorkflowCreateReviewBase(workspaceId)) + } const workflowId = crypto.randomUUID() const now = new Date() const description = typeof args.description === 'string' ? args.description : 'New workflow' From c6361fbb57d9b6f81142a2a11deea0235f108ec0 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 16:15:36 -0600 Subject: [PATCH 236/284] fix(yjs): keep saved entity projections in sync Move entity list projection updates into the write path and run delete-side cleanup after the database mutation so a single side-effect failure cannot block the primary operation.\n\nCo-authored-by: Codex \nCo-authored-by: BWJ2310 \nCo-authored-by: BWJ2310-backup --- .../app/api/indicators/custom/route.ts | 7 ++- .../app/api/mcp/servers/[id]/route.ts | 44 ++++++++----------- .../app/api/tools/custom/route.ts | 7 ++- .../lib/custom-tools/operations.ts | 20 ++++----- .../lib/indicators/custom/operations.ts | 23 +++++----- apps/tradinggoose/lib/knowledge/service.ts | 11 +++-- apps/tradinggoose/lib/skills/operations.ts | 27 +++++++----- 7 files changed, 74 insertions(+), 65 deletions(-) diff --git a/apps/tradinggoose/app/api/indicators/custom/route.ts b/apps/tradinggoose/app/api/indicators/custom/route.ts index c3fd6f874..dd62f2b49 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -241,12 +241,15 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Indicator not found' }, { status: 404 }) } - await deleteYjsSessionInSocketServer(indicatorId) - await notifyEntityListMemberRemoved('indicator', workspaceId, indicatorId) await db .delete(pineIndicators) .where(and(eq(pineIndicators.id, indicatorId), eq(pineIndicators.workspaceId, workspaceId))) + await Promise.allSettled([ + deleteYjsSessionInSocketServer(indicatorId), + notifyEntityListMemberRemoved('indicator', workspaceId, indicatorId), + ]) + logger.info(`[${requestId}] Deleted indicator ${indicatorId}`) return NextResponse.json({ success: true }, { status: 200 }) } catch (error) { diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts index 164e918c9..00216b146 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts @@ -6,7 +6,11 @@ import { createLogger } from '@/lib/logs/console/logger' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { notifyEntityListMembersUpserted } from '@/lib/yjs/server/snapshot-bridge' +import { savedEntityRowToFields } from '@/lib/yjs/entity-state' +import { + applySavedEntityState, + SavedEntityPersistenceError, +} from '@/lib/yjs/server/apply-entity-state' import { RenameMcpServerSchema } from '../schema' const logger = createLogger('McpServerAPI') @@ -43,12 +47,9 @@ export const PATCH = withMcpAuth('write')( updates: Object.keys(body).filter((k) => k !== 'workspaceId'), }) - const [updatedServer] = await db - .update(mcpServers) - .set({ - name: body.name.trim(), - updatedAt: new Date(), - }) + const [server] = await db + .select() + .from(mcpServers) .where( and( eq(mcpServers.id, serverId), @@ -56,9 +57,9 @@ export const PATCH = withMcpAuth('write')( isNull(mcpServers.deletedAt) ) ) - .returning({ id: mcpServers.id, name: mcpServers.name, enabled: mcpServers.enabled }) + .limit(1) - if (!updatedServer) { + if (!server) { return createMcpErrorResponse( new Error('Server not found or access denied'), 'Server not found', @@ -66,20 +67,9 @@ export const PATCH = withMcpAuth('write')( ) } - try { - await notifyEntityListMembersUpserted('mcp_server', workspaceId, [ - { - id: serverId, - name: updatedServer.name, - enabled: updatedServer.enabled !== false, - }, - ]) - } catch (error) { - logger.warn(`[${requestId}] Updated MCP server but failed list projection update`, { - error, - serverId, - }) - } + const fields = savedEntityRowToFields('mcp_server', server) + const name = body.name.trim() + await applySavedEntityState('mcp_server', serverId, { ...fields, name }) // Clear MCP service cache after update mcpService.clearCache(workspaceId) @@ -89,11 +79,15 @@ export const PATCH = withMcpAuth('write')( server: { id: serverId, workspaceId, - name: updatedServer.name, - enabled: updatedServer.enabled !== false, + name, + enabled: fields.enabled !== false, }, }) } catch (error) { + if (error instanceof SavedEntityPersistenceError) { + return createMcpErrorResponse(error, error.message, error.status) + } + logger.error(`[${requestId}] Error updating MCP server:`, error) return createMcpErrorResponse( error instanceof Error ? error : new Error('Failed to update MCP server'), diff --git a/apps/tradinggoose/app/api/tools/custom/route.ts b/apps/tradinggoose/app/api/tools/custom/route.ts index d39c9f724..d1504c0f3 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.ts @@ -221,12 +221,15 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Tool not found' }, { status: 404 }) } - await deleteYjsSessionInSocketServer(toolId) - await notifyEntityListMemberRemoved('custom_tool', workspaceId, toolId) await db .delete(customTools) .where(and(eq(customTools.id, toolId), eq(customTools.workspaceId, workspaceId))) + await Promise.allSettled([ + deleteYjsSessionInSocketServer(toolId), + notifyEntityListMemberRemoved('custom_tool', workspaceId, toolId), + ]) + logger.info(`[${requestId}] Deleted tool: ${toolId}`) return NextResponse.json({ success: true }) } catch (error) { diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index 28d9cec87..c8e564b2a 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -101,15 +101,15 @@ export async function createCustomTools({ } const createdTools = await tx.insert(customTools).values(insertValues).returning() + await notifyEntityListMembersUpserted( + 'custom_tool', + workspaceId, + createdTools.map((createdTool) => ({ id: createdTool.id, name: createdTool.title })) + ) logger.info(`[${requestId}] Created ${createdTools.length} custom tool(s)`) return createdTools }) - await notifyEntityListMembersUpserted( - 'custom_tool', - workspaceId, - created.map((createdTool) => ({ id: createdTool.id, name: createdTool.title })) - ) return created } @@ -170,6 +170,11 @@ export async function importCustomTools({ })) const importedTools = await tx.insert(customTools).values(importValues).returning() + await notifyEntityListMembersUpserted( + 'custom_tool', + workspaceId, + importedTools.map((importedTool) => ({ id: importedTool.id, name: importedTool.title })) + ) logger.info(`[${requestId}] Imported ${importedTools.length} custom tool(s)`, { workspaceId, @@ -183,10 +188,5 @@ export async function importCustomTools({ } }) - await notifyEntityListMembersUpserted( - 'custom_tool', - workspaceId, - result.tools.map((importedTool) => ({ id: importedTool.id, name: importedTool.title })) - ) return result } diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index 1b48b1370..8d691381d 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -95,15 +95,18 @@ export async function createIndicators({ } const createdIndicators = await tx.insert(pineIndicators).values(insertValues).returning() + await notifyEntityListMembersUpserted( + 'indicator', + workspaceId, + createdIndicators.map((createdIndicator) => ({ + id: createdIndicator.id, + name: createdIndicator.name, + })) + ) logger.info(`[${requestId}] Created ${createdIndicators.length} indicator(s)`) return createdIndicators }) - await notifyEntityListMembersUpserted( - 'indicator', - workspaceId, - created.map((createdIndicator) => ({ id: createdIndicator.id, name: createdIndicator.name })) - ) return created } @@ -174,6 +177,11 @@ export async function importIndicators({ }) const importedIndicators = await tx.insert(pineIndicators).values(importValues).returning() + await notifyEntityListMembersUpserted( + 'indicator', + workspaceId, + importedIndicators.map((imported) => ({ id: imported.id, name: imported.name })) + ) logger.info(`[${requestId}] Imported ${importedIndicators.length} indicator(s)`, { workspaceId, @@ -187,10 +195,5 @@ export async function importIndicators({ } }) - await notifyEntityListMembersUpserted( - 'indicator', - workspaceId, - result.indicators.map((imported) => ({ id: imported.id, name: imported.name })) - ) return result } diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index 63a6aca41..3a5d5dca2 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -425,10 +425,6 @@ export async function deleteKnowledgeBase( .where(eq(knowledgeBase.id, knowledgeBaseId)) .limit(1) - await deleteYjsSessionInSocketServer(knowledgeBaseId) - if (existing?.workspaceId) { - await notifyEntityListMemberRemoved('knowledge_base', existing.workspaceId, knowledgeBaseId) - } await db .update(knowledgeBase) .set({ @@ -437,5 +433,12 @@ export async function deleteKnowledgeBase( }) .where(eq(knowledgeBase.id, knowledgeBaseId)) + if (existing?.workspaceId) { + await Promise.allSettled([ + deleteYjsSessionInSocketServer(knowledgeBaseId), + notifyEntityListMemberRemoved('knowledge_base', existing.workspaceId, knowledgeBaseId), + ]) + } + logger.info(`[${requestId}] Soft deleted knowledge base: ${knowledgeBaseId}`) } diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index 64bce1908..a1b1b7dc8 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -74,12 +74,15 @@ export async function deleteSkill(params: { return false } - await deleteYjsSessionInSocketServer(params.skillId) - await notifyEntityListMemberRemoved('skill', params.workspaceId, params.skillId) await db .delete(skill) .where(and(eq(skill.id, params.skillId), eq(skill.workspaceId, params.workspaceId))) + await Promise.allSettled([ + deleteYjsSessionInSocketServer(params.skillId), + notifyEntityListMemberRemoved('skill', params.workspaceId, params.skillId), + ]) + logger.info(`Deleted skill ${params.skillId}`) return true } @@ -133,15 +136,15 @@ export async function createSkills({ } const createdSkills = await tx.insert(skill).values(insertValues).returning() + await notifyEntityListMembersUpserted( + 'skill', + workspaceId, + createdSkills.map((createdSkill) => ({ id: createdSkill.id, name: createdSkill.name })) + ) logger.info(`[${requestId}] Created ${createdSkills.length} skill(s)`) return createdSkills }) - await notifyEntityListMembersUpserted( - 'skill', - workspaceId, - created.map((createdSkill) => ({ id: createdSkill.id, name: createdSkill.name })) - ) return created } @@ -214,6 +217,11 @@ export async function importSkills({ }) const persistedSkills = await tx.insert(skill).values(insertValues).returning() + await notifyEntityListMembersUpserted( + 'skill', + workspaceId, + persistedSkills.map((importedSkill) => ({ id: importedSkill.id, name: importedSkill.name })) + ) logger.info(`[${requestId}] Imported ${persistedSkills.length} skill(s)`, { workspaceId, @@ -228,10 +236,5 @@ export async function importSkills({ } }) - await notifyEntityListMembersUpserted( - 'skill', - workspaceId, - result.skills.map((importedSkill) => ({ id: importedSkill.id, name: importedSkill.name })) - ) return result } From 87c74876ea2000f065a3cdff0f8b1b5baa2f04f1 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 16:15:45 -0600 Subject: [PATCH 237/284] perf(yjs): parallelize bootstrapped field reads Fetch bootstrapped saved-entity fields concurrently and filter missing entries after the fact to reduce latency in review-target bootstrap reads.\n\nCo-authored-by: Codex \nCo-authored-by: BWJ2310 \nCo-authored-by: BWJ2310-backup --- .../lib/yjs/server/bootstrap-review-target.ts | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 7d8e20bda..93364a8cf 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -88,23 +88,25 @@ export async function readBootstrappedSavedEntityListFields( workspaceId: string ): Promise }>> { const members = await readBootstrappedEntityListMembers(entityKind, workspaceId) - const entries: Array }> = [] - - for (const member of members) { - try { - entries.push({ - ...member, - fields: await readBootstrappedSavedEntityFields(entityKind, member.entityId, workspaceId), - }) - } catch (error) { - if (error instanceof ReviewTargetBootstrapError && error.status === 404) { - continue + const entries = await Promise.all( + members.map(async (member) => { + try { + return { + ...member, + fields: await readBootstrappedSavedEntityFields(entityKind, member.entityId, workspaceId), + } + } catch (error) { + if (error instanceof ReviewTargetBootstrapError && error.status === 404) { + return null + } + throw error } - throw error - } - } + }) + ) - return entries + return entries.filter( + (entry): entry is EntityListMember & { fields: Record } => entry !== null + ) } export async function readBootstrappedSavedEntityFields( From 4c6449f8d644f712981c2a278b3f58682bf705e0 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 16:47:45 -0600 Subject: [PATCH 238/284] fix(yjs): surface realtime-required snapshot errors Map SavedEntityRealtimeRequiredError through snapshot bootstrapping, API handlers, and Copilot server tool error responses. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/indicators/custom/route.ts | 4 +++ .../app/api/indicators/options/route.ts | 4 +++ apps/tradinggoose/app/api/knowledge/route.ts | 4 +++ .../app/api/mcp/servers/[id]/refresh/route.ts | 9 ++++++- .../tradinggoose/app/api/mcp/servers/route.ts | 4 +++ apps/tradinggoose/app/api/skills/route.ts | 4 +++ .../app/api/tools/custom/route.ts | 4 +++ .../lib/copilot/server-tool-errors.ts | 25 +++++++++++++------ apps/tradinggoose/lib/yjs/entity-state.ts | 15 +++++++++++ .../lib/yjs/server/apply-entity-state.ts | 4 +-- .../lib/yjs/server/bootstrap-review-target.ts | 15 ++++++++--- 11 files changed, 77 insertions(+), 15 deletions(-) diff --git a/apps/tradinggoose/app/api/indicators/custom/route.ts b/apps/tradinggoose/app/api/indicators/custom/route.ts index dd62f2b49..fe06d4d1b 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -6,6 +6,7 @@ import { z } from 'zod' import { createIndicators, listIndicators, saveIndicator } from '@/lib/indicators/custom/operations' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' import { deleteYjsSessionInSocketServer, @@ -84,6 +85,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ data: await listIndicators({ workspaceId }) }, { status: 200 }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error fetching indicators:`, error) return NextResponse.json({ error: 'Failed to fetch indicators' }, { status: 500 }) } diff --git a/apps/tradinggoose/app/api/indicators/options/route.ts b/apps/tradinggoose/app/api/indicators/options/route.ts index 97411c625..e1a40d3ed 100644 --- a/apps/tradinggoose/app/api/indicators/options/route.ts +++ b/apps/tradinggoose/app/api/indicators/options/route.ts @@ -7,6 +7,7 @@ import { isIndicatorTriggerCapable } from '@/lib/indicators/trigger-detection' import type { InputMetaMap } from '@/lib/indicators/types' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' import { authenticateIndicatorRequest, checkWorkspacePermission } from '../utils' const logger = createLogger('IndicatorOptionsAPI') @@ -111,6 +112,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ data: merged }, { status: 200 }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Failed to list indicator options`, { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/tradinggoose/app/api/knowledge/route.ts b/apps/tradinggoose/app/api/knowledge/route.ts index e9dbcc828..0a7c7238f 100644 --- a/apps/tradinggoose/app/api/knowledge/route.ts +++ b/apps/tradinggoose/app/api/knowledge/route.ts @@ -4,6 +4,7 @@ import { getSession } from '@/lib/auth' import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' const logger = createLogger('KnowledgeBaseAPI') @@ -50,6 +51,9 @@ export async function GET(req: NextRequest) { data: await getKnowledgeBases(session.user.id, workspaceId), }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error fetching knowledge bases`, error) return NextResponse.json({ error: 'Failed to fetch knowledge bases' }, { status: 500 }) } diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts index 7ea21403d..069bba8f5 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts @@ -6,6 +6,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { withMcpAuth } from '@/lib/mcp/middleware' import { McpServerNotFoundError, mcpService } from '@/lib/mcp/service' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' const logger = createLogger('McpServerRefreshAPI') @@ -62,7 +63,10 @@ export const POST = withMcpAuth('read')( `[${requestId}] Successfully connected to server ${serverId}, discovered ${toolCount} tools` ) } catch (error) { - if (error instanceof McpServerNotFoundError) { + if ( + error instanceof McpServerNotFoundError || + error instanceof SavedEntityRealtimeRequiredError + ) { throw error } connectionStatus = 'error' @@ -97,6 +101,9 @@ export const POST = withMcpAuth('read')( if (error instanceof McpServerNotFoundError) { return createMcpErrorResponse(error, 'Server not found', error.status) } + if (error instanceof SavedEntityRealtimeRequiredError) { + return createMcpErrorResponse(error, error.message, error.status) + } return createMcpErrorResponse( error instanceof Error ? error : new Error('Failed to refresh MCP server'), 'Failed to refresh MCP server', diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 23d4b9a19..dae9e2972 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -7,6 +7,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' import { readBootstrappedEntityListMembers } from '@/lib/yjs/server/bootstrap-review-target' import { deleteYjsSessionInSocketServer, @@ -69,6 +70,9 @@ export const GET = withMcpAuth('read')( ) return createMcpSuccessResponse({ servers }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return createMcpErrorResponse(error, error.message, error.status) + } logger.error(`[${requestId}] Error listing MCP servers:`, error) return createMcpErrorResponse( error instanceof Error ? error : new Error('Failed to list MCP servers'), diff --git a/apps/tradinggoose/app/api/skills/route.ts b/apps/tradinggoose/app/api/skills/route.ts index d675ccaf3..0f9e6fa82 100644 --- a/apps/tradinggoose/app/api/skills/route.ts +++ b/apps/tradinggoose/app/api/skills/route.ts @@ -10,6 +10,7 @@ import { } from '@/lib/skills/import-export' import { createSkills, deleteSkill, listSkills, saveSkill } from '@/lib/skills/operations' import { generateRequestId } from '@/lib/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' const logger = createLogger('SkillsAPI') @@ -60,6 +61,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ data: await listSkills({ workspaceId }) }, { status: 200 }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error fetching skills:`, error) return NextResponse.json({ error: 'Failed to fetch skills' }, { status: 500 }) } diff --git a/apps/tradinggoose/app/api/tools/custom/route.ts b/apps/tradinggoose/app/api/tools/custom/route.ts index d1504c0f3..a1360c49e 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.ts @@ -10,6 +10,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' import { readWorkflowAccessContext } from '@/lib/workflows/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' import { deleteYjsSessionInSocketServer, @@ -65,6 +66,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ data: await listCustomTools({ workspaceId }) }, { status: 200 }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error fetching custom tools:`, error) return NextResponse.json({ error: 'Failed to fetch custom tools' }, { status: 500 }) } diff --git a/apps/tradinggoose/lib/copilot/server-tool-errors.ts b/apps/tradinggoose/lib/copilot/server-tool-errors.ts index 2fdf1ee90..0a307eb71 100644 --- a/apps/tradinggoose/lib/copilot/server-tool-errors.ts +++ b/apps/tradinggoose/lib/copilot/server-tool-errors.ts @@ -129,13 +129,13 @@ function buildEditWorkflowError(message: string): CopilotServerToolErrorResponse ? 'Use only the canonical sub-block ids from `get_blocks_metadata` for that block type. Keep the existing canonical ids and remove invented keys.' : details.includes('removedBlockIds') ? 'Keep every existing block id in the Mermaid graph unless the user explicitly asked to remove it; list intentional removals in `removedBlockIds`.' - : details.includes('immutable identities') - ? 'Keep the existing block id/type pair unchanged. `edit_workflow` rewrites topology only; it cannot replace an existing block or change its type.' - : details.includes('unknown block type') - ? 'Use block types exactly as returned by `get_available_blocks` or `get_blocks_metadata`.' - : details.includes('Edge references non-existent') - ? 'Every edge source and target must match a block id in the same document.' - : 'Return a complete workflow graph that validates as workflow state. Preserve block ids and valid edge references.' + : details.includes('immutable identities') + ? 'Keep the existing block id/type pair unchanged. `edit_workflow` rewrites topology only; it cannot replace an existing block or change its type.' + : details.includes('unknown block type') + ? 'Use block types exactly as returned by `get_available_blocks` or `get_blocks_metadata`.' + : details.includes('Edge references non-existent') + ? 'Every edge source and target must match a block id in the same document.' + : 'Return a complete workflow graph that validates as workflow state. Preserve block ids and valid edge references.' return { status: 422, @@ -174,6 +174,17 @@ export function buildCopilotServerToolErrorResponse( } const message = error instanceof Error ? error.message : 'Failed to execute server tool' + const typedError = error as { status?: unknown; code?: unknown; retryable?: unknown } + if (typeof typedError.status === 'number' && typeof typedError.code === 'string') { + return { + status: typedError.status, + body: { + code: typedError.code, + error: message, + ...(typeof typedError.retryable === 'boolean' ? { retryable: typedError.retryable } : {}), + }, + } + } if (toolName === 'edit_workflow') { const structuredError = buildEditWorkflowError(message) diff --git a/apps/tradinggoose/lib/yjs/entity-state.ts b/apps/tradinggoose/lib/yjs/entity-state.ts index ebdf0b186..08dd1351b 100644 --- a/apps/tradinggoose/lib/yjs/entity-state.ts +++ b/apps/tradinggoose/lib/yjs/entity-state.ts @@ -8,6 +8,21 @@ export type SavedEntityRow = { [key: string]: any } +export class SavedEntityRealtimeRequiredError extends Error { + readonly code = 'SAVED_ENTITY_REALTIME_REQUIRED' + readonly status = 503 + readonly retryable = true + + constructor() { + super('Saved entity realtime orchestration is required') + this.name = 'SavedEntityRealtimeRequiredError' + } + + responseBody() { + return { error: this.message, code: this.code, retryable: this.retryable } + } +} + export function savedEntityRowToFields( entityKind: SavedEntityKind, row: SavedEntityRow diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index 769ce1520..ec66beb91 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -14,8 +14,6 @@ import { getEntityFields, getEntityWorkspaceId } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { applyEntityStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' -const SAVED_ENTITY_REALTIME_REQUIRED_CODE = 'SAVED_ENTITY_REALTIME_REQUIRED' - export class SavedEntityPersistenceError extends Error { constructor( public status: number, @@ -189,7 +187,7 @@ export async function applySavedEntityState( throw new SavedEntityPersistenceError( 503, 'Saved entity realtime orchestration is required', - SAVED_ENTITY_REALTIME_REQUIRED_CODE + 'SAVED_ENTITY_REALTIME_REQUIRED' ) } } diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 93364a8cf..5c47f0636 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -19,13 +19,13 @@ import { seedEntityListSession, seedEntitySession, } from '@/lib/yjs/entity-session' -import type { SavedEntityKind } from '@/lib/yjs/entity-state' +import { type SavedEntityKind, SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' import { readEntityListMembersFromDb, readSavedEntityFieldsFromDb, resolveEntityWorkspaceId, } from '@/lib/yjs/server/entity-loaders' -import { getYjsSnapshot } from '@/lib/yjs/server/snapshot-bridge' +import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' import { createWorkflowSnapshot, @@ -58,6 +58,13 @@ export function getRuntimeStateFromUpdate(update: Uint8Array): ReviewTargetRunti } } +function mapSavedEntitySnapshotError(error: unknown): never { + if (error instanceof SocketServerBridgeError && error.status < 500) { + throw new ReviewTargetBootstrapError(error.status, error.message) + } + throw new SavedEntityRealtimeRequiredError() +} + export async function readBootstrappedReviewTargetSnapshot(descriptor: ReviewTargetDescriptor) { const bridgeParams = serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor)) return getYjsSnapshot(descriptor.yjsSessionId, bridgeParams) @@ -69,7 +76,7 @@ export async function readBootstrappedEntityListMembers( ): Promise { const snapshot = await readBootstrappedReviewTargetSnapshot( buildEntityListDescriptor(entityKind, workspaceId) - ) + ).catch(mapSavedEntitySnapshotError) if (!snapshot.snapshotBase64) { return [] } @@ -116,7 +123,7 @@ export async function readBootstrappedSavedEntityFields( ): Promise> { const snapshot = await readBootstrappedReviewTargetSnapshot( buildSavedEntityDescriptor(entityKind, entityId, workspaceId) - ) + ).catch(mapSavedEntitySnapshotError) if (!snapshot.snapshotBase64) { throw new ReviewTargetBootstrapError(404, `Saved ${entityKind} ${entityId} state is missing`) } From 7c0839fb139495098aadeaef375ce29b1623cd45 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 16:48:02 -0600 Subject: [PATCH 239/284] feat(copilot): auto-create MCP workspaces Ensure Copilot MCP instruction generation can create a workspace when one does not already exist. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/copilot/mcp/route.test.ts | 2 +- apps/tradinggoose/app/api/copilot/mcp/route.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index 82ba9c635..b9b84f29d 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -134,7 +134,7 @@ describe('Copilot MCP route', () => { }) expect(mockUpdateApiKeyLastUsed).toHaveBeenCalledWith('key-1') expect(mockCheckApiEndpointRateLimit).toHaveBeenCalledWith('user-1', 'copilot-mcp') - expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ userId: 'user-1', autoCreate: false }) + expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ userId: 'user-1', autoCreate: true }) expect(body.result.capabilities).toEqual({ tools: {} }) expect(body.result.serverInfo).toEqual({ name: 'TradingGoose', version: '0.1.0' }) expect(body.result.instructions).toContain('workspaceId=workspace-1, permissions=admin') diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index 544734e17..64f518b40 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -111,7 +111,7 @@ async function authenticateCopilotMcpRequest( } async function buildInstructions(userId: string) { - const workspaces = await getUserWorkspaces({ userId, autoCreate: false }) + const workspaces = await getUserWorkspaces({ userId, autoCreate: true }) const workspaceLines = workspaces.length > 0 ? workspaces.map( From d0d0a4402f7edfc590a537e9c83f5bf7867eaffb Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 17:39:09 -0600 Subject: [PATCH 240/284] fix(knowledge): make knowledge base notifications transactional Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/knowledge/service.ts | 27 ++++++++++++---------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index 3a5d5dca2..0f72c3927 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -86,10 +86,6 @@ export async function createKnowledgeBase( deletedAt: null, } - await db.insert(knowledgeBase).values(newKnowledgeBase) - - logger.info(`[${requestId}] Created knowledge base: ${data.name} (${kbId})`) - const created = { id: kbId, name: data.name, @@ -104,9 +100,14 @@ export async function createKnowledgeBase( docCount: 0, } - await notifyEntityListMembersUpserted('knowledge_base', data.workspaceId, [ - { id: created.id, name: created.name }, - ]) + await db.transaction(async (tx) => { + await tx.insert(knowledgeBase).values(newKnowledgeBase) + await notifyEntityListMembersUpserted('knowledge_base', data.workspaceId, [ + { id: created.id, name: created.name }, + ]) + }) + + logger.info(`[${requestId}] Created knowledge base: ${data.name} (${kbId})`) return created } @@ -172,12 +173,13 @@ export async function copyKnowledgeBaseToWorkspace( throw error } + const copiedName = `${sourceKnowledgeBase.name} (Copy)` const copyTransaction = db.transaction(async (tx) => { await tx.insert(knowledgeBase).values({ id: newKnowledgeBaseId, userId, workspaceId: targetWorkspaceId, - name: `${sourceKnowledgeBase.name} (Copy)`, + name: copiedName, description: sourceKnowledgeBase.description, tokenCount: sourceKnowledgeBase.tokenCount, embeddingModel: sourceKnowledgeBase.embeddingModel, @@ -302,6 +304,10 @@ export async function copyKnowledgeBaseToWorkspace( ) } } + + await notifyEntityListMembersUpserted('knowledge_base', targetWorkspaceId, [ + { id: newKnowledgeBaseId, name: copiedName }, + ]) }) try { @@ -315,7 +321,7 @@ export async function copyKnowledgeBaseToWorkspace( const copied = { id: newKnowledgeBaseId, - name: `${sourceKnowledgeBase.name} (Copy)`, + name: copiedName, description: sourceKnowledgeBase.description, tokenCount: sourceKnowledgeBase.tokenCount, embeddingModel: sourceKnowledgeBase.embeddingModel, @@ -343,9 +349,6 @@ export async function copyKnowledgeBaseToWorkspace( `[${requestId}] Copied knowledge base ${sourceKnowledgeBaseId} to workspace ${targetWorkspaceId} as ${newKnowledgeBaseId}` ) - await notifyEntityListMembersUpserted('knowledge_base', targetWorkspaceId, [ - { id: copied.id, name: copied.name }, - ]) return copied } From b43c3bb37bcf8183570f6fea3a45d9d6d34cf7a3 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 17:39:20 -0600 Subject: [PATCH 241/284] feat(yjs): persist workflow docs on live updates Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../socket-server/yjs/upstream-utils.ts | 49 ++++++++++++++++--- .../socket-server/yjs/ws-handler.test.ts | 7 ++- .../socket-server/yjs/ws-handler.ts | 24 +++++---- 3 files changed, 64 insertions(+), 16 deletions(-) diff --git a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts index 0baccfc29..4b75b2d23 100644 --- a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts +++ b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts @@ -25,22 +25,24 @@ const wsReadyStateOpen = 1 const PING_TIMEOUT = 30_000 const docs = new Map() -type DocumentIdleHandler = (docId: string, doc: Y.Doc) => Promise | void +type DocumentPersistenceHandler = (docId: string, doc: Y.Doc) => Promise | void class WSSharedDoc extends Y.Doc { name: string conns: Map> awareness: awarenessProtocol.Awareness whenInitialized: Promise - onDocumentIdle?: DocumentIdleHandler - hasUnsavedChanges: boolean + onDocumentIdle?: DocumentPersistenceHandler + onDocumentUpdate?: DocumentPersistenceHandler + hasUnsavedChanges = false + isPersisting = false + needsPersist = false constructor(name: string, gc: boolean) { super({ gc }) this.name = name this.conns = new Map() this.awareness = new awarenessProtocol.Awareness(this) - this.hasUnsavedChanges = false this.awareness.setLocalState(null) this.awareness.on( @@ -72,6 +74,7 @@ class WSSharedDoc extends Y.Doc { this.on('update', (update: Uint8Array, _origin: unknown) => { this.hasUnsavedChanges = true + scheduleDocumentPersistence(this) const encoder = encoding.createEncoder() encoding.writeVarUint(encoder, messageSync) syncProtocol.writeUpdate(encoder, update) @@ -83,6 +86,36 @@ class WSSharedDoc extends Y.Doc { } } +function scheduleDocumentPersistence(doc: WSSharedDoc): void { + const persist = doc.onDocumentUpdate + if (!persist) { + return + } + + if (doc.isPersisting) { + doc.needsPersist = true + return + } + + doc.isPersisting = true + doc.needsPersist = false + void Promise.resolve(persist(doc.name, doc)) + .then(() => { + if (!doc.needsPersist) { + doc.hasUnsavedChanges = false + } + }) + .catch((error) => { + console.error('[yjs upstream-utils] Failed to persist live document', error) + }) + .finally(() => { + doc.isPersisting = false + if (doc.needsPersist) { + scheduleDocumentPersistence(doc) + } + }) +} + function cleanupDocument(doc: WSSharedDoc): void { if (docs.get(doc.name) !== doc) { return @@ -211,15 +244,19 @@ export function setupWSConnection( docId: string gc?: boolean bootstrapState?: Uint8Array - onDocumentIdle?: DocumentIdleHandler + onDocumentIdle?: DocumentPersistenceHandler + onDocumentUpdate?: DocumentPersistenceHandler } ): void { - const { docId, gc = true, bootstrapState, onDocumentIdle } = opts + const { docId, gc = true, bootstrapState, onDocumentIdle, onDocumentUpdate } = opts conn.binaryType = 'arraybuffer' const doc = getDocument(docId, gc, bootstrapState) as WSSharedDoc doc.onDocumentIdle = onDocumentIdle + if (onDocumentUpdate) { + doc.onDocumentUpdate = onDocumentUpdate + } doc.conns.set(conn, new Set()) conn.on('message', (data: ArrayBuffer) => { diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts index 1f422e720..2ccd92e17 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts @@ -234,7 +234,12 @@ describe('handleYjsUpgrade', () => { expect(mockSetupWSConnection).toHaveBeenCalledWith( expect.anything(), request, - expect.objectContaining({ bootstrapState, docId: sessionId, gc: true }) + expect.objectContaining({ + bootstrapState, + docId: sessionId, + gc: true, + onDocumentUpdate: expect.any(Function), + }) ) expect(socket.write).not.toHaveBeenCalled() expect(socket.destroy).not.toHaveBeenCalled() diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.ts index 8b9e15e4f..1d9909cb6 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.ts @@ -17,7 +17,7 @@ import { getRuntimeStateFromDoc, } from '@/lib/yjs/server/bootstrap-review-target' import { authenticateYjsConnection, YjsAuthError } from './auth' -import { getExistingDocument, markDocumentPersisted, setupWSConnection } from './upstream-utils' +import { getExistingDocument, setupWSConnection } from './upstream-utils' const logger = createLogger('YjsWsHandler') @@ -25,9 +25,10 @@ interface YjsIncomingMessage extends IncomingMessage { yjsSessionId?: string yjsUserId?: string yjsBootstrapState?: Uint8Array + yjsPersistLiveUpdates?: boolean } -async function persistIdleDocument(docId: string, doc: Y.Doc): Promise { +async function persistWorkflowDocument(docId: string, doc: Y.Doc): Promise { if (isEntityListSessionId(docId)) { return } @@ -41,12 +42,8 @@ async function persistIdleDocument(docId: string, doc: Y.Doc): Promise { return } - // Only workflows auto-save on idle. Saved entities (skill, custom_tool, - // indicator, knowledge_base, mcp_server) persist ONLY via the explicit Save - // path or the Copilot apply path; their dirty docs are discarded on idle. if (metadata.get('entityKind') === 'workflow') { await saveWorkflowYjsDocToDb(docId, doc) - markDocumentPersisted(doc) } } @@ -68,11 +65,12 @@ export function handleYjsUpgrade( const yjsSessionId = decodeURIComponent(match[1]) void authenticateAndPrepareUpgrade(yjsSessionId, url) - .then(({ bootstrapState, userId, resolvedSessionId }) => { + .then(({ bootstrapState, userId, resolvedSessionId, persistLiveUpdates }) => { const yjsReq = request as YjsIncomingMessage yjsReq.yjsSessionId = resolvedSessionId yjsReq.yjsUserId = userId yjsReq.yjsBootstrapState = bootstrapState + yjsReq.yjsPersistLiveUpdates = persistLiveUpdates ensureConnectionHandler(wss) wss.handleUpgrade(request, socket, head, (ws: WebSocket) => { @@ -93,7 +91,12 @@ export function handleYjsUpgrade( async function authenticateAndPrepareUpgrade( pathSessionId: string, url: URL -): Promise<{ bootstrapState?: Uint8Array; userId: string; resolvedSessionId: string }> { +): Promise<{ + bootstrapState?: Uint8Array + userId: string + resolvedSessionId: string + persistLiveUpdates: boolean +}> { const accessMode = parseAccessMode(url, pathSessionId) const { userId, envelope } = await authenticateYjsConnection(url) @@ -146,6 +149,8 @@ async function authenticateAndPrepareUpgrade( bootstrapState: bootstrapped?.state, userId, resolvedSessionId: pathSessionId, + persistLiveUpdates: + descriptor.entityKind === 'workflow' && descriptor.entityId === pathSessionId, } } @@ -188,7 +193,8 @@ function ensureConnectionHandler(wss: WebSocketServer): void { docId, gc: true, bootstrapState: yjsReq.yjsBootstrapState, - onDocumentIdle: persistIdleDocument, + onDocumentIdle: persistWorkflowDocument, + onDocumentUpdate: yjsReq.yjsPersistLiveUpdates ? persistWorkflowDocument : undefined, }) } catch (error) { logger.error('Failed to attach Yjs connection', { docId, error }) From 878b9139ee131cfe6b9bb57920c001b42c8817ec Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 18:11:14 -0600 Subject: [PATCH 242/284] feat(realtime): surface realtime sync errors from saved entity writes Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/indicators/custom/import/route.ts | 4 ++ .../app/api/indicators/custom/route.ts | 3 ++ .../app/api/knowledge/[id]/copy/route.ts | 4 ++ apps/tradinggoose/app/api/knowledge/route.ts | 3 ++ .../tradinggoose/app/api/mcp/servers/route.ts | 14 +++---- .../app/api/skills/import/route.ts | 4 ++ apps/tradinggoose/app/api/skills/route.ts | 3 ++ .../app/api/tools/custom/import/route.ts | 4 ++ .../app/api/tools/custom/route.ts | 3 ++ .../lib/custom-tools/operations.ts | 31 ++++++++-------- .../lib/indicators/custom/operations.ts | 37 +++++++++---------- apps/tradinggoose/lib/knowledge/service.ts | 18 ++++----- .../lib/skills/operations.test.ts | 9 ++++- apps/tradinggoose/lib/skills/operations.ts | 31 ++++++++-------- .../lib/yjs/server/snapshot-bridge.ts | 17 ++++++--- 15 files changed, 110 insertions(+), 75 deletions(-) diff --git a/apps/tradinggoose/app/api/indicators/custom/import/route.ts b/apps/tradinggoose/app/api/indicators/custom/import/route.ts index e7aac5fed..e6d542828 100644 --- a/apps/tradinggoose/app/api/indicators/custom/import/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/import/route.ts @@ -4,6 +4,7 @@ import { importIndicators } from '@/lib/indicators/custom/operations' import { parseImportedIndicatorsFile } from '@/lib/indicators/import-export' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' import { authenticateIndicatorRequest, checkWorkspacePermission } from '@/app/api/indicators/utils' const logger = createLogger('IndicatorsImportAPI') @@ -79,6 +80,9 @@ export async function POST(request: NextRequest) { throw validationError } } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error importing indicators`, { error }) return NextResponse.json({ error: 'Failed to import indicators' }, { status: 500 }) } diff --git a/apps/tradinggoose/app/api/indicators/custom/route.ts b/apps/tradinggoose/app/api/indicators/custom/route.ts index fe06d4d1b..d430b09c7 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -188,6 +188,9 @@ export async function POST(request: NextRequest) { throw validationError } } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error updating indicators`, error) return NextResponse.json({ error: 'Failed to update indicators' }, { status: 500 }) } diff --git a/apps/tradinggoose/app/api/knowledge/[id]/copy/route.ts b/apps/tradinggoose/app/api/knowledge/[id]/copy/route.ts index 030213c9a..32d6b4e1b 100644 --- a/apps/tradinggoose/app/api/knowledge/[id]/copy/route.ts +++ b/apps/tradinggoose/app/api/knowledge/[id]/copy/route.ts @@ -4,6 +4,7 @@ import { getSession } from '@/lib/auth' import { copyKnowledgeBaseToWorkspace } from '@/lib/knowledge/service' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' const logger = createLogger('KnowledgeBaseCopyAPI') @@ -44,6 +45,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: data: copiedKnowledgeBase, }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } if (error instanceof z.ZodError) { return NextResponse.json( { error: 'Invalid request data', details: error.errors }, diff --git a/apps/tradinggoose/app/api/knowledge/route.ts b/apps/tradinggoose/app/api/knowledge/route.ts index 0a7c7238f..abf3f34e5 100644 --- a/apps/tradinggoose/app/api/knowledge/route.ts +++ b/apps/tradinggoose/app/api/knowledge/route.ts @@ -102,6 +102,9 @@ export async function POST(req: NextRequest) { throw validationError } } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error creating knowledge base`, error) return NextResponse.json({ error: 'Failed to create knowledge base' }, { status: 500 }) } diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index dae9e2972..3318e4a73 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -140,14 +140,9 @@ export const POST = withMcpAuth('write')( updatedAt: new Date(), }) - try { - await notifyEntityListMembersUpserted('mcp_server', workspaceId, [ - { id: serverId, name: String(fields.name ?? ''), enabled: fields.enabled !== false }, - ]) - } catch (error) { - await db.delete(mcpServers).where(eq(mcpServers.id, serverId)) - throw error - } + await notifyEntityListMembersUpserted('mcp_server', workspaceId, [ + { id: serverId, name: String(fields.name ?? ''), enabled: fields.enabled !== false }, + ]) mcpService.clearCache(workspaceId) @@ -168,6 +163,9 @@ export const POST = withMcpAuth('write')( return createMcpSuccessResponse({ serverId }, 201) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return createMcpErrorResponse(error, error.message, error.status) + } logger.error(`[${requestId}] Error registering MCP server:`, error) return createMcpErrorResponse( error instanceof Error ? error : new Error('Failed to register MCP server'), diff --git a/apps/tradinggoose/app/api/skills/import/route.ts b/apps/tradinggoose/app/api/skills/import/route.ts index c293868c8..6320f54ce 100644 --- a/apps/tradinggoose/app/api/skills/import/route.ts +++ b/apps/tradinggoose/app/api/skills/import/route.ts @@ -6,6 +6,7 @@ import { getUserEntityPermissions } from '@/lib/permissions/utils' import { parseImportedSkillsFile } from '@/lib/skills/import-export' import { importSkills } from '@/lib/skills/operations' import { generateRequestId } from '@/lib/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' const logger = createLogger('SkillsImportAPI') @@ -60,6 +61,9 @@ export async function POST(request: NextRequest) { }, }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } if (error instanceof z.ZodError) { logger.warn(`[${requestId}] Invalid skills import data`, { errors: error.errors }) const workspaceError = error.errors.find( diff --git a/apps/tradinggoose/app/api/skills/route.ts b/apps/tradinggoose/app/api/skills/route.ts index 0f9e6fa82..6433e18db 100644 --- a/apps/tradinggoose/app/api/skills/route.ts +++ b/apps/tradinggoose/app/api/skills/route.ts @@ -160,6 +160,9 @@ export async function POST(request: NextRequest) { throw validationError } } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error updating skills`, error) return NextResponse.json({ error: 'Failed to update skills' }, { status: 500 }) } diff --git a/apps/tradinggoose/app/api/tools/custom/import/route.ts b/apps/tradinggoose/app/api/tools/custom/import/route.ts index d5ac85980..f344c1e0d 100644 --- a/apps/tradinggoose/app/api/tools/custom/import/route.ts +++ b/apps/tradinggoose/app/api/tools/custom/import/route.ts @@ -6,6 +6,7 @@ import { importCustomTools } from '@/lib/custom-tools/operations' import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' const logger = createLogger('CustomToolsImportAPI') @@ -59,6 +60,9 @@ export async function POST(request: NextRequest) { }, }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } if (error instanceof z.ZodError) { logger.warn(`[${requestId}] Invalid custom tools import data`, { errors: error.errors }) const workspaceError = error.errors.find( diff --git a/apps/tradinggoose/app/api/tools/custom/route.ts b/apps/tradinggoose/app/api/tools/custom/route.ts index a1360c49e..264ec7349 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.ts @@ -171,6 +171,9 @@ export async function POST(req: NextRequest) { throw validationError } } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error updating custom tools`, error) return NextResponse.json({ error: 'Failed to update custom tools' }, { status: 500 }) } diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index c8e564b2a..c9222185f 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -101,15 +101,15 @@ export async function createCustomTools({ } const createdTools = await tx.insert(customTools).values(insertValues).returning() - await notifyEntityListMembersUpserted( - 'custom_tool', - workspaceId, - createdTools.map((createdTool) => ({ id: createdTool.id, name: createdTool.title })) - ) - logger.info(`[${requestId}] Created ${createdTools.length} custom tool(s)`) return createdTools }) + await notifyEntityListMembersUpserted( + 'custom_tool', + workspaceId, + created.map((createdTool) => ({ id: createdTool.id, name: createdTool.title })) + ) + logger.info(`[${requestId}] Created ${created.length} custom tool(s)`) return created } @@ -170,16 +170,6 @@ export async function importCustomTools({ })) const importedTools = await tx.insert(customTools).values(importValues).returning() - await notifyEntityListMembersUpserted( - 'custom_tool', - workspaceId, - importedTools.map((importedTool) => ({ id: importedTool.id, name: importedTool.title })) - ) - - logger.info(`[${requestId}] Imported ${importedTools.length} custom tool(s)`, { - workspaceId, - renamedCount, - }) return { tools: importedTools, @@ -188,5 +178,14 @@ export async function importCustomTools({ } }) + await notifyEntityListMembersUpserted( + 'custom_tool', + workspaceId, + result.tools.map((importedTool) => ({ id: importedTool.id, name: importedTool.title })) + ) + logger.info(`[${requestId}] Imported ${result.tools.length} custom tool(s)`, { + workspaceId, + renamedCount: result.renamedCount, + }) return result } diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index 8d691381d..96ed35799 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -95,18 +95,18 @@ export async function createIndicators({ } const createdIndicators = await tx.insert(pineIndicators).values(insertValues).returning() - await notifyEntityListMembersUpserted( - 'indicator', - workspaceId, - createdIndicators.map((createdIndicator) => ({ - id: createdIndicator.id, - name: createdIndicator.name, - })) - ) - logger.info(`[${requestId}] Created ${createdIndicators.length} indicator(s)`) return createdIndicators }) + await notifyEntityListMembersUpserted( + 'indicator', + workspaceId, + created.map((createdIndicator) => ({ + id: createdIndicator.id, + name: createdIndicator.name, + })) + ) + logger.info(`[${requestId}] Created ${created.length} indicator(s)`) return created } @@ -177,16 +177,6 @@ export async function importIndicators({ }) const importedIndicators = await tx.insert(pineIndicators).values(importValues).returning() - await notifyEntityListMembersUpserted( - 'indicator', - workspaceId, - importedIndicators.map((imported) => ({ id: imported.id, name: imported.name })) - ) - - logger.info(`[${requestId}] Imported ${importedIndicators.length} indicator(s)`, { - workspaceId, - renamedCount, - }) return { indicators: importedIndicators, @@ -195,5 +185,14 @@ export async function importIndicators({ } }) + await notifyEntityListMembersUpserted( + 'indicator', + workspaceId, + result.indicators.map((imported) => ({ id: imported.id, name: imported.name })) + ) + logger.info(`[${requestId}] Imported ${result.indicators.length} indicator(s)`, { + workspaceId, + renamedCount: result.renamedCount, + }) return result } diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index 0f72c3927..acdae2860 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -100,12 +100,10 @@ export async function createKnowledgeBase( docCount: 0, } - await db.transaction(async (tx) => { - await tx.insert(knowledgeBase).values(newKnowledgeBase) - await notifyEntityListMembersUpserted('knowledge_base', data.workspaceId, [ - { id: created.id, name: created.name }, - ]) - }) + await db.insert(knowledgeBase).values(newKnowledgeBase) + await notifyEntityListMembersUpserted('knowledge_base', data.workspaceId, [ + { id: created.id, name: created.name }, + ]) logger.info(`[${requestId}] Created knowledge base: ${data.name} (${kbId})`) return created @@ -304,10 +302,6 @@ export async function copyKnowledgeBaseToWorkspace( ) } } - - await notifyEntityListMembersUpserted('knowledge_base', targetWorkspaceId, [ - { id: newKnowledgeBaseId, name: copiedName }, - ]) }) try { @@ -345,6 +339,10 @@ export async function copyKnowledgeBaseToWorkspace( await enqueueDocumentProcessingJobs(processingJobs, requestId) } + await notifyEntityListMembersUpserted('knowledge_base', targetWorkspaceId, [ + { id: copied.id, name: copied.name }, + ]) + logger.info( `[${requestId}] Copied knowledge base ${sourceKnowledgeBaseId} to workspace ${targetWorkspaceId} as ${newKnowledgeBaseId}` ) diff --git a/apps/tradinggoose/lib/skills/operations.test.ts b/apps/tradinggoose/lib/skills/operations.test.ts index 8c8f3b6fa..02b066471 100644 --- a/apps/tradinggoose/lib/skills/operations.test.ts +++ b/apps/tradinggoose/lib/skills/operations.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockTransaction, mockNanoid } = vi.hoisted(() => ({ +const { mockTransaction, mockNanoid, mockNotifyEntityListMembersUpserted } = vi.hoisted(() => ({ mockTransaction: vi.fn(), mockNanoid: vi.fn(), + mockNotifyEntityListMembersUpserted: vi.fn(), })) vi.mock('@tradinggoose/db', () => ({ @@ -37,6 +38,12 @@ vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ readBootstrappedSavedEntityListFields: vi.fn(), })) +vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ + deleteYjsSessionInSocketServer: vi.fn(), + notifyEntityListMemberRemoved: vi.fn(), + notifyEntityListMembersUpserted: mockNotifyEntityListMembersUpserted, +})) + import { importSkills } from '@/lib/skills/operations' const createQueryChain = (result: unknown) => { diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index a1b1b7dc8..51bc55ef6 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -136,15 +136,15 @@ export async function createSkills({ } const createdSkills = await tx.insert(skill).values(insertValues).returning() - await notifyEntityListMembersUpserted( - 'skill', - workspaceId, - createdSkills.map((createdSkill) => ({ id: createdSkill.id, name: createdSkill.name })) - ) - logger.info(`[${requestId}] Created ${createdSkills.length} skill(s)`) return createdSkills }) + await notifyEntityListMembersUpserted( + 'skill', + workspaceId, + created.map((createdSkill) => ({ id: createdSkill.id, name: createdSkill.name })) + ) + logger.info(`[${requestId}] Created ${created.length} skill(s)`) return created } @@ -217,16 +217,6 @@ export async function importSkills({ }) const persistedSkills = await tx.insert(skill).values(insertValues).returning() - await notifyEntityListMembersUpserted( - 'skill', - workspaceId, - persistedSkills.map((importedSkill) => ({ id: importedSkill.id, name: importedSkill.name })) - ) - - logger.info(`[${requestId}] Imported ${persistedSkills.length} skill(s)`, { - workspaceId, - renamedCount, - }) return { skills: persistedSkills, @@ -236,5 +226,14 @@ export async function importSkills({ } }) + await notifyEntityListMembersUpserted( + 'skill', + workspaceId, + result.skills.map((importedSkill) => ({ id: importedSkill.id, name: importedSkill.name })) + ) + logger.info(`[${requestId}] Imported ${result.skills.length} skill(s)`, { + workspaceId, + renamedCount: result.renamedCount, + }) return result } diff --git a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts index 7fdf4a6db..d0f032c25 100644 --- a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts +++ b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts @@ -4,7 +4,7 @@ import type { ReviewTargetRuntimeState, } from '@/lib/copilot/review-sessions/types' import { env, getInternalRealtimeUrl } from '@/lib/env' -import type { SavedEntityKind } from '@/lib/yjs/entity-state' +import { type SavedEntityKind, SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' import type { WorkflowMetadataPatch, WorkflowSnapshot } from '@/lib/yjs/workflow-session' export interface YjsSnapshotResponse { @@ -157,10 +157,17 @@ async function postEntityListMembersToSocketServer( body: unknown ): Promise { const descriptor = buildEntityListDescriptor(entityKind, workspaceId) - await postJsonToSocketServer( - `/internal/yjs/sessions/${encodeURIComponent(descriptor.yjsSessionId)}/members`, - body - ) + try { + await postJsonToSocketServer( + `/internal/yjs/sessions/${encodeURIComponent(descriptor.yjsSessionId)}/members`, + body + ) + } catch (error) { + if (error instanceof SocketServerBridgeError && error.status < 500) { + throw error + } + throw new SavedEntityRealtimeRequiredError() + } } export async function notifyEntityListMembersUpserted( From 5c35844e39814c2c2456ec812f94d5f8d9a5441a Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 18:53:22 -0600 Subject: [PATCH 243/284] refactor(mcp): centralize workspace server creation Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../tradinggoose/app/api/mcp/servers/route.ts | 55 ++++----------- .../tools/server/entities/mcp-server.ts | 50 +------------- apps/tradinggoose/lib/mcp/service.ts | 67 +++++++++++++++++++ 3 files changed, 82 insertions(+), 90 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 3318e4a73..b1cd8b7e1 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -2,17 +2,15 @@ import { db } from '@tradinggoose/db' import { mcpServers } from '@tradinggoose/db/schema' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { normalizeEntityFields } from '@/lib/copilot/entity-documents' import { createLogger } from '@/lib/logs/console/logger' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpService } from '@/lib/mcp/service' +import { McpServerConfigError, mcpService } from '@/lib/mcp/service' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' import { readBootstrappedEntityListMembers } from '@/lib/yjs/server/bootstrap-review-target' import { deleteYjsSessionInSocketServer, notifyEntityListMemberRemoved, - notifyEntityListMembersUpserted, } from '@/lib/yjs/server/snapshot-bridge' import { CreateMcpServerSchema } from './schema' @@ -108,61 +106,32 @@ export const POST = withMcpAuth('write')( workspaceId, }) - let fields: Record - try { - fields = normalizeEntityFields('mcp_server', body) - } catch (error) { - return createMcpErrorResponse( - error instanceof Error ? error : new Error('Invalid MCP server fields'), - 'Invalid MCP server fields', - 400 - ) - } - - const serverId = crypto.randomUUID() - - await db.insert(mcpServers).values({ - id: serverId, + const created = await mcpService.createWorkspaceServer({ + userId, workspaceId, - createdBy: userId, - name: String(fields.name ?? ''), - description: String(fields.description ?? '') || null, - transport: String(fields.transport ?? ''), - url: String(fields.url ?? '') || null, - headers: fields.headers, - command: String(fields.command ?? '') || null, - args: Array.isArray(fields.args) ? fields.args.map(String) : [], - env: fields.env, - timeout: Number(fields.timeout ?? 30000), - retries: Number(fields.retries ?? 3), - enabled: fields.enabled !== false, - createdAt: new Date(), - updatedAt: new Date(), + fields: body, }) - await notifyEntityListMembersUpserted('mcp_server', workspaceId, [ - { id: serverId, name: String(fields.name ?? ''), enabled: fields.enabled !== false }, - ]) - - mcpService.clearCache(workspaceId) - - logger.info(`[${requestId}] Successfully registered MCP server: ${fields.name}`) + logger.info(`[${requestId}] Successfully registered MCP server: ${created.fields.name}`) // Track MCP server registration try { const { trackPlatformEvent } = await import('@/lib/telemetry/tracer') trackPlatformEvent('platform.mcp.server_added', { - 'mcp.server_id': serverId, - 'mcp.server_name': String(fields.name ?? ''), - 'mcp.transport': String(fields.transport ?? ''), + 'mcp.server_id': created.entityId, + 'mcp.server_name': String(created.fields.name ?? ''), + 'mcp.transport': String(created.fields.transport ?? ''), 'workspace.id': workspaceId, }) } catch (_e) { // Silently fail } - return createMcpSuccessResponse({ serverId }, 201) + return createMcpSuccessResponse({ serverId: created.entityId }, 201) } catch (error) { + if (error instanceof McpServerConfigError) { + return createMcpErrorResponse(error, error.message, error.status) + } if (error instanceof SavedEntityRealtimeRequiredError) { return createMcpErrorResponse(error, error.message, error.status) } diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts index 5a50779d1..469922be7 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -1,6 +1,3 @@ -import { db } from '@tradinggoose/db' -import { mcpServers } from '@tradinggoose/db/schema' -import { eq } from 'drizzle-orm' import { ENTITY_SECRET_PLACEHOLDER, normalizeEntityFields, @@ -9,10 +6,7 @@ import { import { ENTITY_KIND_MCP_SERVER } from '@/lib/copilot/review-sessions/types' import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' import { mcpService } from '@/lib/mcp/service' -import type { McpTransport } from '@/lib/mcp/types' -import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' -import { notifyEntityListMembersUpserted } from '@/lib/yjs/server/snapshot-bridge' import { buildDocumentEnvelope, buildSavedEntityListInfo, @@ -98,49 +92,11 @@ async function createMcpServerEntity( context: Parameters[0] ): Promise { const { userId, workspaceId } = await verifyWorkspaceContext(context, 'write') - const entityId = crypto.randomUUID() - const normalized = normalizeMcpServerDocumentFields(fields) - - const [row] = await db - .insert(mcpServers) - .values({ - id: entityId, - workspaceId, - createdBy: userId, - name: String(normalized.name ?? ''), - description: String(normalized.description ?? '') || null, - transport: normalized.transport as McpTransport, - url: String(normalized.url ?? '') || null, - headers: normalizeStringRecord(normalized.headers), - command: String(normalized.command ?? '') || null, - args: Array.isArray(normalized.args) ? normalized.args.map(String) : [], - env: normalizeStringRecord(normalized.env), - timeout: Number(normalized.timeout ?? 30000), - retries: Number(normalized.retries ?? 3), - enabled: normalized.enabled !== false, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() - - if (!row) { - throw new Error('Created MCP server was not returned from canonical insert') - } - - const savedFields = savedEntityRowToFields(ENTITY_KIND_MCP_SERVER, row) - try { - await notifyEntityListMembersUpserted('mcp_server', workspaceId, [ - { id: entityId, name: String(normalized.name ?? ''), enabled: normalized.enabled !== false }, - ]) - } catch (error) { - await db.delete(mcpServers).where(eq(mcpServers.id, entityId)) - throw error - } - mcpService.clearCache(workspaceId) + const created = await mcpService.createWorkspaceServer({ userId, workspaceId, fields }) return { - entityId, - fields: savedFields, + entityId: created.entityId, + fields: created.fields, } } diff --git a/apps/tradinggoose/lib/mcp/service.ts b/apps/tradinggoose/lib/mcp/service.ts index 6a264302f..e4cb720e0 100644 --- a/apps/tradinggoose/lib/mcp/service.ts +++ b/apps/tradinggoose/lib/mcp/service.ts @@ -16,11 +16,13 @@ import type { } from '@/lib/mcp/types' import { MCP_CONSTANTS } from '@/lib/mcp/utils' import { generateRequestId } from '@/lib/utils' +import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { ReviewTargetBootstrapError, readBootstrappedSavedEntityFields, readBootstrappedSavedEntityListFields, } from '@/lib/yjs/server/bootstrap-review-target' +import { notifyEntityListMembersUpserted } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('McpService') @@ -33,6 +35,15 @@ export class McpServerNotFoundError extends Error { } } +export class McpServerConfigError extends Error { + readonly status = 400 + + constructor(message: string) { + super(message) + this.name = 'McpServerConfigError' + } +} + interface ToolCache { tools: McpTool[] expiry: Date @@ -261,6 +272,62 @@ class McpService { } } + async createWorkspaceServer(input: { + userId: string + workspaceId: string + fields: Record + }): Promise<{ entityId: string; fields: Record }> { + let normalized: Record + try { + normalized = normalizeEntityFields('mcp_server', input.fields) + } catch (error) { + throw new McpServerConfigError(error instanceof Error ? error.message : 'Invalid MCP server') + } + + const entityId = crypto.randomUUID() + const [row] = await db + .insert(mcpServers) + .values({ + id: entityId, + workspaceId: input.workspaceId, + createdBy: input.userId, + name: String(normalized.name ?? ''), + description: String(normalized.description ?? '') || null, + transport: normalized.transport as McpTransport, + url: String(normalized.url ?? '') || null, + headers: normalized.headers, + command: String(normalized.command ?? '') || null, + args: Array.isArray(normalized.args) ? normalized.args.map(String) : [], + env: normalized.env, + timeout: Number(normalized.timeout ?? 30000), + retries: Number(normalized.retries ?? 3), + enabled: normalized.enabled !== false, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning() + + if (!row) { + throw new Error('Created MCP server was not returned from canonical insert') + } + + try { + await notifyEntityListMembersUpserted('mcp_server', input.workspaceId, [ + { + id: entityId, + name: String(normalized.name ?? ''), + enabled: normalized.enabled !== false, + }, + ]) + } catch (error) { + await db.delete(mcpServers).where(eq(mcpServers.id, entityId)) + throw error + } + + this.clearCache(input.workspaceId) + return { entityId, fields: savedEntityRowToFields('mcp_server', row) } + } + private async getServerConfig( serverId: string, workspaceId: string From 124942b5e905c29642bfa01669c99384ecff88ec Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 18:53:46 -0600 Subject: [PATCH 244/284] fix(mcp): refresh tool cache when server metadata changes Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts | 1 - apps/tradinggoose/app/api/mcp/servers/route.ts | 2 ++ apps/tradinggoose/hooks/use-mcp-tools.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts index 069bba8f5..9347d1d5c 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts @@ -84,7 +84,6 @@ export const POST = withMcpAuth('read')( lastError, lastConnected, toolCount, - updatedAt: now, }) .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index b1cd8b7e1..b617d2040 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -34,6 +34,7 @@ export const GET = withMcpAuth('read')( id: mcpServers.id, name: mcpServers.name, enabled: mcpServers.enabled, + updatedAt: mcpServers.updatedAt, connectionStatus: mcpServers.connectionStatus, lastError: mcpServers.lastError, toolCount: mcpServers.toolCount, @@ -55,6 +56,7 @@ export const GET = withMcpAuth('read')( name: status.name, enabled: status.enabled !== false, workspaceId, + updatedAt: status.updatedAt?.toISOString(), connectionStatus: status.connectionStatus, lastError: status.lastError, toolCount: status.toolCount, diff --git a/apps/tradinggoose/hooks/use-mcp-tools.ts b/apps/tradinggoose/hooks/use-mcp-tools.ts index 61e003182..9bb966d16 100644 --- a/apps/tradinggoose/hooks/use-mcp-tools.ts +++ b/apps/tradinggoose/hooks/use-mcp-tools.ts @@ -50,7 +50,7 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { const serversFingerprint = useMemo(() => { return servers .filter((s) => !s.deletedAt) - .map((s) => `${s.id}:${s.enabled !== false ? '1' : '0'}`) + .map((s) => `${s.id}:${s.enabled !== false ? '1' : '0'}:${s.updatedAt ?? ''}`) .sort() .join('|') }, [servers]) From 919938098367eac1e2ef462487d2af8266ae07e5 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 18:54:09 -0600 Subject: [PATCH 245/284] fix(workflow): debounce live Yjs persistence Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/workflows/db-helpers.ts | 11 ++++- .../socket-server/yjs/upstream-utils.ts | 49 +++++++++++++++++-- .../socket-server/yjs/ws-handler.ts | 2 + 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 3eee6d7cf..aaf74cb6a 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -15,7 +15,8 @@ import { reconcilePublishedChatsForDeploymentTx } from '@/lib/chat/published-dep import { createLogger } from '@/lib/logs/console/logger' import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' import { inferWorkflowDirectionFromState } from '@/lib/workflows/workflow-direction' -import { extractPersistedStateFromDoc } from '@/lib/yjs/workflow-session' +import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' +import { extractPersistedStateFromDoc, setWorkflowState } from '@/lib/yjs/workflow-session' import type { BlockState, Loop, @@ -898,6 +899,14 @@ export async function saveWorkflowYjsDocToDb(workflowId: string, doc: Y.Doc): Pr if (!saveResult.success) { throw new Error(saveResult.error || 'Failed to materialize workflow Yjs state') } + + if (saveResult.normalizedState) { + setWorkflowState( + doc, + { ...saveResult.normalizedState, lastSaved: syncedAt.toISOString() }, + YJS_ORIGINS.SYSTEM + ) + } } /** diff --git a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts index 4b75b2d23..8de512ade 100644 --- a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts +++ b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts @@ -15,6 +15,7 @@ import * as encoding from 'lib0/encoding' import * as map from 'lib0/map' import type { WebSocket } from 'ws' import * as Y from 'yjs' +import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' const messageSync = 0 const messageAwareness = 1 @@ -34,9 +35,11 @@ class WSSharedDoc extends Y.Doc { whenInitialized: Promise onDocumentIdle?: DocumentPersistenceHandler onDocumentUpdate?: DocumentPersistenceHandler + onDocumentUpdateDebounceMs = 0 hasUnsavedChanges = false isPersisting = false needsPersist = false + persistTimer: ReturnType | null = null constructor(name: string, gc: boolean) { super({ gc }) @@ -72,9 +75,11 @@ class WSSharedDoc extends Y.Doc { } ) - this.on('update', (update: Uint8Array, _origin: unknown) => { - this.hasUnsavedChanges = true - scheduleDocumentPersistence(this) + this.on('update', (update: Uint8Array, origin: unknown) => { + if (origin !== YJS_ORIGINS.SYSTEM) { + this.hasUnsavedChanges = true + scheduleDocumentPersistence(this) + } const encoder = encoding.createEncoder() encoding.writeVarUint(encoder, messageSync) syncProtocol.writeUpdate(encoder, update) @@ -87,6 +92,28 @@ class WSSharedDoc extends Y.Doc { } function scheduleDocumentPersistence(doc: WSSharedDoc): void { + if (doc.persistTimer) { + clearTimeout(doc.persistTimer) + doc.persistTimer = null + } + + if (doc.isPersisting) { + doc.needsPersist = true + return + } + + if (doc.onDocumentUpdateDebounceMs > 0) { + doc.persistTimer = setTimeout(() => { + doc.persistTimer = null + runDocumentPersistence(doc) + }, doc.onDocumentUpdateDebounceMs) + return + } + + runDocumentPersistence(doc) +} + +function runDocumentPersistence(doc: WSSharedDoc): void { const persist = doc.onDocumentUpdate if (!persist) { return @@ -126,6 +153,11 @@ function cleanupDocument(doc: WSSharedDoc): void { } function finalizeDocumentCleanup(doc: WSSharedDoc): void { + if (doc.persistTimer) { + clearTimeout(doc.persistTimer) + doc.persistTimer = null + } + if (!doc.onDocumentIdle || !doc.hasUnsavedChanges) { cleanupDocument(doc) return @@ -246,9 +278,17 @@ export function setupWSConnection( bootstrapState?: Uint8Array onDocumentIdle?: DocumentPersistenceHandler onDocumentUpdate?: DocumentPersistenceHandler + onDocumentUpdateDebounceMs?: number } ): void { - const { docId, gc = true, bootstrapState, onDocumentIdle, onDocumentUpdate } = opts + const { + docId, + gc = true, + bootstrapState, + onDocumentIdle, + onDocumentUpdate, + onDocumentUpdateDebounceMs, + } = opts conn.binaryType = 'arraybuffer' @@ -256,6 +296,7 @@ export function setupWSConnection( doc.onDocumentIdle = onDocumentIdle if (onDocumentUpdate) { doc.onDocumentUpdate = onDocumentUpdate + doc.onDocumentUpdateDebounceMs = onDocumentUpdateDebounceMs ?? 0 } doc.conns.set(conn, new Set()) diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.ts index 1d9909cb6..ac1e665a8 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.ts @@ -20,6 +20,7 @@ import { authenticateYjsConnection, YjsAuthError } from './auth' import { getExistingDocument, setupWSConnection } from './upstream-utils' const logger = createLogger('YjsWsHandler') +const WORKFLOW_LIVE_PERSIST_DEBOUNCE_MS = 1500 interface YjsIncomingMessage extends IncomingMessage { yjsSessionId?: string @@ -195,6 +196,7 @@ function ensureConnectionHandler(wss: WebSocketServer): void { bootstrapState: yjsReq.yjsBootstrapState, onDocumentIdle: persistWorkflowDocument, onDocumentUpdate: yjsReq.yjsPersistLiveUpdates ? persistWorkflowDocument : undefined, + onDocumentUpdateDebounceMs: WORKFLOW_LIVE_PERSIST_DEBOUNCE_MS, }) } catch (error) { logger.error('Failed to attach Yjs connection', { docId, error }) From 2de8e0c65952641662f6d1698b0431a829e5ad24 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 19:22:55 -0600 Subject: [PATCH 246/284] fix(copilot): enforce workspace-scoped review targets Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../review-sessions/permissions.test.ts | 27 ++++++++ .../copilot/review-sessions/permissions.ts | 11 ++- .../copilot/tools/server/entities/shared.ts | 2 +- .../server/entities/workflow-variable.test.ts | 8 +-- .../copilot/tools/server/entities/workflow.ts | 12 +++- .../copilot/tools/server/review-acceptance.ts | 6 ++ apps/tradinggoose/lib/mcp/service.ts | 67 +++++++------------ .../lib/yjs/server/bootstrap-review-target.ts | 5 +- 8 files changed, 86 insertions(+), 52 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/review-sessions/permissions.test.ts b/apps/tradinggoose/lib/copilot/review-sessions/permissions.test.ts index c7f7c36dd..b38eee5b5 100644 --- a/apps/tradinggoose/lib/copilot/review-sessions/permissions.test.ts +++ b/apps/tradinggoose/lib/copilot/review-sessions/permissions.test.ts @@ -146,6 +146,33 @@ describe('review session permissions', () => { }) }) + it('rejects workflow targets when the supplied workspace does not match the workflow', async () => { + mockReadWorkflowAccessContext.mockResolvedValueOnce({ + workflow: { + id: 'workflow-1', + userId: 'member-1', + workspaceId: 'workspace-actual', + } as NonNullable>>['workflow'], + workspaceOwnerId: 'owner-1', + workspacePermission: 'write', + isOwner: false, + isWorkspaceOwner: false, + }) + + const result = await verifyReviewTargetAccess( + 'collaborator-1', + { entityKind: 'workflow', entityId: 'workflow-1', workspaceId: 'workspace-supplied' }, + 'read' + ) + + expect(result).toEqual({ + hasAccess: false, + userPermission: null, + workspaceId: null, + isOwner: false, + }) + }) + it('rejects review-session targets that carry entity ids', async () => { const reviewSessionRow = [ { diff --git a/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts b/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts index 5b1a0ec2f..c3c173b9d 100644 --- a/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts +++ b/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts @@ -271,7 +271,16 @@ export async function verifyReviewTargetAccess( return { hasAccess: false, userPermission: null, workspaceId: null, isOwner: false } } - return verifyWorkflowAccess(userId, reviewTarget.entityId, accessMode) + const access = await verifyWorkflowAccess(userId, reviewTarget.entityId, accessMode) + if (reviewTarget.workspaceId && reviewTarget.workspaceId !== access.workspaceId) { + logger.warn('Workflow workspace mismatch', { + userId, + workflowId: reviewTarget.entityId, + }) + return { hasAccess: false, userPermission: null, workspaceId: null, isOwner: false } + } + + return access } if (reviewTarget.yjsSessionId && isEntityListSessionId(reviewTarget.yjsSessionId)) { diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index 09d89490e..0b97999d6 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -123,7 +123,7 @@ export async function verifySavedEntityContext( const userId = requireUserId(context) const access = await verifyReviewTargetAccess( userId, - buildSavedEntityDescriptor(entityKind, entityId, null), + buildSavedEntityDescriptor(entityKind, entityId, context?.workspaceId?.trim() ?? null), accessMode ) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts index 1063ba81f..807a016bd 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts @@ -9,7 +9,7 @@ import { createWorkflowSnapshot, setVariables, setWorkflowState } from '@/lib/yj const mockDbLimit = vi.hoisted(() => vi.fn()) const mockReadBootstrappedReviewTargetSnapshot = vi.hoisted(() => vi.fn()) -const mockVerifyWorkflowAccess = vi.hoisted(() => vi.fn()) +const mockVerifyReviewTargetAccess = vi.hoisted(() => vi.fn()) const mockApplyWorkflowState = vi.hoisted(() => vi.fn()) const mockApplyWorkflowPatchInSocketServer = vi.hoisted(() => vi.fn()) @@ -26,7 +26,7 @@ vi.mock('@tradinggoose/db', () => ({ })) vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ - verifyWorkflowAccess: (...args: any[]) => mockVerifyWorkflowAccess(...args), + verifyReviewTargetAccess: (...args: any[]) => mockVerifyReviewTargetAccess(...args), })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ @@ -62,7 +62,7 @@ describe('workflow variable server tools', () => { vi.unstubAllGlobals() mockDbLimit.mockReset() mockReadBootstrappedReviewTargetSnapshot.mockReset() - mockVerifyWorkflowAccess.mockReset() + mockVerifyReviewTargetAccess.mockReset() mockApplyWorkflowState.mockReset() mockApplyWorkflowPatchInSocketServer.mockReset() mockDbLimit.mockResolvedValue([ @@ -72,7 +72,7 @@ describe('workflow variable server tools', () => { workspaceId: 'workspace-1', }, ]) - mockVerifyWorkflowAccess.mockResolvedValue({ + mockVerifyReviewTargetAccess.mockResolvedValue({ hasAccess: true, workspaceId: 'workspace-1', }) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index 8a123377b..2e95de727 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -5,7 +5,7 @@ import * as Y from 'yjs' import { z } from 'zod' import { getStableVibrantColor } from '@/lib/colors' import { WORKFLOW_VARIABLE_DOCUMENT_FORMAT } from '@/lib/copilot/entity-documents' -import { verifyWorkflowAccess } from '@/lib/copilot/review-sessions/permissions' +import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' import { ENTITY_KIND_WORKFLOW, type ReviewAccessMode } from '@/lib/copilot/review-sessions/types' import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target' import type { @@ -188,7 +188,15 @@ async function verifyWorkflowContext( accessMode: ReviewAccessMode ) { const userId = requireUserId(context) - const access = await verifyWorkflowAccess(userId, workflowId, accessMode) + const access = await verifyReviewTargetAccess( + userId, + { + workspaceId: context?.workspaceId?.trim() ?? null, + entityKind: ENTITY_KIND_WORKFLOW, + entityId: workflowId, + }, + accessMode + ) if (!access.hasAccess) { throw new Error( `Access denied: You do not have permission to ${accessMode === 'write' ? 'edit' : 'read'} this workflow` diff --git a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts index af6788458..1191c42ea 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts @@ -8,6 +8,7 @@ import { type ServerToolExecutionContext, } from '@/lib/copilot/tools/server/base-tool' import { routeExecution } from '@/lib/copilot/tools/server/router' +import { normalizeOptionalString } from '@/lib/utils' import { decryptSecret, encryptSecret } from '@/lib/utils-server' const REVIEW_TOKEN_PREFIX = 'copilot-tool-review:' @@ -139,6 +140,11 @@ export async function acceptServerManagedToolReview( if (!staged.executionContext || typeof staged.executionContext !== 'object') { throw new Error('Server tool review token does not match this request') } + const requestWorkspaceId = normalizeOptionalString(context.workspaceId) + const stagedWorkspaceId = normalizeOptionalString(staged.executionContext.workspaceId) + if (requestWorkspaceId && requestWorkspaceId !== stagedWorkspaceId) { + throw new Error('workspaceId does not match execution context') + } const { decrypted } = await decryptSecret(staged.encryptedPayload) const payload = JSON.parse(decrypted) const executionContext = { diff --git a/apps/tradinggoose/lib/mcp/service.ts b/apps/tradinggoose/lib/mcp/service.ts index e4cb720e0..735efd7e2 100644 --- a/apps/tradinggoose/lib/mcp/service.ts +++ b/apps/tradinggoose/lib/mcp/service.ts @@ -17,11 +17,6 @@ import type { import { MCP_CONSTANTS } from '@/lib/mcp/utils' import { generateRequestId } from '@/lib/utils' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' -import { - ReviewTargetBootstrapError, - readBootstrappedSavedEntityFields, - readBootstrappedSavedEntityListFields, -} from '@/lib/yjs/server/bootstrap-review-target' import { notifyEntityListMembersUpserted } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('McpService') @@ -332,51 +327,37 @@ class McpService { serverId: string, workspaceId: string ): Promise { - try { - const [server] = await db - .select({ id: mcpServers.id }) - .from(mcpServers) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) + const [server] = await db + .select() + .from(mcpServers) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) ) - .limit(1) - if (!server) { - return null - } - - const fields = normalizeEntityFields( - 'mcp_server', - await readBootstrappedSavedEntityFields('mcp_server', serverId, workspaceId) ) - return fields.enabled === false ? null : this.toServerConfig(serverId, fields) - } catch (error) { - if (error instanceof ReviewTargetBootstrapError && error.status === 404) { - return null - } - throw error + .limit(1) + if (!server) { + return null } + + const fields = normalizeEntityFields('mcp_server', savedEntityRowToFields('mcp_server', server)) + return fields.enabled === false ? null : this.toServerConfig(serverId, fields) } private async getWorkspaceServers(workspaceId: string): Promise { - const activeServerIds = new Set( - ( - await db - .select({ id: mcpServers.id }) - .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) - ).map((server) => server.id) - ) - const servers = await readBootstrappedSavedEntityListFields('mcp_server', workspaceId) + const servers = await db + .select() + .from(mcpServers) + .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) + return servers.flatMap((server) => { - if (!activeServerIds.has(server.entityId)) { - return [] - } - const normalized = normalizeEntityFields('mcp_server', server.fields) - return normalized.enabled === false ? [] : [this.toServerConfig(server.entityId, normalized)] + const fields = normalizeEntityFields( + 'mcp_server', + savedEntityRowToFields('mcp_server', server) + ) + return fields.enabled === false ? [] : [this.toServerConfig(server.id, fields)] }) } diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 5c47f0636..65fb65abf 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -84,7 +84,10 @@ export async function readBootstrappedEntityListMembers( const doc = new Y.Doc() try { Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) - return getEntityListMembers(doc) + const activeEntityIds = new Set( + (await readEntityListMembersFromDb(entityKind, workspaceId)).map((member) => member.id) + ) + return getEntityListMembers(doc).filter((member) => activeEntityIds.has(member.entityId)) } finally { doc.destroy() } From 7c9e9568337bc97ec53019c50b8aa7514e0aa7ef Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 19:58:12 -0600 Subject: [PATCH 247/284] fix(yjs): rollback created members on publish failure Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../tradinggoose/app/api/mcp/servers/route.ts | 4 +-- .../tools/server/entities/indicator.test.ts | 2 +- .../tools/server/entities/shared.test.ts | 2 +- .../copilot/tools/server/entities/shared.ts | 4 +-- .../lib/custom-tools/operations.ts | 14 +++++---- .../lib/indicators/custom/operations.ts | 21 +++++++------ apps/tradinggoose/lib/knowledge/service.ts | 27 +++++++++++------ apps/tradinggoose/lib/mcp/service.ts | 21 +++++-------- .../lib/skills/operations.test.ts | 3 +- apps/tradinggoose/lib/skills/operations.ts | 14 +++++---- .../lib/yjs/server/apply-entity-state.ts | 30 +++++++++++++++++-- .../lib/yjs/server/bootstrap-review-target.ts | 6 ++-- 12 files changed, 91 insertions(+), 57 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index b617d2040..6b5c2f778 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -7,7 +7,7 @@ import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { McpServerConfigError, mcpService } from '@/lib/mcp/service' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' -import { readBootstrappedEntityListMembers } from '@/lib/yjs/server/bootstrap-review-target' +import { requireSavedEntityListMembers } from '@/lib/yjs/server/bootstrap-review-target' import { deleteYjsSessionInSocketServer, notifyEntityListMemberRemoved, @@ -26,7 +26,7 @@ export const GET = withMcpAuth('read')( try { logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) - const listMembers = await readBootstrappedEntityListMembers('mcp_server', workspaceId) + const listMembers = await requireSavedEntityListMembers('mcp_server', workspaceId) const statusById = new Map( ( await db diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts index f6357afe5..626426e3b 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts @@ -17,7 +17,7 @@ vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ - readBootstrappedEntityListMembers: (...args: unknown[]) => + requireSavedEntityListMembers: (...args: unknown[]) => mockReadBootstrappedEntityListMembers(...args), readBootstrappedSavedEntityFields: (...args: unknown[]) => mockReadBootstrappedSavedEntityFields(...args), diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts index c1e8c2364..2171aeb52 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts @@ -33,7 +33,7 @@ vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ - readBootstrappedEntityListMembers: (...args: unknown[]) => + requireSavedEntityListMembers: (...args: unknown[]) => mockReadBootstrappedEntityListMembers(...args), readBootstrappedSavedEntityFields: (...args: unknown[]) => mockReadBootstrappedSavedEntityFields(...args), diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index 0b97999d6..1bac819f9 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -21,8 +21,8 @@ import { checkWorkspaceAccess } from '@/lib/permissions/utils' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' import { - readBootstrappedEntityListMembers, readBootstrappedSavedEntityFields, + requireSavedEntityListMembers, } from '@/lib/yjs/server/bootstrap-review-target' export type SavedEntityDocumentKind = EntityDocumentKind @@ -205,7 +205,7 @@ export function buildSavedEntityListInfo( entityKind: SavedEntityKind, workspaceId: string ): Promise { - return readBootstrappedEntityListMembers(entityKind, workspaceId) + return requireSavedEntityListMembers(entityKind, workspaceId) } async function hashCreateEntityReviewBase( diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index c9222185f..791a7f6ee 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -9,9 +9,11 @@ import { import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' -import { readBootstrappedSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' -import { notifyEntityListMembersUpserted } from '@/lib/yjs/server/snapshot-bridge' +import { + applySavedEntityState, + publishCreatedSavedEntityListMembers, +} from '@/lib/yjs/server/apply-entity-state' +import { requireSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' const logger = createLogger('CustomToolsOperations') @@ -45,7 +47,7 @@ interface ImportCustomToolsParams { } export async function listCustomTools(params: { workspaceId: string }) { - const entries = await readBootstrappedSavedEntityListFields('custom_tool', params.workspaceId) + const entries = await requireSavedEntityListFields('custom_tool', params.workspaceId) return entries.map(({ entityId, fields }) => ({ id: entityId, workspaceId: params.workspaceId, @@ -104,7 +106,7 @@ export async function createCustomTools({ return createdTools }) - await notifyEntityListMembersUpserted( + await publishCreatedSavedEntityListMembers( 'custom_tool', workspaceId, created.map((createdTool) => ({ id: createdTool.id, name: createdTool.title })) @@ -178,7 +180,7 @@ export async function importCustomTools({ } }) - await notifyEntityListMembersUpserted( + await publishCreatedSavedEntityListMembers( 'custom_tool', workspaceId, result.tools.map((importedTool) => ({ id: importedTool.id, name: importedTool.title })) diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index 96ed35799..530ee1cc1 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -9,14 +9,16 @@ import { import { inferInputMetaFromPineCode, normalizeInputMetaMap } from '@/lib/indicators/input-meta' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' -import { readBootstrappedSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' -import { notifyEntityListMembersUpserted } from '@/lib/yjs/server/snapshot-bridge' +import { + applySavedEntityState, + publishCreatedSavedEntityListMembers, +} from '@/lib/yjs/server/apply-entity-state' +import { requireSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' const logger = createLogger('IndicatorsOperations') export async function listCustomIndicatorRuntimeEntries(workspaceId: string) { - const entries = await readBootstrappedSavedEntityListFields('indicator', workspaceId) + const entries = await requireSavedEntityListFields('indicator', workspaceId) return entries.map(({ entityId, fields }) => ({ id: entityId, pineCode: String(fields.pineCode ?? ''), @@ -25,7 +27,7 @@ export async function listCustomIndicatorRuntimeEntries(workspaceId: string) { } export async function listIndicators(params: { workspaceId: string }) { - const entries = await readBootstrappedSavedEntityListFields('indicator', params.workspaceId) + const entries = await requireSavedEntityListFields('indicator', params.workspaceId) return entries.map(({ entityId, fields }) => ({ id: entityId, workspaceId: params.workspaceId, @@ -98,13 +100,10 @@ export async function createIndicators({ return createdIndicators }) - await notifyEntityListMembersUpserted( + await publishCreatedSavedEntityListMembers( 'indicator', workspaceId, - created.map((createdIndicator) => ({ - id: createdIndicator.id, - name: createdIndicator.name, - })) + created.map((createdIndicator) => ({ id: createdIndicator.id, name: createdIndicator.name })) ) logger.info(`[${requestId}] Created ${created.length} indicator(s)`) return created @@ -185,7 +184,7 @@ export async function importIndicators({ } }) - await notifyEntityListMembersUpserted( + await publishCreatedSavedEntityListMembers( 'indicator', workspaceId, result.indicators.map((imported) => ({ id: imported.id, name: imported.name })) diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index acdae2860..dc5353eed 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -21,12 +21,14 @@ import type { } from '@/lib/knowledge/types' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/permissions/utils' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' -import { readBootstrappedSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' +import { + applySavedEntityState, + publishCreatedSavedEntityListMembers, +} from '@/lib/yjs/server/apply-entity-state' +import { requireSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' import { deleteYjsSessionInSocketServer, notifyEntityListMemberRemoved, - notifyEntityListMembersUpserted, } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('KnowledgeBaseService') @@ -43,7 +45,7 @@ export async function getKnowledgeBases( return [] } - const entries = await readBootstrappedSavedEntityListFields('knowledge_base', workspaceId) + const entries = await requireSavedEntityListFields('knowledge_base', workspaceId) return entries.map(({ entityId, fields }) => ({ id: entityId, name: String(fields.name ?? ''), @@ -101,7 +103,7 @@ export async function createKnowledgeBase( } await db.insert(knowledgeBase).values(newKnowledgeBase) - await notifyEntityListMembersUpserted('knowledge_base', data.workspaceId, [ + await publishCreatedSavedEntityListMembers('knowledge_base', data.workspaceId, [ { id: created.id, name: created.name }, ]) @@ -327,6 +329,17 @@ export async function copyKnowledgeBaseToWorkspace( docCount: sourceDocuments.length, } + await publishCreatedSavedEntityListMembers( + 'knowledge_base', + targetWorkspaceId, + [{ id: copied.id, name: copied.name }], + async () => { + if (copiedDocuments.length > 0) { + await deleteKnowledgeDocumentFiles(copiedDocuments.map(({ fileUrl }) => fileUrl)) + } + } + ) + if (totalDocumentSize > 0) { try { await incrementStorageUsage(userId, totalDocumentSize, targetWorkspaceId) @@ -339,10 +352,6 @@ export async function copyKnowledgeBaseToWorkspace( await enqueueDocumentProcessingJobs(processingJobs, requestId) } - await notifyEntityListMembersUpserted('knowledge_base', targetWorkspaceId, [ - { id: copied.id, name: copied.name }, - ]) - logger.info( `[${requestId}] Copied knowledge base ${sourceKnowledgeBaseId} to workspace ${targetWorkspaceId} as ${newKnowledgeBaseId}` ) diff --git a/apps/tradinggoose/lib/mcp/service.ts b/apps/tradinggoose/lib/mcp/service.ts index 735efd7e2..efbb4b415 100644 --- a/apps/tradinggoose/lib/mcp/service.ts +++ b/apps/tradinggoose/lib/mcp/service.ts @@ -17,7 +17,7 @@ import type { import { MCP_CONSTANTS } from '@/lib/mcp/utils' import { generateRequestId } from '@/lib/utils' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' -import { notifyEntityListMembersUpserted } from '@/lib/yjs/server/snapshot-bridge' +import { publishCreatedSavedEntityListMembers } from '@/lib/yjs/server/apply-entity-state' const logger = createLogger('McpService') @@ -306,18 +306,13 @@ class McpService { throw new Error('Created MCP server was not returned from canonical insert') } - try { - await notifyEntityListMembersUpserted('mcp_server', input.workspaceId, [ - { - id: entityId, - name: String(normalized.name ?? ''), - enabled: normalized.enabled !== false, - }, - ]) - } catch (error) { - await db.delete(mcpServers).where(eq(mcpServers.id, entityId)) - throw error - } + await publishCreatedSavedEntityListMembers('mcp_server', input.workspaceId, [ + { + id: entityId, + name: String(normalized.name ?? ''), + enabled: normalized.enabled !== false, + }, + ]) this.clearCache(input.workspaceId) return { entityId, fields: savedEntityRowToFields('mcp_server', row) } diff --git a/apps/tradinggoose/lib/skills/operations.test.ts b/apps/tradinggoose/lib/skills/operations.test.ts index 02b066471..759d32c9f 100644 --- a/apps/tradinggoose/lib/skills/operations.test.ts +++ b/apps/tradinggoose/lib/skills/operations.test.ts @@ -32,10 +32,11 @@ vi.mock('nanoid', () => ({ vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ applySavedEntityState: vi.fn(), + publishCreatedSavedEntityListMembers: mockNotifyEntityListMembersUpserted, })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ - readBootstrappedSavedEntityListFields: vi.fn(), + requireSavedEntityListFields: vi.fn(), })) vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index 51bc55ef6..a64941189 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -9,12 +9,14 @@ import { type SkillTransferRecord, } from '@/lib/skills/import-export' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' -import { readBootstrappedSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' +import { + applySavedEntityState, + publishCreatedSavedEntityListMembers, +} from '@/lib/yjs/server/apply-entity-state' +import { requireSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' import { deleteYjsSessionInSocketServer, notifyEntityListMemberRemoved, - notifyEntityListMembersUpserted, } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('SkillsOperations') @@ -49,7 +51,7 @@ interface ImportSkillsParams { } export async function listSkills(params: { workspaceId: string }) { - const entries = await readBootstrappedSavedEntityListFields('skill', params.workspaceId) + const entries = await requireSavedEntityListFields('skill', params.workspaceId) return entries.map(({ entityId, fields }) => ({ id: entityId, workspaceId: params.workspaceId, @@ -139,7 +141,7 @@ export async function createSkills({ return createdSkills }) - await notifyEntityListMembersUpserted( + await publishCreatedSavedEntityListMembers( 'skill', workspaceId, created.map((createdSkill) => ({ id: createdSkill.id, name: createdSkill.name })) @@ -226,7 +228,7 @@ export async function importSkills({ } }) - await notifyEntityListMembersUpserted( + await publishCreatedSavedEntityListMembers( 'skill', workspaceId, result.skills.map((importedSkill) => ({ id: importedSkill.id, name: importedSkill.name })) diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index ec66beb91..f764905b0 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -6,13 +6,16 @@ import { pineIndicators, skill, } from '@tradinggoose/db/schema' -import { and, eq } from 'drizzle-orm' +import { and, eq, inArray } from 'drizzle-orm' import type * as Y from 'yjs' import { normalizeEntityFields } from '@/lib/copilot/entity-documents' import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' import { getEntityFields, getEntityWorkspaceId } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' -import { applyEntityStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { + applyEntityStateInSocketServer, + notifyEntityListMembersUpserted, +} from '@/lib/yjs/server/snapshot-bridge' export class SavedEntityPersistenceError extends Error { constructor( @@ -69,6 +72,29 @@ function normalizeSavedEntityFields( } } +export async function publishCreatedSavedEntityListMembers( + entityKind: SavedEntityKind, + workspaceId: string, + members: Array<{ id: string; name: string; enabled?: boolean }>, + afterRollback?: () => Promise +): Promise { + try { + await notifyEntityListMembersUpserted(entityKind, workspaceId, members) + } catch (error) { + const ids = members.map((member) => member.id) + if (entityKind === 'skill') await db.delete(skill).where(inArray(skill.id, ids)) + if (entityKind === 'custom_tool') + await db.delete(customTools).where(inArray(customTools.id, ids)) + if (entityKind === 'indicator') + await db.delete(pineIndicators).where(inArray(pineIndicators.id, ids)) + if (entityKind === 'knowledge_base') + await db.delete(knowledgeBase).where(inArray(knowledgeBase.id, ids)) + if (entityKind === 'mcp_server') await db.delete(mcpServers).where(inArray(mcpServers.id, ids)) + await afterRollback?.() + throw error + } +} + async function persistSavedEntityState( entityKind: SavedEntityKind, entityId: string, diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 65fb65abf..52b60d74c 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -70,7 +70,7 @@ export async function readBootstrappedReviewTargetSnapshot(descriptor: ReviewTar return getYjsSnapshot(descriptor.yjsSessionId, bridgeParams) } -export async function readBootstrappedEntityListMembers( +export async function requireSavedEntityListMembers( entityKind: SavedEntityKind, workspaceId: string ): Promise { @@ -93,11 +93,11 @@ export async function readBootstrappedEntityListMembers( } } -export async function readBootstrappedSavedEntityListFields( +export async function requireSavedEntityListFields( entityKind: SavedEntityKind, workspaceId: string ): Promise }>> { - const members = await readBootstrappedEntityListMembers(entityKind, workspaceId) + const members = await requireSavedEntityListMembers(entityKind, workspaceId) const entries = await Promise.all( members.map(async (member) => { try { From 80e5ff04126b6326b7b1e42b34968b2e758ebe7b Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 19:58:46 -0600 Subject: [PATCH 248/284] refactor(mcp): rename server update action Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/stores/mcp-servers/store.ts | 15 +++++++-------- apps/tradinggoose/stores/mcp-servers/types.ts | 4 ++-- .../widgets/widgets/list_mcp/index.tsx | 10 ++++------ 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/apps/tradinggoose/stores/mcp-servers/store.ts b/apps/tradinggoose/stores/mcp-servers/store.ts index 15a2d1714..deb403b79 100644 --- a/apps/tradinggoose/stores/mcp-servers/store.ts +++ b/apps/tradinggoose/stores/mcp-servers/store.ts @@ -92,25 +92,24 @@ export const useMcpServersStore = create()( } }, - updateServer: async (workspaceId: string, id: string, updates) => { + renameServer: async (workspaceId: string, id: string, name: string) => { set({ isLoading: true, error: null }) try { const response = await fetch(`/api/mcp/servers/${id}?workspaceId=${workspaceId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates), + body: JSON.stringify({ name }), }) const data = await response.json() if (!response.ok) { - throw new Error(data.error || 'Failed to update server') + throw new Error(data.error || 'Failed to rename server') } const updatedServer = data.data?.server || null - const nextName = - typeof updatedServer?.name === 'string' ? updatedServer.name : updates.name + const nextName = typeof updatedServer?.name === 'string' ? updatedServer.name : name set((state) => ({ servers: state.servers.map((server) => @@ -121,11 +120,11 @@ export const useMcpServersStore = create()( isLoading: false, })) - logger.info(`Updated MCP server: ${id} in workspace: ${workspaceId}`) + logger.info(`Renamed MCP server: ${id} in workspace: ${workspaceId}`) return updatedServer } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to update server' - logger.error('Failed to update MCP server:', error) + const errorMessage = error instanceof Error ? error.message : 'Failed to rename server' + logger.error('Failed to rename MCP server:', error) set({ error: errorMessage, isLoading: false }) throw error } diff --git a/apps/tradinggoose/stores/mcp-servers/types.ts b/apps/tradinggoose/stores/mcp-servers/types.ts index 7aeee5cdf..4b1674355 100644 --- a/apps/tradinggoose/stores/mcp-servers/types.ts +++ b/apps/tradinggoose/stores/mcp-servers/types.ts @@ -52,10 +52,10 @@ export interface McpServersActions { | 'workspaceId' > ) => Promise - updateServer: ( + renameServer: ( workspaceId: string, id: string, - updates: Partial + name: string ) => Promise deleteServer: (workspaceId: string, id: string) => Promise refreshServer: ( diff --git a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx index 4bab1ce17..ec41c564c 100644 --- a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx @@ -214,7 +214,7 @@ const ListMcpWidgetContent = ({ const permissions = useUserPermissionsContext() const [hasRequestedLoad, setHasRequestedLoad] = useState(false) const [deletingIds, setDeletingIds] = useState>(new Set()) - const { servers, isLoading, error, fetchServers, deleteServer, updateServer } = + const { servers, isLoading, error, fetchServers, deleteServer, renameServer } = useMcpServersStore( (state) => ({ servers: state.servers, @@ -222,7 +222,7 @@ const ListMcpWidgetContent = ({ error: state.error, fetchServers: state.fetchServers, deleteServer: state.deleteServer, - updateServer: state.updateServer, + renameServer: state.renameServer, }), shallow ) @@ -350,12 +350,10 @@ const ListMcpWidgetContent = ({ async (serverId: string, name: string) => { if (!workspaceId || !permissions.canEdit) return - await updateServer(workspaceId, serverId, { - name, - }) + await renameServer(workspaceId, serverId, name) await refreshTools(true) }, - [permissions.canEdit, refreshTools, updateServer, workspaceId] + [permissions.canEdit, refreshTools, renameServer, workspaceId] ) const handleDeleteServer = useCallback( From 4366ca728bb4db7d984a4f3c1fa53350bf82e55e Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 21:10:03 -0600 Subject: [PATCH 249/284] fix(yjs): keep live entity docs in sync after persistence Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/yjs/entity-session.ts | 10 ++++-- .../lib/yjs/server/apply-entity-state.test.ts | 36 ++++++++++++++----- .../lib/yjs/server/apply-entity-state.ts | 3 +- apps/tradinggoose/socket-server/index.test.ts | 12 +++++-- .../tradinggoose/socket-server/routes/http.ts | 3 +- 5 files changed, 48 insertions(+), 16 deletions(-) diff --git a/apps/tradinggoose/lib/yjs/entity-session.ts b/apps/tradinggoose/lib/yjs/entity-session.ts index 8b50c0f62..49534a7cc 100644 --- a/apps/tradinggoose/lib/yjs/entity-session.ts +++ b/apps/tradinggoose/lib/yjs/entity-session.ts @@ -189,9 +189,13 @@ export function seedEntitySession(doc: Y.Doc, options: EntitySessionSeedOptions) fields.set('name', payload.name ?? '') fields.set('description', payload.description ?? '') fields.set('chunkingConfig', payload.chunkingConfig) - fields.set('tokenCount', payload.tokenCount ?? 0) - fields.set('embeddingModel', payload.embeddingModel ?? 'text-embedding-3-small') - fields.set('embeddingDimension', payload.embeddingDimension ?? 1536) + if ('tokenCount' in payload) fields.set('tokenCount', payload.tokenCount ?? 0) + if ('embeddingModel' in payload) { + fields.set('embeddingModel', payload.embeddingModel ?? 'text-embedding-3-small') + } + if ('embeddingDimension' in payload) { + fields.set('embeddingDimension', payload.embeddingDimension ?? 1536) + } break case 'mcp_server': diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts index d6094c456..22b0d25ae 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts @@ -97,29 +97,47 @@ describe('applySavedEntityState', () => { }) it('materializes saved-entity DB state from a provided Yjs document', async () => { + const { normalizeEntityFields } = await import('@/lib/copilot/entity-documents') + const { getEntityFields } = await import('@/lib/yjs/entity-session') const { saveSavedEntityYjsDocToDb } = await import('./apply-entity-state') + const inputMeta = { + length: { name: 'length', title: 'Length', type: 'int', defval: 14 }, + } + vi.mocked(normalizeEntityFields).mockImplementationOnce((_entityKind, fields) => ({ + ...fields, + name: 'Canonical Indicator', + inputMeta, + })) const doc = buildDoc({ - name: 'Yjs Skill', - description: 'Yjs description', - content: 'Use the Yjs document.', + name: 'Draft Indicator', + color: '#ff0000', + pineCode: 'indicator("Draft")', + inputMeta: { stale: true }, }) try { - await saveSavedEntityYjsDocToDb('skill', 'skill-1', doc) + await saveSavedEntityYjsDocToDb('indicator', 'indicator-1', doc) + expect(getEntityFields(doc, 'indicator')).toEqual({ + name: 'Canonical Indicator', + color: '#ff0000', + pineCode: 'indicator("Draft")', + inputMeta, + }) } finally { doc.destroy() } expect(mockUpdateSet).toHaveBeenCalledWith({ - name: 'Yjs Skill', - description: 'Yjs description', - content: 'Use the Yjs document.', + name: 'Canonical Indicator', + color: '#ff0000', + pineCode: 'indicator("Draft")', + inputMeta, updatedAt: expect.any(Date), }) expect(mockUpdateWhere).toHaveBeenCalledWith({ and: [ - { field: 'skill.id', value: 'skill-1' }, - { field: 'skill.workspaceId', value: 'workspace-1' }, + { field: 'pineIndicators.id', value: 'indicator-1' }, + { field: 'pineIndicators.workspaceId', value: 'workspace-1' }, ], }) expect(events).toEqual(['db']) diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index f764905b0..bd1dde1f1 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -10,7 +10,7 @@ import { and, eq, inArray } from 'drizzle-orm' import type * as Y from 'yjs' import { normalizeEntityFields } from '@/lib/copilot/entity-documents' import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' -import { getEntityFields, getEntityWorkspaceId } from '@/lib/yjs/entity-session' +import { getEntityFields, getEntityWorkspaceId, seedEntitySession } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { applyEntityStateInSocketServer, @@ -232,4 +232,5 @@ export async function saveSavedEntityYjsDocToDb( ) } await persistSavedEntityState(entityKind, entityId, yjsFields, workspaceId) + seedEntitySession(doc, { entityKind, payload: yjsFields }) } diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 0360ba646..6cc9d790e 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -464,6 +464,14 @@ describe('Socket Server Index Integration', () => { it('should apply saved entity state through Yjs', async () => { const { conn, doc: listDoc } = await connectTestDocument('list:skill:workspace-1') seedEntityListSession(listDoc, [{ id: 'skill-1', name: 'Old Skill' }]) + mockSaveSavedEntityYjsDocToDb.mockImplementationOnce(async (entityKind, entityId, doc) => { + doc.getMap('fields').set('name', 'Canonical Risk Skill') + savedEntityStates.push({ + entityKind, + entityId, + fields: getEntityFields(doc, entityKind), + }) + }) const response = await sendHttpRequestWithOptions( PORT, @@ -491,7 +499,7 @@ describe('Socket Server Index Integration', () => { entityKind: 'skill', entityId: 'skill-1', fields: { - name: 'Risk Skill', + name: 'Canonical Risk Skill', description: 'Position sizing rules', content: 'Keep risk below one percent.', }, @@ -501,7 +509,7 @@ describe('Socket Server Index Integration', () => { expect(getEntityListMembers(listDoc)).toEqual([ { entityId: 'skill-1', - entityName: 'Risk Skill', + entityName: 'Canonical Risk Skill', }, ]) diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index 7130cc4f7..d0586e590 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -297,12 +297,13 @@ async function applyThroughStaging( mutate: (target: Y.Doc) => void, persist: (staged: Y.Doc) => Promise ): Promise { + const liveState = Y.encodeStateVector(doc) const staging = new Y.Doc() Y.applyUpdate(staging, Y.encodeStateAsUpdate(doc)) try { mutate(staging) await persist(staging) - mutate(doc) + Y.applyUpdate(doc, Y.encodeStateAsUpdate(staging, liveState), YJS_ORIGINS.SYSTEM) markDocumentPersisted(doc) } finally { staging.destroy() From 7c84d138284de31d0942cd49451d49a07b3bb9d1 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 21:49:31 -0600 Subject: [PATCH 250/284] fix(copilot): harden MCP initialize handling Validate MCP initialize requests, reject batched initialize calls, and sanitize server-tool errors. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/copilot/mcp/route.test.ts | 60 ++++++++++++++++--- .../tradinggoose/app/api/copilot/mcp/route.ts | 53 +++++++++++++--- .../lib/copilot/server-tool-errors.test.ts | 5 +- .../lib/copilot/server-tool-errors.ts | 6 +- 4 files changed, 103 insertions(+), 21 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index b9b84f29d..48433dc89 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -60,6 +60,15 @@ function createMcpRequest(body: unknown, authorization = 'Bearer sk-tradinggoose }) } +function initializeRequest(id: string | number = 1, protocolVersion = '2025-03-26') { + return { + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion }, + } +} + describe('Copilot MCP route', () => { beforeEach(() => { vi.resetAllMocks() @@ -112,9 +121,7 @@ describe('Copilot MCP route', () => { it('rejects requests without bearer auth', async () => { const { POST } = await import('./route') - const response = await POST( - createMcpRequest({ jsonrpc: '2.0', id: 1, method: 'initialize' }, '') - ) + const response = await POST(createMcpRequest(initializeRequest(), '')) const body = await response.json() expect(response.status).toBe(401) @@ -125,7 +132,7 @@ describe('Copilot MCP route', () => { it('returns initialize metadata with authenticated workspace context', async () => { const { POST } = await import('./route') - const response = await POST(createMcpRequest({ jsonrpc: '2.0', id: 1, method: 'initialize' })) + const response = await POST(createMcpRequest(initializeRequest())) const body = await response.json() expect(response.headers.get('MCP-Protocol-Version')).toBe('2025-03-26') @@ -134,7 +141,7 @@ describe('Copilot MCP route', () => { }) expect(mockUpdateApiKeyLastUsed).toHaveBeenCalledWith('key-1') expect(mockCheckApiEndpointRateLimit).toHaveBeenCalledWith('user-1', 'copilot-mcp') - expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ userId: 'user-1', autoCreate: true }) + expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ userId: 'user-1', autoCreate: false }) expect(body.result.capabilities).toEqual({ tools: {} }) expect(body.result.serverInfo).toEqual({ name: 'TradingGoose', version: '0.1.0' }) expect(body.result.instructions).toContain('workspaceId=workspace-1, permissions=admin') @@ -150,9 +157,7 @@ describe('Copilot MCP route', () => { it('accepts a case-insensitive bearer auth scheme', async () => { const { POST } = await import('./route') - const response = await POST( - createMcpRequest({ jsonrpc: '2.0', id: 1, method: 'initialize' }, 'bearer sk-lowercase') - ) + const response = await POST(createMcpRequest(initializeRequest(), 'bearer sk-lowercase')) expect(response.status).toBe(200) expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('sk-lowercase', { @@ -300,17 +305,43 @@ describe('Copilot MCP route', () => { expect(body.error).toBeUndefined() expect(body.result.isError).toBe(true) expect(body.result.structuredContent.code).toBe('server_tool_execution_failed') + expect(body.result.structuredContent.error).toBe('Server tool execution failed') + expect(body.result.content[0].text).not.toContain('db.internal') }) it('sanitizes errors thrown by non-tool methods instead of leaking a raw response', async () => { const { POST } = await import('./route') mockGetUserWorkspaces.mockRejectedValueOnce(new Error('workspace bootstrap failed at shard-3')) - const response = await POST(createMcpRequest({ jsonrpc: '2.0', id: 7, method: 'initialize' })) + const response = await POST(createMcpRequest(initializeRequest(7))) const body = await response.json() expect(body.error.code).toBe(-32603) expect(body.error.data.code).toBe('server_tool_execution_failed') + expect(body.error.message).toBe('Server tool execution failed') + expect(JSON.stringify(body)).not.toContain('shard-3') + }) + + it('enforces JSON-RPC and MCP initialize request shape', async () => { + const { POST } = await import('./route') + + const invalidJsonRpcResponse = await POST( + createMcpRequest({ jsonrpc: '1.0', id: 8, method: 'ping' }) + ) + const nullIdResponse = await POST( + createMcpRequest({ jsonrpc: '2.0', id: null, method: 'ping' }) + ) + const invalidInitializeResponse = await POST( + createMcpRequest({ jsonrpc: '2.0', id: 9, method: 'initialize', params: {} }) + ) + const unsupportedVersionResponse = await POST(createMcpRequest(initializeRequest(10, '1.0'))) + + expect((await invalidJsonRpcResponse.json()).error.code).toBe(-32600) + expect((await nullIdResponse.json()).error.code).toBe(-32600) + expect((await invalidInitializeResponse.json()).error.code).toBe(-32602) + const unsupportedVersionBody = await unsupportedVersionResponse.json() + expect(unsupportedVersionBody.error.code).toBe(-32000) + expect(unsupportedVersionBody.error.data.supportedProtocolVersions).toEqual(['2025-03-26']) }) it('returns per-entry invalid request errors for malformed batches', async () => { @@ -369,4 +400,15 @@ describe('Copilot MCP route', () => { expect(body.error.message).toBe('JSON-RPC batch size cannot exceed 10') expect(mockRouteExecution).not.toHaveBeenCalled() }) + + it('rejects batched initialize requests', async () => { + const { POST } = await import('./route') + + const response = await POST(createMcpRequest([initializeRequest()])) + const body = await response.json() + + expect(body.error.code).toBe(-32600) + expect(body.error.message).toBe('initialize cannot be batched') + expect(mockGetUserWorkspaces).not.toHaveBeenCalled() + }) }) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index 64f518b40..22a14898e 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -17,12 +17,12 @@ const SERVER_NAME = 'TradingGoose' const SERVER_VERSION = '0.1.0' const MAX_JSON_RPC_BATCH_SIZE = 10 -type JsonRpcId = string | number | null +type JsonRpcId = string | number type JsonRpcRequest = { - jsonrpc?: string - id?: JsonRpcId - method?: string + jsonrpc?: unknown + id?: unknown + method?: unknown params?: unknown } @@ -38,7 +38,7 @@ function jsonRpcResult(id: JsonRpcId, result: unknown) { } } -function jsonRpcError(id: JsonRpcId, code: number, message: string, data?: unknown) { +function jsonRpcError(id: JsonRpcId | null, code: number, message: string, data?: unknown) { return { jsonrpc: '2.0', id, @@ -111,7 +111,7 @@ async function authenticateCopilotMcpRequest( } async function buildInstructions(userId: string) { - const workspaces = await getUserWorkspaces({ userId, autoCreate: true }) + const workspaces = await getUserWorkspaces({ userId, autoCreate: false }) const workspaceLines = workspaces.length > 0 ? workspaces.map( @@ -167,13 +167,33 @@ function isJsonRpcRequest(value: unknown): value is JsonRpcRequest { return !!value && typeof value === 'object' && !Array.isArray(value) } +function getResponseId(request: JsonRpcRequest): JsonRpcId | null { + return typeof request.id === 'string' || typeof request.id === 'number' ? request.id : null +} + +function getInitializeProtocolVersion(params: unknown) { + if (!params || typeof params !== 'object' || Array.isArray(params)) { + return null + } + + const protocolVersion = (params as { protocolVersion?: unknown }).protocolVersion + return typeof protocolVersion === 'string' ? protocolVersion : null +} + +function isInitializeRequest(value: unknown) { + return isJsonRpcRequest(value) && value.method === 'initialize' +} + async function handleJsonRpcRequest(entry: unknown, auth: AuthenticatedMcpUser) { if (!isJsonRpcRequest(entry)) { return jsonRpcError(null, -32600, 'Invalid JSON-RPC request') } const request = entry - const id = request.id ?? null + const id = getResponseId(request) + if (request.jsonrpc !== '2.0') { + return jsonRpcError(id, -32600, 'Invalid JSON-RPC request') + } if (typeof request.method !== 'string') { return jsonRpcError(id, -32600, 'Invalid JSON-RPC request') } @@ -181,10 +201,23 @@ async function handleJsonRpcRequest(entry: unknown, auth: AuthenticatedMcpUser) if (request.id === undefined) { return null } + if (id === null) { + return jsonRpcError(null, -32600, 'Invalid JSON-RPC request') + } try { switch (request.method) { - case 'initialize': + case 'initialize': { + const protocolVersion = getInitializeProtocolVersion(request.params) + if (!protocolVersion) { + return jsonRpcError(id, -32602, 'Invalid initialize params') + } + if (protocolVersion !== MCP_PROTOCOL_VERSION) { + return jsonRpcError(id, -32000, 'Unsupported MCP protocol version', { + supportedProtocolVersions: [MCP_PROTOCOL_VERSION], + }) + } + return jsonRpcResult(id, { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: { @@ -196,6 +229,7 @@ async function handleJsonRpcRequest(entry: unknown, auth: AuthenticatedMcpUser) }, instructions: await buildInstructions(auth.userId), }) + } case 'ping': return jsonRpcResult(id, {}) @@ -282,6 +316,9 @@ export async function POST(request: NextRequest) { jsonRpcError(null, -32600, `JSON-RPC batch size cannot exceed ${MAX_JSON_RPC_BATCH_SIZE}`) ) } + if (body.some(isInitializeRequest)) { + return mcpJsonResponse(jsonRpcError(null, -32600, 'initialize cannot be batched')) + } const responses = [] for (const entry of body) { diff --git a/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts b/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts index f0928a5b6..f1e480f28 100644 --- a/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts +++ b/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts @@ -97,17 +97,18 @@ describe('copilot server tool errors', () => { it('falls back to a generic 500 payload for unknown tool failures', () => { const response = buildCopilotServerToolErrorResponse( 'make_api_request', - new Error('socket hang up') + new Error('socket hang up at db.internal:5432') ) expect(response).toEqual({ status: 500, body: { code: 'server_tool_execution_failed', - error: 'socket hang up', + error: 'Server tool execution failed', retryable: false, }, }) + expect(response.body.error).not.toContain('db.internal') }) it('returns a structured 422 payload for tool argument schema failures', () => { diff --git a/apps/tradinggoose/lib/copilot/server-tool-errors.ts b/apps/tradinggoose/lib/copilot/server-tool-errors.ts index 0a307eb71..2f0436f45 100644 --- a/apps/tradinggoose/lib/copilot/server-tool-errors.ts +++ b/apps/tradinggoose/lib/copilot/server-tool-errors.ts @@ -16,6 +16,8 @@ export interface CopilotServerToolErrorResponse { body: CopilotServerToolErrorPayload } +const GENERIC_SERVER_TOOL_ERROR = 'Server tool execution failed' + export class StructuredServerToolError extends Error { public readonly status: number public readonly code: string @@ -180,7 +182,7 @@ export function buildCopilotServerToolErrorResponse( status: typedError.status, body: { code: typedError.code, - error: message, + error: GENERIC_SERVER_TOOL_ERROR, ...(typeof typedError.retryable === 'boolean' ? { retryable: typedError.retryable } : {}), }, } @@ -197,7 +199,7 @@ export function buildCopilotServerToolErrorResponse( status: 500, body: { code: 'server_tool_execution_failed', - error: message, + error: GENERIC_SERVER_TOOL_ERROR, retryable: false, }, } From 363556862a0df8ef87c148f3cd58bc96b05fb162 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 21:58:23 -0600 Subject: [PATCH 251/284] refactor(copilot): internalize entity helper utilities Remove the unused client execution-context helper and make shared server entity helpers internal to the module.\n\nCo-authored-by: Codex \nCo-authored-by: BWJ2310 \nCo-authored-by: BWJ2310-backup --- .../lib/copilot/tools/client/execution-context.ts | 13 ------------- .../lib/copilot/tools/server/entities/shared.ts | 6 +++--- 2 files changed, 3 insertions(+), 16 deletions(-) delete mode 100644 apps/tradinggoose/lib/copilot/tools/client/execution-context.ts diff --git a/apps/tradinggoose/lib/copilot/tools/client/execution-context.ts b/apps/tradinggoose/lib/copilot/tools/client/execution-context.ts deleted file mode 100644 index 06e16366e..000000000 --- a/apps/tradinggoose/lib/copilot/tools/client/execution-context.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ClientToolExecutionContext } from '@/lib/copilot/tools/client/base-tool' - -export function resolveWorkspaceIdFromExecutionContext( - executionContext: ClientToolExecutionContext -): string { - if (executionContext.workspaceId) { - return executionContext.workspaceId - } - - throw new Error( - 'No active workspace found in execution context. Ensure workspaceId is included in tool provenance.' - ) -} diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index 1bac819f9..612e4f123 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -73,7 +73,7 @@ export type PrepareEntityDocumentFields = ( fields: Record ) => Record -export const ENTITY_KIND_LABELS: Record = { +const ENTITY_KIND_LABELS: Record = { skill: 'skill', custom_tool: 'custom tool', indicator: 'indicator', @@ -89,7 +89,7 @@ export function requireUserId(context?: ServerToolExecutionContext): string { return userId } -export function requireWorkspaceId(context?: ServerToolExecutionContext): string { +function requireWorkspaceId(context?: ServerToolExecutionContext): string { const workspaceId = context?.workspaceId?.trim() if (!workspaceId) { throw new Error( @@ -144,7 +144,7 @@ export function requireEntityId(args: EntityDocumentArgs, toolName: string): str return entityId } -export function parseEntityMutationDocument( +function parseEntityMutationDocument( kind: SavedEntityDocumentKind, args: EntityDocumentArgs ): Record { From 50a62de4b5df42da4dfe86d31b35f931c7bb62c8 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 21:58:45 -0600 Subject: [PATCH 252/284] docs(changelog): remove stale June 24 entry Remove the outdated changelog note for the June 24 Copilot MCP work.\n\nCo-authored-by: Codex \nCo-authored-by: BWJ2310 \nCo-authored-by: BWJ2310-backup --- changelog/June-24-2026.md | 99 --------------------------------------- 1 file changed, 99 deletions(-) delete mode 100644 changelog/June-24-2026.md diff --git a/changelog/June-24-2026.md b/changelog/June-24-2026.md deleted file mode 100644 index ded3e358e..000000000 --- a/changelog/June-24-2026.md +++ /dev/null @@ -1,99 +0,0 @@ -# June-24-2026 - -## feat/copilot-mcp @ 599ffd73 vs upstream/staging - -### Summary -- Adds a TradingGoose Copilot MCP endpoint at `apps/tradinggoose/app/api/copilot/mcp/route.ts` and local installer/login scripts at `apps/tradinggoose/app/mcp/[[...command]]/route.ts`. -- Moves most Copilot entity, workflow, monitor, knowledge, MCP, and credential operations from browser-managed client tools into the server-managed tool router in `apps/tradinggoose/lib/copilot/tools/server/router.ts`. -- Makes saved entities editable through shared document envelopes and Yjs sessions instead of direct per-widget field patches. -- Makes workflow state persistence flow through Yjs first, then materializes normalized database tables from the applied Yjs snapshot. - -### Branch Scope -- Compared `61dc75e4449ae1b9f7a9aaa55cc25bf0c46cfb71..599ffd73`, where `61dc75e4449ae1b9f7a9aaa55cc25bf0c46cfb71` is the merge base with the freshly fetched available staging ref. -- The user requested `upstream/staging`, but this checkout has no `upstream` remote. `git fetch upstream staging` failed with `fatal: 'upstream' does not appear to be a git repository`, so the reviewed evidence uses freshly fetched `origin/staging` at `61dc75e4`. -- `git status --short --branch --untracked-files=all`, `git diff --stat HEAD`, and `git diff --name-status HEAD` showed no staged, unstaged, or untracked feature edits before this changelog file was created. The only dirty-tree edit from this run is this dated changelog file. -- Main areas touched: MCP auth and installer routes, personal API key auth, Copilot runtime tool registry, server-managed Copilot tools, saved entity document contracts, review-token acceptance, Yjs socket bridge endpoints, workflow normalized persistence, monitor update reuse, custom tool/indicator persistence, editor widgets, and focused tests. - -### Key Changes -- `apps/tradinggoose/app/api/copilot/mcp/route.ts` implements the external MCP JSON-RPC endpoint with `initialize`, `ping`, `tools/list`, `tools/call`, `resources/list`, and `prompts/list`; it authenticates only personal API keys through `authenticateApiKeyFromHeader()` and executes exposed tools through `routeExecution()`. -- `apps/tradinggoose/app/mcp/[[...command]]/route.ts`, `apps/tradinggoose/lib/mcp/install-script.ts`, and `apps/tradinggoose/lib/mcp/local-config-writer-script.ts` serve `/mcp`, `/mcp/setup`, `/mcp/setup/`, and `/mcp/login` scripts that configure Codex, Cursor, Claude, and OpenCode with a bearer auth header instead of a workspace or entity target stored in local config. -- `apps/tradinggoose/lib/mcp/auth.ts` adds the device-login state machine: `startMcpDeviceLogin()`, `pollMcpDeviceLogin()`, `approveMcpDeviceLogin()`, `cancelMcpDeviceLogin()`, and `readMcpDeviceLoginApprovalStatus()` store pending/approved state in `verification`, hash the login code and verification key, and insert the generated personal API key only when the terminal poll consumes an approval. -- `apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx` plus `apps/tradinggoose/app/api/auth/mcp/{start,poll,authorize}/route.ts` add the localized browser approval flow for MCP device login and preserve locale-aware redirects on approve, cancel, invalid, expired, and login-required states. -- `apps/tradinggoose/lib/api-key/service.ts` centralizes `createApiKeyMaterial()`, `createPersonalApiKey()`, `authenticateApiKeyFromHeader()`, and `updateApiKeyLastUsed()`, and rejects malformed `sk-tradinggoose-` or `tradinggoose_` values before querying stored keys. -- `apps/tradinggoose/lib/copilot/tools/server/router.ts` is the canonical server tool registry. `routeExecution()` validates names through `isToolId()`, parses arguments through `ServerToolArgSchemas`, injects `workspaceId` from context when possible, and validates each result against the tool contract. -- `apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts` becomes the browser-to-server execution endpoint for server-managed tools, enforcing session auth, workspace access, payload/context `workspaceId` consistency, review-token acceptance, and structured error responses. -- `apps/tradinggoose/lib/copilot/tools/server/base-tool.ts` defines `ServerToolExecutionContext`, `withWorkspaceArgContext()`, `shouldStageServerToolMutationForReview()`, `hashServerToolReviewBase()`, and `assertAcceptedServerToolReviewBase()` as the shared mutation/review contract. -- `apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts` stages server-managed mutation reviews in `verification` with encrypted payloads, base-state hashes, workspace/entity context, and a single-accept claim path before re-executing the tool with full access. -- `apps/tradinggoose/lib/copilot/entity-documents.ts` defines the saved entity document formats `tg-skill-document-v1`, `tg-custom-tool-document-v1`, `tg-indicator-document-v1`, `tg-mcp-server-document-v1`, `tg-knowledge-base-document-v1`, and `tg-workflow-variable-document-v1`, including MCP secret redaction through `ENTITY_SECRET_PLACEHOLDER`. -- `apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts` centralizes `buildDocumentEnvelope()`, `parseEntityMutationDocument()`, `executeCreateEntityDocumentMutation()`, `executeUpdateEntityDocumentMutation()`, `verifyWorkspaceContext()`, and `verifySavedEntityContext()` for skill, custom tool, indicator, knowledge base, and MCP server tools. -- `apps/tradinggoose/lib/copilot/tools/server/entities/{custom-tool,indicator,mcp-server,skill,workflow}.ts` provide the canonical server-side list/read/create/edit/rename tools for saved entities; workflow tools additionally return `workflowSummary`, full `tg-mermaid-v1` documents, and `workflowVariableDocumentFormat`. -- `apps/tradinggoose/lib/copilot/registry.ts` and `apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts` align the model-visible schema and descriptions with the server contracts: full document replacement for saved entities, exact `entityId`, workspace-scoped list/create calls, custom tool `schemaText`, indicator `runtimeId`, and minimal Mermaid for `edit_workflow`. -- `apps/tradinggoose/stores/copilot/tool-registry.ts` marks most Copilot tools as server-managed through `SERVER_TOOL_METADATA`, injects workspace IDs for workspace-targeted tools, and refreshes workflow/entity/MCP/environment query state after server-managed create/edit/rename success. -- `apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts` and `apps/tradinggoose/socket-server/routes/http.ts` add authenticated internal socket-server endpoints for Yjs snapshots, workflow state application, saved entity state application, and raw Yjs update application. -- `apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts` bootstraps missing saved workflow/entity Yjs sessions from canonical database state, records `reseededFromCanonical`, and exposes `readBootstrappedReviewTargetSnapshot()` plus `readBootstrappedSavedEntityFields()` for Copilot reads. -- `apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts` and `apps/tradinggoose/lib/yjs/server/apply-entity-state.ts` apply changes into Yjs through the socket bridge first, then persist the applied snapshot back to workflow normalized tables or saved entity tables. -- `apps/tradinggoose/lib/workflows/db-helpers.ts` makes `loadWorkflowState()` prefer non-stale Yjs state over normalized tables, adds `remapVariableIds()`, regenerates conflicting block/edge IDs before saving, sanitizes invalid Agent custom tools with `sanitizeAgentToolsInBlocks()`, and lets `saveWorkflowToNormalizedTables()` run an optional transaction callback. -- `apps/tradinggoose/app/api/workflows/[id]/state/route.ts`, `apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts`, `apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts`, and `apps/tradinggoose/lib/workflows/import.ts` now save, duplicate, revert, and import workflows through `applyWorkflowState()` or `saveWorkflowToNormalizedTables()` with explicit variable preservation/remapping. -- `apps/tradinggoose/app/api/monitors/update-service.ts` extracts monitor update behavior from `apps/tradinggoose/app/api/monitors/[id]/route.ts`, and `apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts` reuses it for reviewed server-managed monitor document edits. -- `apps/tradinggoose/lib/custom-tools/operations.ts` and `apps/tradinggoose/lib/indicators/custom/operations.ts` route updates for existing custom tools and indicators through `applySavedEntityPersistedState()` so editor/Yjs state and saved database rows stay synchronized. -- `apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx`, `apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.tsx`, `apps/tradinggoose/widgets/widgets/editor_indicator/*`, and `apps/tradinggoose/widgets/widgets/editor_skill/*` read/write saved entity fields through Yjs helpers such as `useSavedEntityYjsSession()`, `useYjsStringField()`, `getFieldsMap()`, and `setEntityField()`. - -### Design Decisions -- External MCP calls use the same `routeExecution()` path as Studio Copilot server tools. This keeps MCP tool schemas, result validation, workspace access, review-safe behavior, and model-facing prompt metadata in one place. -- Local MCP config stores only endpoint URL and bearer token. `buildInstructions()` in `apps/tradinggoose/app/api/copilot/mcp/route.ts` tells clients to pass workspace/entity IDs per tool call instead of persisting workspace scope in local client config. -- Server-managed mutation tools stage review server-side with an encrypted payload and base-state hash instead of trusting client-side staged data. Acceptance re-reads the target state and rejects stale edits through `assertAcceptedServerToolReviewBase()`. -- Saved entity editing now uses full document replacement contracts. Future entity edits should replace `entityDocument` under the matching `documentFormat` rather than inventing partial field patch arguments for each entity kind. -- MCP server documents intentionally redact secret `headers` and `env` values as `[redacted]`. `preserveMcpServerSecretPlaceholders()` keeps existing secrets only when a redacted key already exists; new MCP server documents cannot use the placeholder. -- Workflow graph edits intentionally split topology, block internals, workflow variables, and workflow names: use `edit_workflow` for minimal graph Mermaid, `edit_workflow_block` for one block patch, `edit_workflow_variable` for `tg-workflow-variable-document-v1`, and `rename_workflow` for metadata. -- Yjs is the editable workflow/entity source of truth. Persistence applies into the socket-server document first, then materializes database tables from the resulting Yjs snapshot to avoid divergent field overlays. -- Workflow imports and duplicates preserve skill references by name during transfer and remap variable IDs with `remapVariableIds()` so imported/duplicated workflows do not share source workflow variable identity. -- Indicator runtime identity is now explicit in Copilot surfaces. Built-in indicators use default runtime IDs, custom indicators use their entity IDs as `runtimeId`, and `read_indicator` accepts either custom `entityId` or callable `runtimeId`. - -### Shared Contracts and Helpers to Reuse -- Use `routeExecution()` and `getServerToolIds()` from `apps/tradinggoose/lib/copilot/tools/server/router.ts` for any server-side Copilot or MCP tool execution. -- Use `ServerToolExecutionContext`, `withWorkspaceArgContext()`, `shouldStageServerToolMutationForReview()`, `hashServerToolReviewBase()`, and `assertAcceptedServerToolReviewBase()` from `apps/tradinggoose/lib/copilot/tools/server/base-tool.ts` for new server-managed tools. -- Use `stageServerManagedToolReview()` and `acceptServerManagedToolReview()` from `apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts`; do not create separate review-token tables or client-owned acceptance flows. -- Use the document helpers in `apps/tradinggoose/lib/copilot/entity-documents.ts`: `getEntityDocumentFormat()`, `getEntityDocumentSchema()`, `parseEntityDocument()`, `serializeEntityDocument()`, `normalizeEntityFields()`, and `ENTITY_SECRET_PLACEHOLDER`. -- Use `executeCreateEntityDocumentMutation()` and `executeUpdateEntityDocumentMutation()` from `apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts` when adding saved-entity Copilot tools. -- Use `applySavedEntityPersistedState()` and `persistSavedEntityYjsState()` from `apps/tradinggoose/lib/yjs/server/apply-entity-state.ts` for saved entity persistence that must stay aligned with Yjs. -- Use `applyWorkflowState()` and `applyWorkflowEntityName()` from `apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts` for workflow writes, metadata renames, imports, duplicates, and deployment reverts. -- Use `readBootstrappedReviewTargetSnapshot()` and `readBootstrappedSavedEntityFields()` from `apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts` when server code needs current saved entity or workflow state. -- Use `buildMcpInstallScript()` from `apps/tradinggoose/lib/mcp/install-script.ts` and `MCP_LOCAL_CONFIG_WRITER_SCRIPT` from `apps/tradinggoose/lib/mcp/local-config-writer-script.ts` for local MCP setup changes. -- Use `startMcpDeviceLogin()`, `pollMcpDeviceLogin()`, `approveMcpDeviceLogin()`, and `cancelMcpDeviceLogin()` from `apps/tradinggoose/lib/mcp/auth.ts` for MCP browser-approved device login. -- Use `createApiKeyMaterial()`, `createPersonalApiKey()`, and `authenticateApiKeyFromHeader()` from `apps/tradinggoose/lib/api-key/service.ts` for personal API key generation and authentication. -- Use `createCustomToolRuntimeId()`, `isCustomToolRuntimeId()`, and `getCustomToolEntityIdFromRuntimeId()` from `apps/tradinggoose/lib/custom-tools/schema.ts`; workflow validation depends on canonical `custom_` tool IDs. -- Use `sanitizeAgentToolsInBlocks()` from `apps/tradinggoose/lib/workflows/validation.ts` when loading or saving workflow blocks that may contain Agent custom tools. -- Use `remapVariableIds()`, `ensureUniqueBlockIds()`, `ensureUniqueEdgeIds()`, `loadWorkflowState()`, and `saveWorkflowToNormalizedTables()` from `apps/tradinggoose/lib/workflows/db-helpers.ts` for workflow duplication, import, save, deploy, and revert paths. -- Use `updateMonitorForUser()` from `apps/tradinggoose/app/api/monitors/update-service.ts` for monitor update behavior shared by the REST route and Copilot server tool. -- Use `useSavedEntityYjsSession()`, `getFieldsMap()`, `setEntityField()`, `useYjsStringField()`, and the editor action event helpers when wiring saved entity editor widgets. - -### Removed or Replaced Items -- Deleted `apps/tradinggoose/lib/copilot/tools/client/entities/entity-document-tool-utils.ts`, `entity-document-tools.ts`, and `entity-tools.test.ts`. Use the server entity tools under `apps/tradinggoose/lib/copilot/tools/server/entities/` plus `entity-documents.ts` instead. -- Deleted client-side knowledge base tool code in `apps/tradinggoose/lib/copilot/tools/client/knowledge/knowledge-base.ts`. Use the server knowledge base tools imported by `apps/tradinggoose/lib/copilot/tools/server/router.ts`. -- Deleted client-side monitor tools in `apps/tradinggoose/lib/copilot/tools/client/monitor/{edit-monitor,list-monitors,read-monitor}.ts` and moved shared monitor helpers from `client/monitor/monitor-tool-utils.ts` to `apps/tradinggoose/lib/copilot/tools/server/monitor/shared.ts`. -- Deleted client-side workflow tool modules such as `create-workflow.ts`, `edit-workflow.ts`, `edit-workflow-block.ts`, `read-workflow.ts`, `rename-workflow.ts`, `set-workflow-variables.ts`, `check-deployment-status.ts`, and block-output readers under `apps/tradinggoose/lib/copilot/tools/client/workflow/`. Use the server workflow tools under `apps/tradinggoose/lib/copilot/tools/server/workflow/` and `apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts`. -- Moved `apps/tradinggoose/lib/copilot/tools/client/workflow/block-output-utils.ts` to `apps/tradinggoose/lib/copilot/workflow/block-output-utils.ts`. Use the new path for output/reference helpers. -- Replaced direct monitor update logic in `apps/tradinggoose/app/api/monitors/[id]/route.ts` with `apps/tradinggoose/app/api/monitors/update-service.ts`. Do not re-add a second monitor update implementation in the route. -- Replaced saved entity field overlays in the Yjs layer with `seedEntitySession()`, `getEntityFields()`, and `applySavedEntityPersistedState()`. Do not revive separate overlay maps for skill/custom tool/indicator/MCP editor state. -- Replaced title-shaped or malformed custom Agent tool validation with `custom_` validation through `getCustomToolEntityIdFromRuntimeId()`. Do not persist Agent custom tools by title. -- Removed the assumption that `git diff` evidence should be compared to `upstream/main`; this entry documents staging comparison intent and records the missing `upstream` remote in this checkout. - -### Future Branch Guardrails -- Do not add new Copilot mutation tools as browser-only client tools unless they truly need browser APIs. Default to server-managed tools in `apps/tradinggoose/lib/copilot/tools/server/router.ts` and expose client display copy through `SERVER_TOOL_METADATA`. -- Do not add new saved entity mutation argument shapes when a `tg-*-document-v1` document can represent the change. Extend `entity-documents.ts` and `entities/shared.ts` first. -- Do not store workspace ID, entity ID, or entity targets in local MCP client config. MCP clients should keep only endpoint URL plus bearer token and pass scope per JSON-RPC tool call. -- Do not create a second MCP auth path. Device login state belongs in `apps/tradinggoose/lib/mcp/auth.ts`, local setup scripts belong in `apps/tradinggoose/lib/mcp/install-script.ts`, and request authentication belongs in `authenticateApiKeyFromHeader()`. -- Do not bypass `execute-copilot-server-tool` for browser-triggered server-managed tools. It owns session auth, workspace access checks, review token acceptance, and structured errors. -- Do not mutate workflow normalized tables directly for editor/import/revert flows. Apply to Yjs with `applyWorkflowState()` and let `saveWorkflowToNormalizedTables()` materialize the applied snapshot. -- Do not reintroduce old client entity/workflow tool files under `apps/tradinggoose/lib/copilot/tools/client/`. The canonical replacements are server tools, Yjs bridge helpers, and shared document contracts. -- Do not reintroduce `function.name` as custom tool identity. Custom tool display lives in `title`; execution identity lives in `custom_`. -- Do not drop MCP server secret preservation behavior. `[redacted]` is a preservation placeholder only for existing secret keys, not a valid new secret value. -- Do not bypass `sanitizeAgentToolsInBlocks()` when loading or saving workflow blocks that may include Agent custom tools. - -### Validation Notes -- Used the requested `staging-changelog` workflow and followed `changelog/TEMPLATE.md`. -- Reviewed `git status --short --branch --untracked-files=all`, `git remote -v`, `git fetch upstream staging`, `git fetch origin staging`, `git branch -r`, `git merge-base origin/staging HEAD`, `git log --oneline 61dc75e4449ae1b9f7a9aaa55cc25bf0c46cfb71..HEAD`, `git diff --stat`, `git diff --name-status --find-renames`, `git diff --summary`, `git diff --dirstat=files,10,cumulative`, `git diff --stat HEAD`, and `git diff --name-status HEAD`. -- `git fetch upstream staging` failed because no `upstream` remote exists in this clone. `origin/staging` was fetched successfully and used as the available staging evidence; no `upstream/main` comparison was used. -- Inspected `AGENTS.md`, `changelog/TEMPLATE.md`, recent changelog style, MCP route/auth/install files, personal API key helpers/tests, Copilot registry/prompt metadata/runtime manifest tests, server tool router/base/review acceptance, server entity tools/tests, removed client entity/workflow/monitor/knowledge tool contents from the merge base, Yjs bootstrap/apply/snapshot bridge helpers, socket-server internal routes, workflow persistence/import/duplicate/revert/state routes, monitor update service, custom tool and indicator operations, editor widget Yjs bindings, and MCP auth/install route tests. -- Confirmed no `*/migration/*` files are in the branch diff. -- No automated test suite was run for this changelog-only update. Validation focused on merge-base diff review, related source/test inspection, deleted-file inspection, dirty-tree confirmation before editing, and template conformance. From d71fa13cceb4b38317462617bf12b188197556f9 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 22:02:57 -0600 Subject: [PATCH 253/284] refactor(copilot): remove legacy typed server tool error path Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/copilot/server-tool-errors.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/apps/tradinggoose/lib/copilot/server-tool-errors.ts b/apps/tradinggoose/lib/copilot/server-tool-errors.ts index 2f0436f45..ff0d78356 100644 --- a/apps/tradinggoose/lib/copilot/server-tool-errors.ts +++ b/apps/tradinggoose/lib/copilot/server-tool-errors.ts @@ -176,18 +176,6 @@ export function buildCopilotServerToolErrorResponse( } const message = error instanceof Error ? error.message : 'Failed to execute server tool' - const typedError = error as { status?: unknown; code?: unknown; retryable?: unknown } - if (typeof typedError.status === 'number' && typeof typedError.code === 'string') { - return { - status: typedError.status, - body: { - code: typedError.code, - error: GENERIC_SERVER_TOOL_ERROR, - ...(typeof typedError.retryable === 'boolean' ? { retryable: typedError.retryable } : {}), - }, - } - } - if (toolName === 'edit_workflow') { const structuredError = buildEditWorkflowError(message) if (structuredError) { From 30714fd6788684decde16e71841f6c0e97dc4fee Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 23:19:51 -0600 Subject: [PATCH 254/284] fix(mcp): remove stale MCP tool cache Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/mcp/servers/[id]/route.ts | 4 - .../tradinggoose/app/api/mcp/servers/route.ts | 2 - .../app/api/mcp/tools/discover/route.ts | 4 +- .../sessions/[sessionId]/snapshot/route.ts | 5 - .../app/mcp/[[...command]]/route.test.ts | 2 +- apps/tradinggoose/hooks/use-mcp-tools.ts | 10 +- .../tools/server/entities/mcp-server.ts | 1 - apps/tradinggoose/lib/mcp/install-script.ts | 2 +- apps/tradinggoose/lib/mcp/service.ts | 234 +----------------- apps/tradinggoose/lib/mcp/utils.ts | 1 - .../widgets/editor_mcp/editor-mcp-body.tsx | 2 +- .../widgets/widgets/list_mcp/index.tsx | 4 +- 12 files changed, 12 insertions(+), 259 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts index 00216b146..86433a533 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts @@ -4,7 +4,6 @@ import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpService } from '@/lib/mcp/service' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { @@ -71,9 +70,6 @@ export const PATCH = withMcpAuth('write')( const name = body.name.trim() await applySavedEntityState('mcp_server', serverId, { ...fields, name }) - // Clear MCP service cache after update - mcpService.clearCache(workspaceId) - logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`) return createMcpSuccessResponse({ server: { diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 6b5c2f778..fc2e42cce 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -197,8 +197,6 @@ export const DELETE = withMcpAuth('write')( }) } - mcpService.clearCache(workspaceId) - logger.info(`[${requestId}] Successfully deleted MCP server: ${deletedServer.id}`) return createMcpSuccessResponse({ message: `Server ${deletedServer.id} deleted successfully`, diff --git a/apps/tradinggoose/app/api/mcp/tools/discover/route.ts b/apps/tradinggoose/app/api/mcp/tools/discover/route.ts index 8ae3dfb59..380d7dbbf 100644 --- a/apps/tradinggoose/app/api/mcp/tools/discover/route.ts +++ b/apps/tradinggoose/app/api/mcp/tools/discover/route.ts @@ -17,19 +17,17 @@ export const GET = withMcpAuth('read')( try { const { searchParams } = new URL(request.url) const serverId = searchParams.get('serverId') - const forceRefresh = searchParams.get('refresh') === 'true' logger.info(`[${requestId}] Discovering MCP tools for user ${userId}`, { serverId, workspaceId, - forceRefresh, }) let tools if (serverId) { tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) } else { - tools = await mcpService.discoverTools(userId, workspaceId, forceRefresh) + tools = await mcpService.discoverTools(userId, workspaceId) } const byServer: Record = {} diff --git a/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts b/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts index 264f7f870..a37f9308f 100644 --- a/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts +++ b/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts @@ -5,7 +5,6 @@ import { parseYjsTransportEnvelope, } from '@/lib/copilot/review-sessions/identity' import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' -import { mcpService } from '@/lib/mcp/service' import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' import { applyYjsUpdateInSocketServer, @@ -125,10 +124,6 @@ export async function POST( updateBase64 ) - if (descriptor.entityKind === 'mcp_server') { - mcpService.clearCache(descriptor.workspaceId) - } - return NextResponse.json({ success: true }) } catch (error) { if (error instanceof SocketServerBridgeError) { diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts index 04ed42b3a..14367a8e7 100644 --- a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts @@ -91,7 +91,7 @@ describe('MCP install route', () => { const acknowledgeIndex = script.indexOf('await acknowledge(login)', setupIndex) expect(printedTokenIndex).toBeGreaterThan(firstReturnTokenIndex) expect(configWriteIndex).toBeGreaterThan(setupIndex) - expect(acknowledgeIndex).toBeLessThan(configWriteIndex) + expect(acknowledgeIndex).toBeGreaterThan(configWriteIndex) }) it('serves target-specific setup scripts from the URL path', async () => { diff --git a/apps/tradinggoose/hooks/use-mcp-tools.ts b/apps/tradinggoose/hooks/use-mcp-tools.ts index 9bb966d16..0b65ae1b4 100644 --- a/apps/tradinggoose/hooks/use-mcp-tools.ts +++ b/apps/tradinggoose/hooks/use-mcp-tools.ts @@ -31,7 +31,7 @@ export interface UseMcpToolsResult { mcpTools: McpToolForUI[] isLoading: boolean error: string | null - refreshTools: (forceRefresh?: boolean) => Promise + refreshTools: () => Promise getToolsByServer: (serverId: string) => McpToolForUI[] } @@ -56,7 +56,7 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { }, [servers]) const refreshTools = useCallback( - async (forceRefresh = false) => { + async () => { if (!normalizedWorkspaceId) { setMcpTools([]) setError(null) @@ -68,12 +68,12 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { setError(null) try { - logger.info('Discovering MCP tools', { forceRefresh, workspaceId: normalizedWorkspaceId }) + logger.info('Discovering MCP tools', { workspaceId: normalizedWorkspaceId }) const response = await fetch( `/api/mcp/tools/discover?workspaceId=${encodeURIComponent( normalizedWorkspaceId - )}&refresh=${forceRefresh}` + )}` ) if (!response.ok) { @@ -159,7 +159,7 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { const handleToolsChanged = (event: Event) => { const workspaceId = (event as CustomEvent<{ workspaceId?: string }>).detail?.workspaceId if (workspaceId === normalizedWorkspaceId) { - void refreshTools(true) + void refreshTools() } } diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts index 469922be7..b683dce7f 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -115,7 +115,6 @@ async function applyMcpServerDocument(input: { input.entityId, preserveMcpServerSecretPlaceholders(input.fields, currentFields) ) - mcpService.clearCache(input.workspaceId) } export const listMcpServersServerTool: EntityServerTool> = { diff --git a/apps/tradinggoose/lib/mcp/install-script.ts b/apps/tradinggoose/lib/mcp/install-script.ts index 6fe547170..c2911b7d4 100644 --- a/apps/tradinggoose/lib/mcp/install-script.ts +++ b/apps/tradinggoose/lib/mcp/install-script.ts @@ -161,12 +161,12 @@ async function main() { } const login = await authenticate() - await acknowledge(login) console.log('Using MCP endpoint: ' + mcpUrl) for (const target of targets) { const configPath = runConfigWriter([target, mcpUrl, login.token]) console.log('Configured ' + target + ': ' + configPath) } + await acknowledge(login) return } diff --git a/apps/tradinggoose/lib/mcp/service.ts b/apps/tradinggoose/lib/mcp/service.ts index efbb4b415..9cde48c22 100644 --- a/apps/tradinggoose/lib/mcp/service.ts +++ b/apps/tradinggoose/lib/mcp/service.ts @@ -2,7 +2,6 @@ import { db } from '@tradinggoose/db' import { mcpServers } from '@tradinggoose/db/schema' import { and, eq, isNull } from 'drizzle-orm' import { normalizeEntityFields } from '@/lib/copilot/entity-documents' -import { isTest } from '@/lib/environment' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { createLogger } from '@/lib/logs/console/logger' import { McpClient } from '@/lib/mcp/client' @@ -14,7 +13,6 @@ import type { McpToolResult, McpTransport, } from '@/lib/mcp/types' -import { MCP_CONSTANTS } from '@/lib/mcp/utils' import { generateRequestId } from '@/lib/utils' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' import { publishCreatedSavedEntityListMembers } from '@/lib/yjs/server/apply-entity-state' @@ -39,156 +37,7 @@ export class McpServerConfigError extends Error { } } -interface ToolCache { - tools: McpTool[] - expiry: Date - lastAccessed: Date -} - -interface CacheStats { - totalEntries: number - activeEntries: number - expiredEntries: number - maxCacheSize: number - cacheHitRate: number - memoryUsage: { - approximateBytes: number - entriesEvicted: number - } -} - class McpService { - private toolCache = new Map() - private readonly cacheTimeout = MCP_CONSTANTS.CACHE_TIMEOUT - private readonly maxCacheSize = 1000 - private cleanupInterval: NodeJS.Timeout | null = null - private cacheHits = 0 - private cacheMisses = 0 - private entriesEvicted = 0 - - constructor() { - this.startPeriodicCleanup() - } - - /** - * Start periodic cleanup of expired cache entries - */ - private startPeriodicCleanup(): void { - this.cleanupInterval = setInterval( - () => { - this.cleanupExpiredEntries() - }, - 5 * 60 * 1000 - ) - } - - /** - * Stop periodic cleanup - */ - private stopPeriodicCleanup(): void { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval) - this.cleanupInterval = null - } - } - - /** - * Cleanup expired cache entries - */ - private cleanupExpiredEntries(): void { - const now = new Date() - const expiredKeys: string[] = [] - - this.toolCache.forEach((cache, key) => { - if (cache.expiry <= now) { - expiredKeys.push(key) - } - }) - - expiredKeys.forEach((key) => this.toolCache.delete(key)) - - if (expiredKeys.length > 0) { - logger.debug(`Cleaned up ${expiredKeys.length} expired cache entries`) - } - } - - /** - * Evict least recently used entries when cache exceeds max size - */ - private evictLRUEntries(): void { - if (this.toolCache.size <= this.maxCacheSize) { - return - } - - const entries: { key: string; cache: ToolCache }[] = [] - this.toolCache.forEach((cache, key) => { - entries.push({ key, cache }) - }) - entries.sort((a, b) => a.cache.lastAccessed.getTime() - b.cache.lastAccessed.getTime()) - - const entriesToRemove = this.toolCache.size - this.maxCacheSize + 1 - for (let i = 0; i < entriesToRemove && i < entries.length; i++) { - this.toolCache.delete(entries[i].key) - this.entriesEvicted++ - } - - logger.debug(`Evicted ${entriesToRemove} LRU cache entries to maintain size limit`) - } - - /** - * Get cache entry and update last accessed time - */ - private getCacheEntry(key: string): ToolCache | undefined { - const entry = this.toolCache.get(key) - if (entry) { - entry.lastAccessed = new Date() - this.cacheHits++ - return entry - } - this.cacheMisses++ - return undefined - } - - /** - * Set cache entry with LRU eviction - */ - private setCacheEntry(key: string, tools: McpTool[]): void { - const now = new Date() - const cache: ToolCache = { - tools, - expiry: new Date(now.getTime() + this.cacheTimeout), - lastAccessed: now, - } - - this.toolCache.set(key, cache) - - this.evictLRUEntries() - } - - /** - * Calculate approximate memory usage of cache - */ - private calculateMemoryUsage(): number { - let totalBytes = 0 - - this.toolCache.forEach((cache, key) => { - totalBytes += key.length * 2 // UTF-16 encoding - totalBytes += JSON.stringify(cache.tools).length * 2 - totalBytes += 64 - }) - - return totalBytes - } - - /** - * Dispose of the service and cleanup resources - */ - dispose(): void { - this.stopPeriodicCleanup() - this.toolCache.clear() - logger.info('MCP Service disposed and cleanup stopped') - } - /** * Resolve environment variables in strings */ @@ -314,7 +163,6 @@ class McpService { }, ]) - this.clearCache(input.workspaceId) return { entityId, fields: savedEntityRowToFields('mcp_server', row) } } @@ -416,24 +264,10 @@ class McpService { /** * Discover tools from all workspace servers */ - async discoverTools( - userId: string, - workspaceId: string, - forceRefresh = false - ): Promise { + async discoverTools(userId: string, workspaceId: string): Promise { const requestId = generateRequestId() - const cacheKey = `workspace:${workspaceId}` - try { - if (!forceRefresh) { - const cached = this.getCacheEntry(cacheKey) - if (cached && cached.expiry > new Date()) { - logger.debug(`[${requestId}] Using cached tools for user ${userId}`) - return cached.tools - } - } - logger.info(`[${requestId}] Discovering MCP tools for workspace ${workspaceId}`) const servers = await this.getWorkspaceServers(workspaceId) @@ -471,8 +305,6 @@ class McpService { } }) - this.setCacheEntry(cacheKey, allTools) - logger.info( `[${requestId}] Discovered ${allTools.length} tools from ${servers.length} servers` ) @@ -568,70 +400,6 @@ class McpService { } } - /** - * Clear tool cache for a workspace or all workspaces - */ - clearCache(workspaceId?: string): void { - if (workspaceId) { - const workspaceCacheKey = `workspace:${workspaceId}` - this.toolCache.delete(workspaceCacheKey) - logger.debug(`Cleared MCP tool cache for workspace ${workspaceId}`) - } else { - this.toolCache.clear() - this.cacheHits = 0 - this.cacheMisses = 0 - this.entriesEvicted = 0 - logger.debug('Cleared all MCP tool cache and reset statistics') - } - } - - /** - * Get comprehensive cache statistics - */ - getCacheStats(): CacheStats { - const entries: { key: string; cache: ToolCache }[] = [] - this.toolCache.forEach((cache, key) => { - entries.push({ key, cache }) - }) - - const now = new Date() - const activeEntries = entries.filter(({ cache }) => cache.expiry > now) - const totalRequests = this.cacheHits + this.cacheMisses - const hitRate = totalRequests > 0 ? this.cacheHits / totalRequests : 0 - - return { - totalEntries: entries.length, - activeEntries: activeEntries.length, - expiredEntries: entries.length - activeEntries.length, - maxCacheSize: this.maxCacheSize, - cacheHitRate: Math.round(hitRate * 100) / 100, - memoryUsage: { - approximateBytes: this.calculateMemoryUsage(), - entriesEvicted: this.entriesEvicted, - }, - } - } } export const mcpService = new McpService() - -/** - * Setup process signal handlers for graceful shutdown - */ -export function setupMcpServiceCleanup() { - if (isTest) { - return - } - - const cleanup = () => { - mcpService.dispose() - } - - process.on('SIGTERM', cleanup) - process.on('SIGINT', cleanup) - - return () => { - process.removeListener('SIGTERM', cleanup) - process.removeListener('SIGINT', cleanup) - } -} diff --git a/apps/tradinggoose/lib/mcp/utils.ts b/apps/tradinggoose/lib/mcp/utils.ts index c1ff4f7dc..d3ee5d2d5 100644 --- a/apps/tradinggoose/lib/mcp/utils.ts +++ b/apps/tradinggoose/lib/mcp/utils.ts @@ -6,7 +6,6 @@ import type { McpApiResponse } from '@/lib/mcp/types' */ export const MCP_CONSTANTS = { EXECUTION_TIMEOUT: 60000, - CACHE_TIMEOUT: 5 * 60 * 1000, DEFAULT_RETRIES: 3, DEFAULT_CONNECTION_TIMEOUT: 30000, } as const diff --git a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx index 6fcc50e0b..9705fa66a 100644 --- a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx @@ -286,7 +286,7 @@ export function EditorMcpWidgetBody({ copy.failedToRefreshMcpServer ) await refreshServer(workspaceId, selectedServerId, refreshResult?.data) - await refreshTools(true) + await refreshTools() await fetchServers(workspaceId) } catch (refreshError) { console.error('Failed to refresh MCP server tools', refreshError) diff --git a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx index ec41c564c..771abc408 100644 --- a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx @@ -351,7 +351,7 @@ const ListMcpWidgetContent = ({ if (!workspaceId || !permissions.canEdit) return await renameServer(workspaceId, serverId, name) - await refreshTools(true) + await refreshTools() }, [permissions.canEdit, refreshTools, renameServer, workspaceId] ) @@ -364,7 +364,7 @@ const ListMcpWidgetContent = ({ setDeletingIds((prev) => new Set(prev).add(serverId)) try { await deleteServer(workspaceId, serverId) - await refreshTools(true) + await refreshTools() if (selectedServerId === serverId) { handleSelectServer(null) } From c17f53b25e9b19fc00900fcf79d322fcaa566de5 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sat, 27 Jun 2026 23:41:19 -0600 Subject: [PATCH 255/284] feat(mcp): harden MCP server workspace handling - Require at least one accessible workspace for Copilot MCP initialization\n- Broaden MCP server update payloads while keeping validation strict\n- Limit MCP server listing to saved list members and trim names on input\n\nCo-authored-by: Codex \nCo-authored-by: BWJ2310 \nCo-authored-by: BWJ2310-backup --- .../app/api/copilot/mcp/route.test.ts | 13 ++++++ .../tradinggoose/app/api/copilot/mcp/route.ts | 14 +++--- .../app/api/mcp/servers/[id]/route.ts | 15 ++++--- .../tradinggoose/app/api/mcp/servers/route.ts | 43 +++++++++++-------- .../app/api/mcp/servers/schema.ts | 13 +++--- 5 files changed, 62 insertions(+), 36 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index 48433dc89..d6a04f86f 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -152,6 +152,19 @@ describe('Copilot MCP route', () => { expect(body.result.instructions).toContain('trusted personal coding agents') expect(body.result.instructions).toContain('Mutating tools execute directly') expect(body.result.instructions).toContain('authenticated MCP key') + expect(body.result.instructions).not.toContain('No accessible workspaces') + }) + + it('treats missing workspaces as a bootstrap invariant failure instead of MCP onboarding', async () => { + const { POST } = await import('./route') + mockGetUserWorkspaces.mockResolvedValueOnce([]) + + const response = await POST(createMcpRequest(initializeRequest(5))) + const body = await response.json() + + expect(body.error.code).toBe(-32603) + expect(body.error.data.code).toBe('server_tool_execution_failed') + expect(JSON.stringify(body)).not.toContain('No accessible workspaces') }) it('accepts a case-insensitive bearer auth scheme', async () => { diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index 22a14898e..bdc616112 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -112,13 +112,13 @@ async function authenticateCopilotMcpRequest( async function buildInstructions(userId: string) { const workspaces = await getUserWorkspaces({ userId, autoCreate: false }) - const workspaceLines = - workspaces.length > 0 - ? workspaces.map( - (workspace) => - `- ${workspace.name}: workspaceId=${workspace.id}, permissions=${workspace.permissions}` - ) - : ['- No accessible workspaces were found.'] + if (workspaces.length === 0) { + throw new Error('Authenticated TradingGoose users must have at least one workspace') + } + const workspaceLines = workspaces.map( + (workspace) => + `- ${workspace.name}: workspaceId=${workspace.id}, permissions=${workspace.permissions}` + ) return [ 'TradingGoose Copilot MCP exposes server-side Copilot tools for trusted personal coding agents, including direct mutation tools.', diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts index 86433a533..49b619fae 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts @@ -10,14 +10,14 @@ import { applySavedEntityState, SavedEntityPersistenceError, } from '@/lib/yjs/server/apply-entity-state' -import { RenameMcpServerSchema } from '../schema' +import { UpdateMcpServerSchema } from '../schema' const logger = createLogger('McpServerAPI') export const dynamic = 'force-dynamic' /** - * PATCH - Rename an MCP server in the workspace (requires write permission) + * PATCH - Update an MCP server in the workspace (requires write permission) */ export const PATCH = withMcpAuth('write')( async ( @@ -30,7 +30,7 @@ export const PATCH = withMcpAuth('write')( try { const rawBody = getParsedBody(request) || (await request.json()) - const parseResult = RenameMcpServerSchema.safeParse(rawBody) + const parseResult = UpdateMcpServerSchema.safeParse(rawBody) if (!parseResult.success) { return createMcpErrorResponse( new Error(`Invalid request body: ${parseResult.error.message}`), @@ -66,17 +66,18 @@ export const PATCH = withMcpAuth('write')( ) } + const { workspaceId: _bodyWorkspaceId, ...updates } = body const fields = savedEntityRowToFields('mcp_server', server) - const name = body.name.trim() - await applySavedEntityState('mcp_server', serverId, { ...fields, name }) + const nextFields = { ...fields, ...updates } + await applySavedEntityState('mcp_server', serverId, nextFields) logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`) return createMcpSuccessResponse({ server: { id: serverId, workspaceId, - name, - enabled: fields.enabled !== false, + name: String(nextFields.name ?? ''), + enabled: nextFields.enabled !== false, }, }) } catch (error) { diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index fc2e42cce..40f346919 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -1,6 +1,6 @@ import { db } from '@tradinggoose/db' import { mcpServers } from '@tradinggoose/db/schema' -import { and, eq, isNull } from 'drizzle-orm' +import { and, eq, inArray, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' @@ -27,23 +27,32 @@ export const GET = withMcpAuth('read')( logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) const listMembers = await requireSavedEntityListMembers('mcp_server', workspaceId) + const listMemberIds = listMembers.map((member) => member.entityId) const statusById = new Map( - ( - await db - .select({ - id: mcpServers.id, - name: mcpServers.name, - enabled: mcpServers.enabled, - updatedAt: mcpServers.updatedAt, - connectionStatus: mcpServers.connectionStatus, - lastError: mcpServers.lastError, - toolCount: mcpServers.toolCount, - lastConnected: mcpServers.lastConnected, - lastToolsRefresh: mcpServers.lastToolsRefresh, - }) - .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) - ).map((row) => [row.id, row]) + listMemberIds.length === 0 + ? [] + : ( + await db + .select({ + id: mcpServers.id, + name: mcpServers.name, + enabled: mcpServers.enabled, + updatedAt: mcpServers.updatedAt, + connectionStatus: mcpServers.connectionStatus, + lastError: mcpServers.lastError, + toolCount: mcpServers.toolCount, + lastConnected: mcpServers.lastConnected, + lastToolsRefresh: mcpServers.lastToolsRefresh, + }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, workspaceId), + inArray(mcpServers.id, listMemberIds), + isNull(mcpServers.deletedAt) + ) + ) + ).map((row) => [row.id, row]) ) const servers = listMembers.flatMap((server) => { const status = statusById.get(server.entityId) diff --git a/apps/tradinggoose/app/api/mcp/servers/schema.ts b/apps/tradinggoose/app/api/mcp/servers/schema.ts index 3d29685ee..e2ad305e4 100644 --- a/apps/tradinggoose/app/api/mcp/servers/schema.ts +++ b/apps/tradinggoose/app/api/mcp/servers/schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod' const McpServerBaseSchema = z.object({ - name: z.string().min(1), + name: z.string().trim().min(1), description: z.string().optional(), transport: z.enum(['http', 'sse', 'streamable-http']), url: z.string().min(1), @@ -16,7 +16,10 @@ const McpServerBaseSchema = z.object({ export const CreateMcpServerSchema = McpServerBaseSchema -export const RenameMcpServerSchema = z.object({ - name: z.string().trim().min(1), - workspaceId: z.string().optional(), -}) +export const UpdateMcpServerSchema = McpServerBaseSchema.partial() + .extend({ + workspaceId: z.string().optional(), + }) + .refine(({ workspaceId: _workspaceId, ...updates }) => Object.keys(updates).length > 0, { + message: 'At least one MCP server field is required', + }) From e74e3a99eb564c735cc73320d4ad9395c690fcd0 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 00:17:02 -0600 Subject: [PATCH 256/284] fix(mcp): derive base URLs from request origin and acknowledge delivered retries Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/auth/mcp/authorize/route.test.ts | 31 ++++++++------ .../app/api/auth/mcp/authorize/route.ts | 12 +++--- .../app/api/auth/mcp/start/route.test.ts | 4 +- .../app/api/auth/mcp/start/route.ts | 2 +- .../app/mcp/[[...command]]/route.test.ts | 6 +-- .../app/mcp/[[...command]]/route.ts | 2 +- apps/tradinggoose/lib/mcp/auth.test.ts | 42 +++++++++++++++---- apps/tradinggoose/lib/mcp/auth.ts | 4 +- apps/tradinggoose/lib/urls/utils.test.ts | 6 +++ apps/tradinggoose/lib/urls/utils.ts | 8 +++- 10 files changed, 81 insertions(+), 36 deletions(-) diff --git a/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts index fdfc5bc5a..c0c846db4 100644 --- a/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts @@ -33,18 +33,19 @@ vi.mock('@/lib/mcp/auth', () => ({ })) vi.mock('@/lib/urls/utils', () => ({ - getBaseUrl: () => mockGetBaseUrl(), + getBaseUrl: (...args: unknown[]) => mockGetBaseUrl(...args), })) function createAuthorizeRequest( body: Record, - headers: Record = {} + headers: Record = {}, + origin = 'https://studio.example.test' ) { - return new NextRequest('https://studio.example.test/api/auth/mcp/authorize', { + return new NextRequest(`${origin}/api/auth/mcp/authorize`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', - origin: 'https://studio.example.test', + origin, ...headers, }, body: new URLSearchParams(body), @@ -54,7 +55,9 @@ function createAuthorizeRequest( describe('MCP authorize route', () => { beforeEach(() => { vi.clearAllMocks() - mockGetBaseUrl.mockReturnValue('https://studio.example.test') + mockGetBaseUrl.mockImplementation((request?: NextRequest) => + request ? new URL(request.url).origin : 'https://studio.example.test' + ) mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) mockGetSessionCookie.mockReturnValue(null) mockApproveMcpDeviceLogin.mockResolvedValue({ @@ -68,17 +71,21 @@ describe('MCP authorize route', () => { const { POST } = await import('./route') const response = await POST( - createAuthorizeRequest({ - action: 'approve', - approvalToken: 'approval-token', - code: 'login-code', - locale: 'es', - }) + createAuthorizeRequest( + { + action: 'approve', + approvalToken: 'approval-token', + code: 'login-code', + locale: 'es', + }, + {}, + 'https://preview.example.test' + ) ) expect(response.status).toBe(307) expect(response.headers.get('location')).toBe( - 'https://studio.example.test/es/mcp/authorize?status=approved' + 'https://preview.example.test/es/mcp/authorize?status=approved' ) expect(mockApproveMcpDeviceLogin).toHaveBeenCalledWith({ approvalToken: 'approval-token', diff --git a/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts b/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts index f1d715cce..0bf1baf02 100644 --- a/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts @@ -7,15 +7,15 @@ import { normalizeLocaleCode } from '@/i18n/utils' export const dynamic = 'force-dynamic' -function redirectToAuthorizeStatus(locale: string, status: string) { - const url = new URL(`/${normalizeLocaleCode(locale)}/mcp/authorize`, getBaseUrl()) +function redirectToAuthorizeStatus(request: NextRequest, locale: string, status: string) { + const url = new URL(`/${normalizeLocaleCode(locale)}/mcp/authorize`, getBaseUrl(request)) url.searchParams.set('status', status) return NextResponse.redirect(url) } function redirectToLogin(request: NextRequest, locale: string, code: string) { const normalizedLocale = normalizeLocaleCode(locale) - const url = new URL(`/${normalizedLocale}/login`, getBaseUrl()) + const url = new URL(`/${normalizedLocale}/login`, getBaseUrl(request)) if (getSessionCookie(request.headers)) { url.searchParams.set('reauth', '1') } @@ -27,7 +27,7 @@ function redirectToLogin(request: NextRequest, locale: string, code: string) { } function hasTrustedFormOrigin(request: NextRequest) { - const trustedOrigin = new URL(getBaseUrl()).origin + const trustedOrigin = new URL(getBaseUrl(request)).origin const submittedOrigin = request.headers.get('origin') if (submittedOrigin) { try { @@ -65,7 +65,7 @@ export async function POST(request: NextRequest) { !approvalToken || !hasTrustedFormOrigin(request) ) { - return redirectToAuthorizeStatus(locale, 'invalid') + return redirectToAuthorizeStatus(request, locale, 'invalid') } const session = await getSession(request.headers) @@ -78,5 +78,5 @@ export async function POST(request: NextRequest) { ? await approveMcpDeviceLogin({ approvalToken, code, userId: session.user.id }) : await cancelMcpDeviceLogin({ approvalToken, code, userId: session.user.id }) - return redirectToAuthorizeStatus(locale, result.status) + return redirectToAuthorizeStatus(request, locale, result.status) } diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts index 6a103951e..b9e88b4c4 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts @@ -53,7 +53,7 @@ describe('MCP login start route', () => { it('starts a browser approval login and returns an absolute approval URL', async () => { const { POST } = await import('./route') - const request = new NextRequest('https://studio.example.test/api/auth/mcp/start', { + const request = new NextRequest('https://preview.example.test/api/auth/mcp/start', { method: 'POST', }) @@ -65,7 +65,7 @@ describe('MCP login start route', () => { verificationKey: 'verification-key', expiresAt: '2026-06-19T12:00:00.000Z', intervalSeconds: 2, - authorizeUrl: 'https://studio.example.test/mcp/authorize?code=login-code', + authorizeUrl: 'https://preview.example.test/mcp/authorize?code=login-code', }) expect(mockCheckPublicApiEndpointRateLimit).toHaveBeenCalledWith(request, 'mcp-auth-start') expect(mockStartMcpDeviceLogin).toHaveBeenCalledWith() diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.ts index 54b53299a..a7911b4ab 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.ts @@ -16,7 +16,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'API key access is not configured' }, { status: 503 }) } - const baseUrl = getBaseUrl() + const baseUrl = getBaseUrl(request) const login = await startMcpDeviceLogin() const authorizeUrl = new URL('/mcp/authorize', baseUrl) authorizeUrl.searchParams.set('code', login.code) diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts index 14367a8e7..d2c8b7da3 100644 --- a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts @@ -103,7 +103,7 @@ describe('MCP install route', () => { expect(script).toContain('TARGETS="codex"') }) - it('uses configured and quoted installer base URLs', async () => { + it('uses request-origin and quoted installer base URLs', async () => { const response = await callInstaller( '/mcp', undefined, @@ -112,8 +112,8 @@ describe('MCP install route', () => { ) const script = await response.text() - expect(script).toContain("BASE_URL='https://studio.example.test'") - expect(script).not.toContain('request.example.test') + expect(script).toContain("BASE_URL='https://request.example.test'") + expect(script).not.toContain("BASE_URL='https://studio.example.test'") const shellScript = buildMcpInstallScript( "https://studio.example.test/$(touch pwn)`bad`'quote", diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.ts index 8bd242b79..538491b0b 100644 --- a/apps/tradinggoose/app/mcp/[[...command]]/route.ts +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.ts @@ -58,7 +58,7 @@ export async function GET( const format = resolveScriptFormat(request) - return new NextResponse(buildMcpInstallScript(getBaseUrl(), { ...options, format }), { + return new NextResponse(buildMcpInstallScript(getBaseUrl(request), { ...options, format }), { headers: { 'Cache-Control': 'no-store', 'Content-Type': diff --git a/apps/tradinggoose/lib/mcp/auth.test.ts b/apps/tradinggoose/lib/mcp/auth.test.ts index 2faec2cea..f326ff6c5 100644 --- a/apps/tradinggoose/lib/mcp/auth.test.ts +++ b/apps/tradinggoose/lib/mcp/auth.test.ts @@ -139,19 +139,22 @@ describe('MCP device login auth', () => { expect(db.delete).toHaveBeenCalled() }) - it('returns the same approved API key across repeated polls before acknowledgement', async () => { - const { pollMcpDeviceLogin, startMcpDeviceLogin } = await import('./auth') + it('returns the same approved API key across repeated polls and acknowledges delivered retries', async () => { + const { acknowledgeMcpDeviceLogin, pollMcpDeviceLogin, startMcpDeviceLogin } = await import( + './auth' + ) const login = await startMcpDeviceLogin() const fields = readCodeFields(login.code) + const approvedState = { + status: 'approved', + createdAt: fields.createdAt, + verificationKeyHash: fields.verificationKeyHash, + approvedAt: '2026-06-19T12:01:00.000Z', + userId: 'user-1', + } const approvedRow = { id: 'device-login-row', - value: JSON.stringify({ - status: 'approved', - createdAt: fields.createdAt, - verificationKeyHash: fields.verificationKeyHash, - approvedAt: '2026-06-19T12:01:00.000Z', - userId: 'user-1', - }), + value: JSON.stringify(approvedState), expiresAt: fields.expiresAt, } selectRows([approvedRow], [approvedRow]) @@ -162,5 +165,26 @@ describe('MCP device login auth', () => { expect(firstPoll).toEqual(secondPoll) expect(firstPoll.status).toBe('approved') + if (firstPoll.status !== 'approved') throw new Error('Expected approved device login') + + selectRows([ + { + id: 'device-login-row', + value: JSON.stringify({ + ...approvedState, + deliveredAt: '2026-06-19T12:02:00.000Z', + }), + expiresAt: fields.expiresAt, + }, + ]) + + await expect( + acknowledgeMcpDeviceLogin({ + apiKey: firstPoll.apiKey, + code: login.code, + verificationKey: login.verificationKey, + }) + ).resolves.toEqual({ status: 'acknowledged' }) + expect(db.transaction).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index 635b13c1f..f627d4366 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -443,7 +443,9 @@ export async function acknowledgeMcpDeviceLogin({ } if (login.state.status === 'approved' && login.state.deliveredAt) { - return { status: 'expired' } + return hashValue(plainApiKey) === hashValue(createDeviceLoginApiKey(code, verificationKey)) + ? { status: 'acknowledged' } + : { status: 'invalid' } } if (login.state.status !== 'approved') { diff --git a/apps/tradinggoose/lib/urls/utils.test.ts b/apps/tradinggoose/lib/urls/utils.test.ts index bb6abc2c5..73061ad17 100644 --- a/apps/tradinggoose/lib/urls/utils.test.ts +++ b/apps/tradinggoose/lib/urls/utils.test.ts @@ -22,6 +22,12 @@ describe('url helpers', () => { expect(getBaseUrl()).toBe('https://www.tradinggoose.ai') }) + it('uses the request origin when a request is provided', () => { + const request = new Request('https://preview.example.test/api/auth/mcp/start') + + expect(getBaseUrl(request)).toBe('https://preview.example.test') + }) + it('treats preview and production as configured app URLs', () => { mockEnv.NEXT_PUBLIC_APP_URL = 'https://preview.tradinggoose.ai' diff --git a/apps/tradinggoose/lib/urls/utils.ts b/apps/tradinggoose/lib/urls/utils.ts index d78da6c2b..1c46faddf 100644 --- a/apps/tradinggoose/lib/urls/utils.ts +++ b/apps/tradinggoose/lib/urls/utils.ts @@ -1,6 +1,12 @@ import { getEnv } from '@/lib/env' -export function getBaseUrl(): string { +type RequestOrigin = Pick + +export function getBaseUrl(request?: RequestOrigin): string { + if (request) { + return new URL(request.url).origin + } + const value = getEnv('NEXT_PUBLIC_APP_URL')?.trim() if (!value) { From 7075bc8208225f39bdc620e949814729920f811d Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 00:57:54 -0600 Subject: [PATCH 257/284] fix(copilot): accept MCP 2025-06-18 and workflow variable errors Support the newer initialize protocol version, fix server metadata sourcing, and surface workflow variable document errors as retryable 422s. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/copilot/mcp/route.test.ts | 4 ++-- apps/tradinggoose/app/api/copilot/mcp/route.ts | 5 +++-- apps/tradinggoose/app/api/mcp/servers/route.ts | 6 ++---- .../lib/copilot/server-tool-errors.test.ts | 6 ++++++ apps/tradinggoose/lib/copilot/server-tool-errors.ts | 10 ++++++++++ 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index d6a04f86f..d47452d66 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -60,7 +60,7 @@ function createMcpRequest(body: unknown, authorization = 'Bearer sk-tradinggoose }) } -function initializeRequest(id: string | number = 1, protocolVersion = '2025-03-26') { +function initializeRequest(id: string | number = 1, protocolVersion = '2025-06-18') { return { jsonrpc: '2.0', id, @@ -354,7 +354,7 @@ describe('Copilot MCP route', () => { expect((await invalidInitializeResponse.json()).error.code).toBe(-32602) const unsupportedVersionBody = await unsupportedVersionResponse.json() expect(unsupportedVersionBody.error.code).toBe(-32000) - expect(unsupportedVersionBody.error.data.supportedProtocolVersions).toEqual(['2025-03-26']) + expect(unsupportedVersionBody.error.data.supportedProtocolVersions).toEqual(['2025-06-18', '2025-03-26']) }) it('returns per-entry invalid request errors for malformed batches', async () => { diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index bdc616112..ff612e34c 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -13,6 +13,7 @@ import { getUserWorkspaces } from '@/lib/workspaces/service' export const dynamic = 'force-dynamic' const MCP_PROTOCOL_VERSION = '2025-03-26' +const MCP_NEGOTIABLE_PROTOCOL_VERSIONS = ['2025-06-18', MCP_PROTOCOL_VERSION] const SERVER_NAME = 'TradingGoose' const SERVER_VERSION = '0.1.0' const MAX_JSON_RPC_BATCH_SIZE = 10 @@ -212,9 +213,9 @@ async function handleJsonRpcRequest(entry: unknown, auth: AuthenticatedMcpUser) if (!protocolVersion) { return jsonRpcError(id, -32602, 'Invalid initialize params') } - if (protocolVersion !== MCP_PROTOCOL_VERSION) { + if (!MCP_NEGOTIABLE_PROTOCOL_VERSIONS.includes(protocolVersion)) { return jsonRpcError(id, -32000, 'Unsupported MCP protocol version', { - supportedProtocolVersions: [MCP_PROTOCOL_VERSION], + supportedProtocolVersions: MCP_NEGOTIABLE_PROTOCOL_VERSIONS, }) } diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 40f346919..99607980c 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -35,8 +35,6 @@ export const GET = withMcpAuth('read')( await db .select({ id: mcpServers.id, - name: mcpServers.name, - enabled: mcpServers.enabled, updatedAt: mcpServers.updatedAt, connectionStatus: mcpServers.connectionStatus, lastError: mcpServers.lastError, @@ -62,8 +60,8 @@ export const GET = withMcpAuth('read')( return { id: server.entityId, - name: status.name, - enabled: status.enabled !== false, + name: server.entityName, + enabled: server.enabled !== false, workspaceId, updatedAt: status.updatedAt?.toISOString(), connectionStatus: status.connectionStatus, diff --git a/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts b/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts index f1e480f28..739e776d4 100644 --- a/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts +++ b/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts @@ -99,6 +99,10 @@ describe('copilot server tool errors', () => { 'make_api_request', new Error('socket hang up at db.internal:5432') ) + const variableResponse = buildCopilotServerToolErrorResponse( + 'edit_workflow_variable', + new Error('Invalid edited workflow variables: Missing removedVariableIds.') + ) expect(response).toEqual({ status: 500, @@ -109,6 +113,8 @@ describe('copilot server tool errors', () => { }, }) expect(response.body.error).not.toContain('db.internal') + expect(variableResponse.status).toBe(422) + expect(variableResponse.body.error).toContain('removedVariableIds') }) it('returns a structured 422 payload for tool argument schema failures', () => { diff --git a/apps/tradinggoose/lib/copilot/server-tool-errors.ts b/apps/tradinggoose/lib/copilot/server-tool-errors.ts index ff0d78356..b07c7434d 100644 --- a/apps/tradinggoose/lib/copilot/server-tool-errors.ts +++ b/apps/tradinggoose/lib/copilot/server-tool-errors.ts @@ -182,6 +182,16 @@ export function buildCopilotServerToolErrorResponse( return structuredError } } + if (toolName === 'edit_workflow_variable' && /^(Invalid edited workflow variables:|Duplicate workflow variable|Unsupported workflow variable|Unsupported documentFormat ")/.test(message)) { + return { + status: 422, + body: { + code: 'invalid_workflow_variable_document', + error: message, + retryable: true, + }, + } + } return { status: 500, From a1c329d638ae20cc632f7864845276a9c6e0dd87 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 01:29:42 -0600 Subject: [PATCH 258/284] fix(mcp): support protocol negotiation and install ack timing Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/copilot/mcp/route.test.ts | 22 ++++++++++++++++++- .../tradinggoose/app/api/copilot/mcp/route.ts | 14 ++++++++++-- .../app/mcp/[[...command]]/route.test.ts | 3 ++- apps/tradinggoose/lib/mcp/install-script.ts | 2 +- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index d47452d66..83e69c2f4 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -49,12 +49,17 @@ vi.mock('@/lib/workspaces/service', () => ({ getUserWorkspaces: (...args: unknown[]) => mockGetUserWorkspaces(...args), })) -function createMcpRequest(body: unknown, authorization = 'Bearer sk-tradinggoose-test') { +function createMcpRequest( + body: unknown, + authorization = 'Bearer sk-tradinggoose-test', + headers: Record = {} +) { return new NextRequest('https://studio.example.test/api/copilot/mcp', { method: 'POST', headers: { authorization, 'content-type': 'application/json', + ...headers, }, body: JSON.stringify(body), }) @@ -348,6 +353,16 @@ describe('Copilot MCP route', () => { createMcpRequest({ jsonrpc: '2.0', id: 9, method: 'initialize', params: {} }) ) const unsupportedVersionResponse = await POST(createMcpRequest(initializeRequest(10, '1.0'))) + const notificationResponse = await POST( + createMcpRequest({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }) + ) + const wrongProtocolHeaderResponse = await POST( + createMcpRequest( + { jsonrpc: '2.0', id: 11, method: 'tools/list' }, + 'Bearer sk-tradinggoose-test', + { 'MCP-Protocol-Version': '2025-06-18' } + ) + ) expect((await invalidJsonRpcResponse.json()).error.code).toBe(-32600) expect((await nullIdResponse.json()).error.code).toBe(-32600) @@ -355,6 +370,11 @@ describe('Copilot MCP route', () => { const unsupportedVersionBody = await unsupportedVersionResponse.json() expect(unsupportedVersionBody.error.code).toBe(-32000) expect(unsupportedVersionBody.error.data.supportedProtocolVersions).toEqual(['2025-06-18', '2025-03-26']) + expect(notificationResponse.status).toBe(202) + expect(wrongProtocolHeaderResponse.status).toBe(400) + expect((await wrongProtocolHeaderResponse.json()).error.message).toBe( + 'Unsupported MCP protocol version' + ) }) it('returns per-entry invalid request errors for malformed batches', async () => { diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index ff612e34c..d7e73f959 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -14,6 +14,7 @@ export const dynamic = 'force-dynamic' const MCP_PROTOCOL_VERSION = '2025-03-26' const MCP_NEGOTIABLE_PROTOCOL_VERSIONS = ['2025-06-18', MCP_PROTOCOL_VERSION] +const MCP_ACCEPTED_RESPONSE_INIT = { status: 202, headers: { 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION } } const SERVER_NAME = 'TradingGoose' const SERVER_VERSION = '0.1.0' const MAX_JSON_RPC_BATCH_SIZE = 10 @@ -308,6 +309,15 @@ export async function POST(request: NextRequest) { return mcpJsonResponse(jsonRpcError(null, -32700, 'Invalid JSON body'), { status: 400 }) } + const requestProtocolVersion = request.headers.get('MCP-Protocol-Version') + const isInitialize = Array.isArray(body) ? body.some(isInitializeRequest) : isInitializeRequest(body) + if (requestProtocolVersion && !isInitialize && requestProtocolVersion !== MCP_PROTOCOL_VERSION) { + return mcpJsonResponse( + jsonRpcError(null, -32000, 'Unsupported MCP protocol version'), + { status: 400 } + ) + } + if (Array.isArray(body)) { if (body.length === 0) { return mcpJsonResponse(jsonRpcError(null, -32600, 'Invalid JSON-RPC request')) @@ -329,9 +339,9 @@ export async function POST(request: NextRequest) { return responses.length > 0 ? mcpJsonResponse(responses) - : new NextResponse(null, { status: 204 }) + : new NextResponse(null, MCP_ACCEPTED_RESPONSE_INIT) } const response = await handleJsonRpcRequest(body, auth) - return response ? mcpJsonResponse(response) : new NextResponse(null, { status: 204 }) + return response ? mcpJsonResponse(response) : new NextResponse(null, MCP_ACCEPTED_RESPONSE_INIT) } diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts index d2c8b7da3..5e639985f 100644 --- a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts @@ -91,7 +91,8 @@ describe('MCP install route', () => { const acknowledgeIndex = script.indexOf('await acknowledge(login)', setupIndex) expect(printedTokenIndex).toBeGreaterThan(firstReturnTokenIndex) expect(configWriteIndex).toBeGreaterThan(setupIndex) - expect(acknowledgeIndex).toBeGreaterThan(configWriteIndex) + expect(acknowledgeIndex).toBeGreaterThan(setupIndex) + expect(acknowledgeIndex).toBeLessThan(configWriteIndex) }) it('serves target-specific setup scripts from the URL path', async () => { diff --git a/apps/tradinggoose/lib/mcp/install-script.ts b/apps/tradinggoose/lib/mcp/install-script.ts index c2911b7d4..6fe547170 100644 --- a/apps/tradinggoose/lib/mcp/install-script.ts +++ b/apps/tradinggoose/lib/mcp/install-script.ts @@ -161,12 +161,12 @@ async function main() { } const login = await authenticate() + await acknowledge(login) console.log('Using MCP endpoint: ' + mcpUrl) for (const target of targets) { const configPath = runConfigWriter([target, mcpUrl, login.token]) console.log('Configured ' + target + ': ' + configPath) } - await acknowledge(login) return } From c81fd2391039d836733b13c2b8036c9667cdd8c0 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 01:57:09 -0600 Subject: [PATCH 259/284] refactor(realtime): rename editable bootstrap helpers Rename workflow and saved-entity helper functions to reflect realtime behavior. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/mcp/servers/route.ts | 4 ++-- .../app/api/workflows/[id]/autolayout/route.ts | 4 ++-- .../app/api/workflows/[id]/deploy/route.test.ts | 2 +- .../app/api/workflows/[id]/deploy/route.ts | 4 ++-- .../app/api/workflows/[id]/duplicate/route.test.ts | 2 +- .../app/api/workflows/[id]/duplicate/route.ts | 8 ++++---- .../app/api/workflows/[id]/route.test.ts | 2 +- apps/tradinggoose/app/api/workflows/[id]/route.ts | 4 ++-- .../app/api/workflows/[id]/status/route.test.ts | 2 +- .../app/api/workflows/[id]/status/route.ts | 4 ++-- .../app/api/workflows/yaml/export/route.test.ts | 2 +- .../app/api/workflows/yaml/export/route.ts | 4 ++-- .../copilot/tools/server/entities/indicator.test.ts | 2 +- .../lib/copilot/tools/server/entities/shared.test.ts | 2 +- .../lib/copilot/tools/server/entities/shared.ts | 4 ++-- apps/tradinggoose/lib/custom-tools/operations.ts | 4 ++-- .../tradinggoose/lib/indicators/custom/operations.ts | 6 +++--- apps/tradinggoose/lib/knowledge/service.ts | 4 ++-- apps/tradinggoose/lib/skills/operations.test.ts | 2 +- apps/tradinggoose/lib/skills/operations.ts | 4 ++-- apps/tradinggoose/lib/workflows/db-helpers.test.ts | 8 ++++---- apps/tradinggoose/lib/workflows/db-helpers.ts | 4 ++-- .../lib/workflows/execution-runner.test.ts | 12 ++++++------ apps/tradinggoose/lib/workflows/execution-runner.ts | 4 ++-- .../lib/yjs/server/bootstrap-review-target.ts | 6 +++--- 25 files changed, 52 insertions(+), 52 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 99607980c..332433e7e 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -7,7 +7,7 @@ import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { McpServerConfigError, mcpService } from '@/lib/mcp/service' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' -import { requireSavedEntityListMembers } from '@/lib/yjs/server/bootstrap-review-target' +import { requireSavedEntityRealtimeListMembers } from '@/lib/yjs/server/bootstrap-review-target' import { deleteYjsSessionInSocketServer, notifyEntityListMemberRemoved, @@ -26,7 +26,7 @@ export const GET = withMcpAuth('read')( try { logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) - const listMembers = await requireSavedEntityListMembers('mcp_server', workspaceId) + const listMembers = await requireSavedEntityRealtimeListMembers('mcp_server', workspaceId) const listMemberIds = listMembers.map((member) => member.entityId) const statusById = new Map( listMemberIds.length === 0 diff --git a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts index 209efb57d..3e3873dda 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applyAutoLayout } from '@/lib/workflows/autolayout' -import { requireEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { requireWorkflowRealtimeState } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' @@ -62,7 +62,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } } else { logger.info(`[${requestId}] Loading blocks from current workflow state`) - currentWorkflowData = await requireEditableWorkflowState(workflowId) + currentWorkflowData = await requireWorkflowRealtimeState(workflowId) } if (!currentWorkflowData) { diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts index bb2a4abd6..b5569944c 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts @@ -37,7 +37,7 @@ describe('Workflow Deploy API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ deployWorkflow: vi.fn(), - requireEditableWorkflowState: (...args: unknown[]) => mockLoadWorkflowState(...args), + requireWorkflowRealtimeState: (...args: unknown[]) => mockLoadWorkflowState(...args), })) vi.doMock('@/lib/chat/published-deployment', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts index 286d3dcd9..59b99c661 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts @@ -7,7 +7,7 @@ import { } from '@/lib/chat/published-deployment' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { deployWorkflow, requireEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { deployWorkflow, requireWorkflowRealtimeState } from '@/lib/workflows/db-helpers' import { hasWorkflowChanged, validateWorkflowPermissions } from '@/lib/workflows/utils' import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' import { pauseMonitorsMissingDeployedTrigger } from '@/app/api/monitors/shared' @@ -103,7 +103,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .limit(1) if (active?.state) { - const currentState = await requireEditableWorkflowState(id) + const currentState = await requireWorkflowRealtimeState(id) if (currentState) { needsRedeployment = hasWorkflowChanged(currentState, active.state as any) } diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts index 8fcbe5a7b..93a03f213 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts @@ -111,7 +111,7 @@ describe('Workflow Duplicate API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ isWorkflowRealtimeRequiredError: vi.fn(() => false), - requireEditableWorkflowState: loadWorkflowStateMock, + requireWorkflowRealtimeState: loadWorkflowStateMock, regenerateWorkflowStateIds: regenerateWorkflowStateIdsMock, saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts index 8464434d1..92dc27e0a 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -10,7 +10,7 @@ import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' import { regenerateWorkflowStateIds, - requireEditableWorkflowState, + requireWorkflowRealtimeState, saveWorkflowToNormalizedTables, } from '@/lib/workflows/db-helpers' import { remapVariableIds } from '@/lib/workflows/import-export' @@ -28,11 +28,11 @@ const DuplicateRequestSchema = z.object({ folderId: z.string().nullable().optional(), }) -async function loadSourceWorkflowArtifacts(sourceWorkflowId: string): Promise<{ +async function loadSourceWorkflowRealtimeArtifacts(sourceWorkflowId: string): Promise<{ workflowState: WorkflowState variables: Record }> { - const editableState = await requireEditableWorkflowState(sourceWorkflowId) + const editableState = await requireWorkflowRealtimeState(sourceWorkflowId) if (!editableState) { throw new Error('Failed to load source workflow state') } @@ -103,7 +103,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: ) } - const sourceArtifacts = await loadSourceWorkflowArtifacts(sourceWorkflowId) + const sourceArtifacts = await loadSourceWorkflowRealtimeArtifacts(sourceWorkflowId) const newWorkflowId = crypto.randomUUID() const now = new Date() diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index 3c1f9349c..c5c8270c1 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -36,7 +36,7 @@ describe('Workflow By ID API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', isWorkflowRealtimeRequiredError: vi.fn(() => false), - requireEditableWorkflowState: mockLoadWorkflowState, + requireWorkflowRealtimeState: mockLoadWorkflowState, })) vi.doMock('@tradinggoose/db', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index 2e43c9f07..17a501a9e 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -9,7 +9,7 @@ import { verifyInternalTokenDetailed } from '@/lib/auth/internal' import { hydrateListingUI } from '@/lib/listing/hydrate-ui' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { requireEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { requireWorkflowRealtimeState } from '@/lib/workflows/db-helpers' import { readWorkflowAccessContext, readWorkflowById } from '@/lib/workflows/utils' import { applyWorkflowMetadata } from '@/lib/yjs/server/apply-workflow-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' @@ -126,7 +126,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from Yjs session`) - const workflowState = await requireEditableWorkflowState(workflowId) + const workflowState = await requireWorkflowRealtimeState(workflowId) if (!workflowState) { logger.warn(`[${requestId}] Workflow ${workflowId} is missing saved state`) diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts index 5bc014c4f..11bfc07f6 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts @@ -57,7 +57,7 @@ describe('Workflow Status API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', isWorkflowRealtimeRequiredError: vi.fn(() => false), - requireEditableWorkflowState: mockLoadWorkflowState, + requireWorkflowRealtimeState: mockLoadWorkflowState, })) vi.doMock('@/lib/workflows/utils', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts index 17fa1a090..4b59869a5 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts @@ -3,7 +3,7 @@ import { and, desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { requireEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { requireWorkflowRealtimeState } from '@/lib/workflows/db-helpers' import { hasWorkflowChanged } from '@/lib/workflows/utils' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' import { @@ -32,7 +32,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ if (validation.workflow.isDeployed) { // Load current workflow state and the active deployment version in parallel. const [currentState, [active]] = await Promise.all([ - requireEditableWorkflowState(id), + requireWorkflowRealtimeState(id), db .select({ state: workflowDeploymentVersion.state }) .from(workflowDeploymentVersion) diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts index 40b52a24a..be2c7be80 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts @@ -94,7 +94,7 @@ describe('Workflow YAML Export API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', isWorkflowRealtimeRequiredError: vi.fn(() => false), - requireEditableWorkflowState: loadWorkflowStateMock, + requireWorkflowRealtimeState: loadWorkflowStateMock, })) vi.doMock('@/lib/copilot/workflow/block-output-utils', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts index 5030b0e46..96bbf44d7 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts @@ -8,7 +8,7 @@ import { extractSubBlockValuesFromBlocks } from '@/lib/copilot/workflow/block-ou import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' -import { requireEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { requireWorkflowRealtimeState } from '@/lib/workflows/db-helpers' import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' import { getAllBlocks } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' @@ -71,7 +71,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const editableState = await requireEditableWorkflowState(workflowId) + const editableState = await requireWorkflowRealtimeState(workflowId) if (!editableState) { return NextResponse.json( diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts index 626426e3b..a759292c8 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts @@ -17,7 +17,7 @@ vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ - requireSavedEntityListMembers: (...args: unknown[]) => + requireSavedEntityRealtimeListMembers: (...args: unknown[]) => mockReadBootstrappedEntityListMembers(...args), readBootstrappedSavedEntityFields: (...args: unknown[]) => mockReadBootstrappedSavedEntityFields(...args), diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts index 2171aeb52..d820fd6e0 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts @@ -33,7 +33,7 @@ vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ - requireSavedEntityListMembers: (...args: unknown[]) => + requireSavedEntityRealtimeListMembers: (...args: unknown[]) => mockReadBootstrappedEntityListMembers(...args), readBootstrappedSavedEntityFields: (...args: unknown[]) => mockReadBootstrappedSavedEntityFields(...args), diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index 612e4f123..dae330128 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -22,7 +22,7 @@ import type { SavedEntityKind } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' import { readBootstrappedSavedEntityFields, - requireSavedEntityListMembers, + requireSavedEntityRealtimeListMembers, } from '@/lib/yjs/server/bootstrap-review-target' export type SavedEntityDocumentKind = EntityDocumentKind @@ -205,7 +205,7 @@ export function buildSavedEntityListInfo( entityKind: SavedEntityKind, workspaceId: string ): Promise { - return requireSavedEntityListMembers(entityKind, workspaceId) + return requireSavedEntityRealtimeListMembers(entityKind, workspaceId) } async function hashCreateEntityReviewBase( diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index 791a7f6ee..d2016f010 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -13,7 +13,7 @@ import { applySavedEntityState, publishCreatedSavedEntityListMembers, } from '@/lib/yjs/server/apply-entity-state' -import { requireSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' +import { requireSavedEntityRealtimeListFields } from '@/lib/yjs/server/bootstrap-review-target' const logger = createLogger('CustomToolsOperations') @@ -47,7 +47,7 @@ interface ImportCustomToolsParams { } export async function listCustomTools(params: { workspaceId: string }) { - const entries = await requireSavedEntityListFields('custom_tool', params.workspaceId) + const entries = await requireSavedEntityRealtimeListFields('custom_tool', params.workspaceId) return entries.map(({ entityId, fields }) => ({ id: entityId, workspaceId: params.workspaceId, diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index 530ee1cc1..9864d5805 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -13,12 +13,12 @@ import { applySavedEntityState, publishCreatedSavedEntityListMembers, } from '@/lib/yjs/server/apply-entity-state' -import { requireSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' +import { requireSavedEntityRealtimeListFields } from '@/lib/yjs/server/bootstrap-review-target' const logger = createLogger('IndicatorsOperations') export async function listCustomIndicatorRuntimeEntries(workspaceId: string) { - const entries = await requireSavedEntityListFields('indicator', workspaceId) + const entries = await requireSavedEntityRealtimeListFields('indicator', workspaceId) return entries.map(({ entityId, fields }) => ({ id: entityId, pineCode: String(fields.pineCode ?? ''), @@ -27,7 +27,7 @@ export async function listCustomIndicatorRuntimeEntries(workspaceId: string) { } export async function listIndicators(params: { workspaceId: string }) { - const entries = await requireSavedEntityListFields('indicator', params.workspaceId) + const entries = await requireSavedEntityRealtimeListFields('indicator', params.workspaceId) return entries.map(({ entityId, fields }) => ({ id: entityId, workspaceId: params.workspaceId, diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index dc5353eed..e0a88162b 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -25,7 +25,7 @@ import { applySavedEntityState, publishCreatedSavedEntityListMembers, } from '@/lib/yjs/server/apply-entity-state' -import { requireSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' +import { requireSavedEntityRealtimeListFields } from '@/lib/yjs/server/bootstrap-review-target' import { deleteYjsSessionInSocketServer, notifyEntityListMemberRemoved, @@ -45,7 +45,7 @@ export async function getKnowledgeBases( return [] } - const entries = await requireSavedEntityListFields('knowledge_base', workspaceId) + const entries = await requireSavedEntityRealtimeListFields('knowledge_base', workspaceId) return entries.map(({ entityId, fields }) => ({ id: entityId, name: String(fields.name ?? ''), diff --git a/apps/tradinggoose/lib/skills/operations.test.ts b/apps/tradinggoose/lib/skills/operations.test.ts index 759d32c9f..fb80f3bb7 100644 --- a/apps/tradinggoose/lib/skills/operations.test.ts +++ b/apps/tradinggoose/lib/skills/operations.test.ts @@ -36,7 +36,7 @@ vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ - requireSavedEntityListFields: vi.fn(), + requireSavedEntityRealtimeListFields: vi.fn(), })) vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index a64941189..5c7a31027 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -13,7 +13,7 @@ import { applySavedEntityState, publishCreatedSavedEntityListMembers, } from '@/lib/yjs/server/apply-entity-state' -import { requireSavedEntityListFields } from '@/lib/yjs/server/bootstrap-review-target' +import { requireSavedEntityRealtimeListFields } from '@/lib/yjs/server/bootstrap-review-target' import { deleteYjsSessionInSocketServer, notifyEntityListMemberRemoved, @@ -51,7 +51,7 @@ interface ImportSkillsParams { } export async function listSkills(params: { workspaceId: string }) { - const entries = await requireSavedEntityListFields('skill', params.workspaceId) + const entries = await requireSavedEntityRealtimeListFields('skill', params.workspaceId) return entries.map(({ entityId, fields }) => ({ id: entityId, workspaceId: params.workspaceId, diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index 23ffb659c..c010578ad 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -1015,7 +1015,7 @@ describe('Database Helpers', () => { }) }) - describe('requireEditableWorkflowState', () => { + describe('requireWorkflowRealtimeState', () => { it('loads workflow state through a bootstrapped Yjs session', async () => { const yjsState = { direction: 'LR' as const, @@ -1033,7 +1033,7 @@ describe('Database Helpers', () => { buildWorkflowSnapshotResponseFromState(yjsState, yjsVariables) ) - const result = await dbHelpers.requireEditableWorkflowState(mockWorkflowId) + const result = await dbHelpers.requireWorkflowRealtimeState(mockWorkflowId) expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalledWith({ workspaceId: null, @@ -1068,7 +1068,7 @@ describe('Database Helpers', () => { }, }) - const result = await dbHelpers.requireEditableWorkflowState(mockWorkflowId) + const result = await dbHelpers.requireWorkflowRealtimeState(mockWorkflowId) expect(result).toBeNull() expect(mockDb.select).not.toHaveBeenCalled() @@ -1077,7 +1077,7 @@ describe('Database Helpers', () => { it('requires the live Yjs bridge for editable workflow state', async () => { mockReadBootstrappedReviewTargetSnapshot.mockRejectedValue(new Error('bridge unavailable')) - await expect(dbHelpers.requireEditableWorkflowState(mockWorkflowId)).rejects.toThrow( + await expect(dbHelpers.requireWorkflowRealtimeState(mockWorkflowId)).rejects.toThrow( 'bridge unavailable' ) expect(mockDb.select).not.toHaveBeenCalled() diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index aaf74cb6a..2b8cde77b 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -126,7 +126,7 @@ function decodeWorkflowSnapshot(snapshotBase64: string): PersistedWorkflowState * used by the Yjs bootstrap path when a session is not already live. Bridge * failures intentionally surface instead of falling back to stale saved tables. */ -export async function requireEditableWorkflowState( +export async function requireWorkflowRealtimeState( workflowId: string ): Promise { const { readBootstrappedReviewTargetSnapshot } = await import( @@ -939,7 +939,7 @@ export async function deployWorkflow(params: { } = params try { - const editableState = await requireEditableWorkflowState(workflowId) + const editableState = await requireWorkflowRealtimeState(workflowId) if (!editableState) { return { success: false, error: 'Failed to load workflow state' } } diff --git a/apps/tradinggoose/lib/workflows/execution-runner.test.ts b/apps/tradinggoose/lib/workflows/execution-runner.test.ts index ceaf63ded..a6dc46972 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.test.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.test.ts @@ -66,7 +66,7 @@ vi.mock('@/lib/utils-server', () => ({ vi.mock('@/lib/workflows/db-helpers', () => ({ loadDeployedWorkflowState: vi.fn(), - requireEditableWorkflowState: vi.fn(), + requireWorkflowRealtimeState: vi.fn(), })) vi.mock('@/lib/workflows/triggers', () => ({ @@ -403,10 +403,10 @@ describe('loadWorkflowExecutionBlueprint', () => { }) it('loads Yjs workflow state for live execution when no snapshot is supplied', async () => { - const { loadDeployedWorkflowState, requireEditableWorkflowState } = await import( + const { loadDeployedWorkflowState, requireWorkflowRealtimeState } = await import( '@/lib/workflows/db-helpers' ) - vi.mocked(requireEditableWorkflowState).mockResolvedValueOnce({ + vi.mocked(requireWorkflowRealtimeState).mockResolvedValueOnce({ blocks: { trigger: { subBlocks: {} } }, edges: [{ source: 'trigger', target: 'worker' }], loops: {}, @@ -426,12 +426,12 @@ describe('loadWorkflowExecutionBlueprint', () => { expect(result.workflowData.blocks).toEqual({ trigger: { subBlocks: {} } }) expect(result.workflowContext.variables).toEqual({ risk: { value: 1 } }) expect(loadDeployedWorkflowState).not.toHaveBeenCalled() - expect(requireEditableWorkflowState).toHaveBeenCalledWith('workflow-1') + expect(requireWorkflowRealtimeState).toHaveBeenCalledWith('workflow-1') expect(mocks.dbSelect).not.toHaveBeenCalled() }) it('uses variables from the active deployment for deployed execution', async () => { - const { loadDeployedWorkflowState, requireEditableWorkflowState } = await import( + const { loadDeployedWorkflowState, requireWorkflowRealtimeState } = await import( '@/lib/workflows/db-helpers' ) const deployedVariables = { @@ -474,6 +474,6 @@ describe('loadWorkflowExecutionBlueprint', () => { unknown > expect(Object.keys(selectShape)).toEqual(['workspaceId']) - expect(requireEditableWorkflowState).not.toHaveBeenCalled() + expect(requireWorkflowRealtimeState).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/lib/workflows/execution-runner.ts b/apps/tradinggoose/lib/workflows/execution-runner.ts index 4af4d8302..43d4213d2 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.ts @@ -8,7 +8,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { decryptSecret } from '@/lib/utils-server' -import { loadDeployedWorkflowState, requireEditableWorkflowState } from '@/lib/workflows/db-helpers' +import { loadDeployedWorkflowState, requireWorkflowRealtimeState } from '@/lib/workflows/db-helpers' import { TriggerUtils } from '@/lib/workflows/triggers' import { updateWorkflowRunCounts } from '@/lib/workflows/utils' import { normalizeVariables } from '@/lib/workflows/variable-utils' @@ -267,7 +267,7 @@ export async function loadWorkflowExecutionBlueprint(params: { const executionTarget = params.executionTarget ?? 'deployed' const liveWorkflowState = executionTarget === 'live' && !params.workflowData - ? await requireEditableWorkflowState(params.workflowId) + ? await requireWorkflowRealtimeState(params.workflowId) : null const workflowContext = await resolveRequiredWorkflowExecutionContext( params.workflowId, diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 52b60d74c..412db1f37 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -70,7 +70,7 @@ export async function readBootstrappedReviewTargetSnapshot(descriptor: ReviewTar return getYjsSnapshot(descriptor.yjsSessionId, bridgeParams) } -export async function requireSavedEntityListMembers( +export async function requireSavedEntityRealtimeListMembers( entityKind: SavedEntityKind, workspaceId: string ): Promise { @@ -93,11 +93,11 @@ export async function requireSavedEntityListMembers( } } -export async function requireSavedEntityListFields( +export async function requireSavedEntityRealtimeListFields( entityKind: SavedEntityKind, workspaceId: string ): Promise }>> { - const members = await requireSavedEntityListMembers(entityKind, workspaceId) + const members = await requireSavedEntityRealtimeListMembers(entityKind, workspaceId) const entries = await Promise.all( members.map(async (member) => { try { From 277bedb3c482037affd07a3e978ce372a410bef3 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 02:16:43 -0600 Subject: [PATCH 260/284] fix(workspaces): prevent deleting the last workspace Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/copilot/mcp/route.test.ts | 12 ------------ apps/tradinggoose/app/api/workspaces/[id]/route.ts | 6 ++++++ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index 83e69c2f4..21161e267 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -160,18 +160,6 @@ describe('Copilot MCP route', () => { expect(body.result.instructions).not.toContain('No accessible workspaces') }) - it('treats missing workspaces as a bootstrap invariant failure instead of MCP onboarding', async () => { - const { POST } = await import('./route') - mockGetUserWorkspaces.mockResolvedValueOnce([]) - - const response = await POST(createMcpRequest(initializeRequest(5))) - const body = await response.json() - - expect(body.error.code).toBe(-32603) - expect(body.error.data.code).toBe('server_tool_execution_failed') - expect(JSON.stringify(body)).not.toContain('No accessible workspaces') - }) - it('accepts a case-insensitive bearer auth scheme', async () => { const { POST } = await import('./route') diff --git a/apps/tradinggoose/app/api/workspaces/[id]/route.ts b/apps/tradinggoose/app/api/workspaces/[id]/route.ts index b4010a1d6..96bd784c9 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/route.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/route.ts @@ -15,6 +15,7 @@ import { WorkspaceBillingOwnerUpdateError, workspaceBillingOwnerSchema, } from '@/lib/workspaces/billing-owner' +import { getUserWorkspaces } from '@/lib/workspaces/service' const logger = createLogger('WorkspaceByIdAPI') @@ -211,6 +212,11 @@ export async function DELETE( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } + const userWorkspaces = await getUserWorkspaces({ userId: session.user.id, autoCreate: false }) + if (userWorkspaces.length <= 1) { + return NextResponse.json({ error: 'Cannot delete your last workspace' }, { status: 400 }) + } + try { logger.info( `Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}` From 301d1d5f80df7dd606c95a717c0bffdcd1629dcb Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 13:23:14 -0600 Subject: [PATCH 261/284] fix(workspaces): move bootstrap to signup and refresh MCP tools Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/[locale]/workspace/page.test.tsx | 14 -- .../app/[locale]/workspace/page.tsx | 7 +- .../app/api/copilot/mcp/route.test.ts | 2 +- .../tradinggoose/app/api/copilot/mcp/route.ts | 2 +- .../app/api/workspaces/[id]/route.test.ts | 4 + .../app/api/workspaces/[id]/route.ts | 2 +- .../app/api/workspaces/route.test.ts | 42 +----- apps/tradinggoose/app/api/workspaces/route.ts | 7 +- apps/tradinggoose/hooks/use-mcp-tools.ts | 138 +++++++++--------- apps/tradinggoose/lib/auth.ts | 3 + apps/tradinggoose/lib/workspaces/service.ts | 50 +------ .../stores/copilot/tool-registry.test.ts | 30 ++-- .../stores/copilot/tool-registry.ts | 1 + 13 files changed, 118 insertions(+), 184 deletions(-) diff --git a/apps/tradinggoose/app/[locale]/workspace/page.test.tsx b/apps/tradinggoose/app/[locale]/workspace/page.test.tsx index d302326ec..d4a942ece 100644 --- a/apps/tradinggoose/app/[locale]/workspace/page.test.tsx +++ b/apps/tradinggoose/app/[locale]/workspace/page.test.tsx @@ -146,20 +146,6 @@ describe('Workspace root page access guard', () => { expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ userId: 'user-1', - userName: 'Ada Lovelace', - }) - }) - - it('bootstraps a workspace on the server when the user has none and redirects to it', async () => { - mockGetUserWorkspaces.mockResolvedValue([{ id: 'workspace-bootstrapped' }]) - - await expect(renderWorkspacePage('en')).rejects.toThrow( - 'redirect:/en/workspace/workspace-bootstrapped/dashboard' - ) - - expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ - userId: 'user-1', - userName: 'Ada Lovelace', }) }) }) diff --git a/apps/tradinggoose/app/[locale]/workspace/page.tsx b/apps/tradinggoose/app/[locale]/workspace/page.tsx index d4782dbbc..c308ced44 100644 --- a/apps/tradinggoose/app/[locale]/workspace/page.tsx +++ b/apps/tradinggoose/app/[locale]/workspace/page.tsx @@ -87,13 +87,10 @@ export default async function WorkspacePage({ } } - const [workspace] = await getUserWorkspaces({ - userId, - userName: session.user.name, - }) + const [workspace] = await getUserWorkspaces({ userId }) if (!workspace) { - throw new Error('Expected workspace bootstrap to return a workspace') + throw new Error('Authenticated user account has no workspace') } return redirect({ href: `/workspace/${workspace.id}/dashboard`, locale }) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index 21161e267..ee1e6461f 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -146,7 +146,7 @@ describe('Copilot MCP route', () => { }) expect(mockUpdateApiKeyLastUsed).toHaveBeenCalledWith('key-1') expect(mockCheckApiEndpointRateLimit).toHaveBeenCalledWith('user-1', 'copilot-mcp') - expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ userId: 'user-1', autoCreate: false }) + expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ userId: 'user-1' }) expect(body.result.capabilities).toEqual({ tools: {} }) expect(body.result.serverInfo).toEqual({ name: 'TradingGoose', version: '0.1.0' }) expect(body.result.instructions).toContain('workspaceId=workspace-1, permissions=admin') diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index d7e73f959..3a1ab16fd 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -113,7 +113,7 @@ async function authenticateCopilotMcpRequest( } async function buildInstructions(userId: string) { - const workspaces = await getUserWorkspaces({ userId, autoCreate: false }) + const workspaces = await getUserWorkspaces({ userId }) if (workspaces.length === 0) { throw new Error('Authenticated TradingGoose users must have at least one workspace') } diff --git a/apps/tradinggoose/app/api/workspaces/[id]/route.test.ts b/apps/tradinggoose/app/api/workspaces/[id]/route.test.ts index d1c06aff0..5c27ae8e6 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/route.test.ts @@ -101,6 +101,10 @@ vi.mock('@/lib/logs/console/logger', () => ({ createLogger: vi.fn(() => mockLogger), })) +vi.mock('@/lib/workspaces/service', () => ({ + getUserWorkspaces: vi.fn(), +})) + describe('Workspace by id PATCH route', () => { beforeEach(() => { vi.resetModules() diff --git a/apps/tradinggoose/app/api/workspaces/[id]/route.ts b/apps/tradinggoose/app/api/workspaces/[id]/route.ts index 96bd784c9..c8c7124b5 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/route.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/route.ts @@ -212,7 +212,7 @@ export async function DELETE( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } - const userWorkspaces = await getUserWorkspaces({ userId: session.user.id, autoCreate: false }) + const userWorkspaces = await getUserWorkspaces({ userId: session.user.id }) if (userWorkspaces.length <= 1) { return NextResponse.json({ error: 'Cannot delete your last workspace' }, { status: 400 }) } diff --git a/apps/tradinggoose/app/api/workspaces/route.test.ts b/apps/tradinggoose/app/api/workspaces/route.test.ts index 7460a0d38..9718a45cb 100644 --- a/apps/tradinggoose/app/api/workspaces/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/route.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment node */ -import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('Workspaces API Route', () => { @@ -14,9 +13,6 @@ describe('Workspaces API Route', () => { const deleteMock = vi.fn((_table: unknown) => ({ where: deleteWhereMock, })) - const updateWhereMock = vi.fn() - const updateSetMock = vi.fn() - const updateMock = vi.fn() const mockSaveWorkflowToNormalizedTables = vi.fn() let userWorkspaces: Array<{ workspace: Record @@ -33,9 +29,6 @@ describe('Workspaces API Route', () => { callback({ insert: txInsertMock, delete: deleteMock }) ) deleteWhereMock.mockResolvedValue(undefined) - updateWhereMock.mockResolvedValue([]) - updateSetMock.mockReturnValue({ where: updateWhereMock }) - updateMock.mockReturnValue({ set: updateSetMock }) mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) vi.doMock('@tradinggoose/db', () => ({ @@ -55,7 +48,6 @@ describe('Workspaces API Route', () => { })), })), })), - update: updateMock, transaction: transactionMock, }, })) @@ -137,18 +129,17 @@ describe('Workspaces API Route', () => { ) } - it('returns an empty list without creating a default workspace when autoCreate=false', async () => { + it('returns an empty list without creating a default workspace during reads', async () => { const { GET } = await import('@/app/api/workspaces/route') - const response = await GET(new NextRequest('http://localhost/api/workspaces?autoCreate=false')) + const response = await GET() expect(response.status).toBe(200) expect(await response.json()).toEqual({ workspaces: [] }) expect(transactionMock).not.toHaveBeenCalled() - expect(updateMock).not.toHaveBeenCalled() }) - it('lists existing workspaces without running workspace migration side effects when autoCreate=false', async () => { + it('lists existing workspaces without running migration side effects', async () => { userWorkspaces = [ { workspace: { @@ -167,7 +158,7 @@ describe('Workspaces API Route', () => { const { GET } = await import('@/app/api/workspaces/route') - const response = await GET(new NextRequest('http://localhost/api/workspaces?autoCreate=false')) + const response = await GET() const data = await response.json() expect(response.status).toBe(200) @@ -182,7 +173,6 @@ describe('Workspaces API Route', () => { role: 'owner', permissions: 'admin', }) - expect(updateMock).not.toHaveBeenCalled() expect(transactionMock).not.toHaveBeenCalled() }) @@ -205,7 +195,7 @@ describe('Workspaces API Route', () => { const { GET } = await import('@/app/api/workspaces/route') - const response = await GET(new NextRequest('http://localhost/api/workspaces?autoCreate=false')) + const response = await GET() const data = await response.json() expect(response.status).toBe(200) @@ -219,28 +209,6 @@ describe('Workspaces API Route', () => { expect(transactionMock).not.toHaveBeenCalled() }) - it('auto-creates a default workspace with the canonical workspace shape', async () => { - const { GET } = await import('@/app/api/workspaces/route') - - const response = await GET(new NextRequest('http://localhost/api/workspaces')) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.workspaces).toEqual([ - expect.objectContaining({ - name: "Bruz's Workspace", - role: 'owner', - permissions: 'admin', - billingOwner: { - type: 'user', - userId: 'user-1', - }, - }), - ]) - expect(transactionMock).toHaveBeenCalled() - expect(updateMock).toHaveBeenCalled() - }) - it('removes a newly created workspace when default workflow state persistence fails', async () => { mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: false, diff --git a/apps/tradinggoose/app/api/workspaces/route.ts b/apps/tradinggoose/app/api/workspaces/route.ts index 444eee6f4..79a61c0d8 100644 --- a/apps/tradinggoose/app/api/workspaces/route.ts +++ b/apps/tradinggoose/app/api/workspaces/route.ts @@ -1,4 +1,4 @@ -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' @@ -10,9 +10,8 @@ const createWorkspaceSchema = z.object({ }) // Get all workspaces for the current user -export async function GET(request: NextRequest) { +export async function GET() { const session = await getSession() - const allowWorkspaceBootstrap = request.nextUrl.searchParams.get('autoCreate') !== 'false' if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -20,8 +19,6 @@ export async function GET(request: NextRequest) { const workspaces = await getUserWorkspaces({ userId: session.user.id, - userName: session.user.name, - autoCreate: allowWorkspaceBootstrap, }) return NextResponse.json({ workspaces }) diff --git a/apps/tradinggoose/hooks/use-mcp-tools.ts b/apps/tradinggoose/hooks/use-mcp-tools.ts index 0b65ae1b4..95b10d27d 100644 --- a/apps/tradinggoose/hooks/use-mcp-tools.ts +++ b/apps/tradinggoose/hooks/use-mcp-tools.ts @@ -6,7 +6,7 @@ */ import type React from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { WrenchIcon } from 'lucide-react' import { createLogger } from '@/lib/logs/console/logger' import type { McpTool } from '@/lib/mcp/types' @@ -14,6 +14,7 @@ import { createMcpToolId } from '@/lib/mcp/utils' import { MCP_TOOLS_CHANGED_EVENT, useMcpServersStore } from '@/stores/mcp-servers/store' const logger = createLogger('useMcpTools') +const DISCOVERY_CACHE_MS = 5 * 60 * 1000 export interface McpToolForUI { id: string @@ -35,6 +36,58 @@ export interface UseMcpToolsResult { getToolsByServer: (serverId: string) => McpToolForUI[] } +const discoveryCache = new Map() +const discoveryRequests = new Map>() + +async function discoverMcpTools( + workspaceId: string, + serversFingerprint: string, + force: boolean +) { + const cacheKey = `${workspaceId}:${serversFingerprint}` + const pending = discoveryRequests.get(cacheKey) + if (pending) return pending + + const cached = discoveryCache.get(cacheKey) + if (!force && cached && cached.expiresAt > Date.now()) { + return cached.tools + } + + const request = fetch(`/api/mcp/tools/discover?workspaceId=${encodeURIComponent(workspaceId)}`) + .then(async (response) => { + if (!response.ok) { + throw new Error(`Failed to discover MCP tools: ${response.status} ${response.statusText}`) + } + + const data = await response.json() + if (!data.success) { + throw new Error(data.error || 'Failed to discover MCP tools') + } + + const tools = (data.data.tools || []).map((tool: McpTool) => ({ + id: createMcpToolId(tool.serverId, tool.name), + name: tool.name, + description: tool.description, + serverId: tool.serverId, + serverName: tool.serverName, + type: 'mcp' as const, + inputSchema: tool.inputSchema, + bgColor: '#6366F1', + icon: WrenchIcon, + })) + + discoveryCache.set(cacheKey, { expiresAt: Date.now() + DISCOVERY_CACHE_MS, tools }) + logger.info(`Discovered ${tools.length} MCP tools`) + return tools + }) + .finally(() => { + discoveryRequests.delete(cacheKey) + }) + + discoveryRequests.set(cacheKey, request) + return request +} + export function useMcpTools(workspaceId: string): UseMcpToolsResult { const [mcpTools, setMcpTools] = useState([]) const [isLoading, setIsLoading] = useState(false) @@ -43,9 +96,6 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { const servers = useMcpServersStore((state) => state.servers) - // Track the last fingerprint - const lastProcessedFingerprintRef = useRef('') - // Create a stable server fingerprint const serversFingerprint = useMemo(() => { return servers @@ -55,9 +105,14 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { .join('|') }, [servers]) - const refreshTools = useCallback( - async () => { - if (!normalizedWorkspaceId) { + const hasEnabledServers = useMemo( + () => servers.some((server) => !server.deletedAt && server.enabled !== false), + [servers] + ) + + const loadTools = useCallback( + async (force = false) => { + if (!normalizedWorkspaceId || !hasEnabledServers) { setMcpTools([]) setError(null) setIsLoading(false) @@ -69,41 +124,7 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { try { logger.info('Discovering MCP tools', { workspaceId: normalizedWorkspaceId }) - - const response = await fetch( - `/api/mcp/tools/discover?workspaceId=${encodeURIComponent( - normalizedWorkspaceId - )}` - ) - - if (!response.ok) { - throw new Error(`Failed to discover MCP tools: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - if (!data.success) { - throw new Error(data.error || 'Failed to discover MCP tools') - } - - const tools = data.data.tools || [] - const transformedTools = tools.map((tool: McpTool) => ({ - id: createMcpToolId(tool.serverId, tool.name), - name: tool.name, - description: tool.description, - serverId: tool.serverId, - serverName: tool.serverName, - type: 'mcp' as const, - inputSchema: tool.inputSchema, - bgColor: '#6366F1', - icon: WrenchIcon, - })) - - setMcpTools(transformedTools) - - logger.info( - `Discovered ${transformedTools.length} MCP tools from ${data.data.byServer ? Object.keys(data.data.byServer).length : 0} servers` - ) + setMcpTools(await discoverMcpTools(normalizedWorkspaceId, serversFingerprint, force)) } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to discover MCP tools' logger.error('Error discovering MCP tools:', err) @@ -113,9 +134,11 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { setIsLoading(false) } }, - [normalizedWorkspaceId, workspaceId] + [hasEnabledServers, normalizedWorkspaceId, serversFingerprint] ) + const refreshTools = useCallback(() => loadTools(true), [loadTools]) + const getToolsByServer = useCallback( (serverId: string): McpToolForUI[] => { return mcpTools.filter((tool) => tool.serverId === serverId) @@ -131,34 +154,15 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { return } - refreshTools() - }, [normalizedWorkspaceId, refreshTools]) - - // Refresh tools when servers change - useEffect(() => { - if ( - !normalizedWorkspaceId || - !serversFingerprint || - serversFingerprint === lastProcessedFingerprintRef.current - ) { - return - } - - logger.info('Active servers changed, refreshing MCP tools', { - serverCount: servers.filter((s) => !s.deletedAt && s.enabled !== false).length, - fingerprint: serversFingerprint, - }) - - lastProcessedFingerprintRef.current = serversFingerprint - refreshTools() - }, [normalizedWorkspaceId, serversFingerprint, refreshTools, servers]) + void loadTools() + }, [loadTools, normalizedWorkspaceId]) useEffect(() => { if (!normalizedWorkspaceId) return const handleToolsChanged = (event: Event) => { const workspaceId = (event as CustomEvent<{ workspaceId?: string }>).detail?.workspaceId - if (workspaceId === normalizedWorkspaceId) { + if (!workspaceId || workspaceId === normalizedWorkspaceId) { void refreshTools() } } @@ -172,14 +176,14 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { const interval = setInterval( () => { if (!isLoading && normalizedWorkspaceId) { - refreshTools() + void loadTools() } }, 5 * 60 * 1000 ) return () => clearInterval(interval) - }, [isLoading, normalizedWorkspaceId, refreshTools]) + }, [isLoading, loadTools, normalizedWorkspaceId]) return { mcpTools, diff --git a/apps/tradinggoose/lib/auth.ts b/apps/tradinggoose/lib/auth.ts index d3ebc5404..dbc5182a8 100644 --- a/apps/tradinggoose/lib/auth.ts +++ b/apps/tradinggoose/lib/auth.ts @@ -82,6 +82,7 @@ import { } from '@/lib/system-services/stripe-runtime' import { getResolvedSystemSettings } from '@/lib/system-settings/service' import { getBaseUrl } from '@/lib/urls/utils' +import { createDefaultWorkspaceForUser } from '@/lib/workspaces/service' import { localizeUrl } from '@/i18n/utils' import { resolveAlpacaTradingBaseUrl } from '@/providers/trading/alpaca/config' import { resolveTradierBaseUrl } from '@/providers/trading/tradier/client' @@ -458,6 +459,8 @@ export const auth = betterAuth({ userId: user.id, }) + await createDefaultWorkspaceForUser(user.id, user.name) + try { await markWaitlistEntrySignedUp(user.email, user.id) } catch (error) { diff --git a/apps/tradinggoose/lib/workspaces/service.ts b/apps/tradinggoose/lib/workspaces/service.ts index 29f8e0853..fdddbdfda 100644 --- a/apps/tradinggoose/lib/workspaces/service.ts +++ b/apps/tradinggoose/lib/workspaces/service.ts @@ -1,6 +1,6 @@ import { db } from '@tradinggoose/db' import { permissions, workflow, workspace } from '@tradinggoose/db/schema' -import { and, desc, eq, isNull } from 'drizzle-orm' +import { desc, eq } from 'drizzle-orm' import { buildWorkspaceAccessScope } from '@/lib/permissions/utils' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' @@ -10,12 +10,8 @@ type WorkspaceRecord = typeof workspace.$inferSelect export async function getUserWorkspaces({ userId, - userName, - autoCreate = true, }: { userId: string - userName?: string | null - autoCreate?: boolean }) { const workspaceAccess = buildWorkspaceAccessScope(userId, workspace.id) const userWorkspaces = await db @@ -28,20 +24,6 @@ export async function getUserWorkspaces({ .where(workspaceAccess.accessFilter) .orderBy(desc(workspace.createdAt)) - if (userWorkspaces.length === 0) { - if (!autoCreate) { - return [] - } - - const defaultWorkspace = await createDefaultWorkspace(userId, userName) - await migrateExistingWorkflows(userId, defaultWorkspace.id) - return [defaultWorkspace] - } - - if (autoCreate) { - await ensureWorkflowsHaveWorkspace(userId, userWorkspaces[0].workspace.id) - } - return userWorkspaces.map(({ workspace: workspaceDetails, permissionType }) => { const resolvedPermissionType = workspaceDetails.ownerId === userId ? 'admin' : permissionType if (!resolvedPermissionType) { @@ -56,6 +38,11 @@ export async function getUserWorkspaces({ }) } +export async function createDefaultWorkspaceForUser(userId: string, userName?: string | null) { + const firstName = userName?.split(' ')[0] || null + return createWorkspace(userId, firstName ? `${firstName}'s Workspace` : 'My Workspace') +} + export async function createWorkspace(userId: string, name: string) { const workspaceId = crypto.randomUUID() const workflowId = crypto.randomUUID() @@ -116,28 +103,3 @@ export async function createWorkspace(userId: string, name: string) { permissions: 'admin', } } - -async function createDefaultWorkspace(userId: string, userName?: string | null) { - const firstName = userName?.split(' ')[0] || null - return createWorkspace(userId, firstName ? `${firstName}'s Workspace` : 'My Workspace') -} - -async function migrateExistingWorkflows(userId: string, workspaceId: string) { - await db - .update(workflow) - .set({ - workspaceId, - updatedAt: new Date(), - }) - .where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId))) -} - -async function ensureWorkflowsHaveWorkspace(userId: string, defaultWorkspaceId: string) { - await db - .update(workflow) - .set({ - workspaceId: defaultWorkspaceId, - updatedAt: new Date(), - }) - .where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId))) -} diff --git a/apps/tradinggoose/stores/copilot/tool-registry.test.ts b/apps/tradinggoose/stores/copilot/tool-registry.test.ts index 79ab03603..01c18008d 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.test.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.test.ts @@ -348,16 +348,28 @@ describe('tool-registry', () => { const invalidateQueries = vi .spyOn(getQueryClient(), 'invalidateQueries') .mockResolvedValue(undefined) + const dispatchEvent = vi.fn() + vi.stubGlobal('window', { dispatchEvent }) - await handleCopilotServerToolSuccess('set_environment_variables', { - success: true, - scope: 'workspace', - workspaceId: 'workspace-1', - }) + try { + await handleCopilotServerToolSuccess('set_environment_variables', { + success: true, + scope: 'workspace', + workspaceId: 'workspace-1', + }) - expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: environmentKeys.workspace('workspace-1'), - }) - expect(invalidateQueries).toHaveBeenCalledTimes(1) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: environmentKeys.workspace('workspace-1'), + }) + expect(invalidateQueries).toHaveBeenCalledTimes(1) + expect(dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: MCP_TOOLS_CHANGED_EVENT, + detail: { workspaceId: 'workspace-1' }, + }) + ) + } finally { + vi.unstubAllGlobals() + } }) }) diff --git a/apps/tradinggoose/stores/copilot/tool-registry.ts b/apps/tradinggoose/stores/copilot/tool-registry.ts index b274b6d5e..cff05a73a 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.ts @@ -325,6 +325,7 @@ export async function handleCopilotServerToolSuccess( } else if (scope === 'personal') { await queryClient.invalidateQueries({ queryKey: environmentKeys.personal() }) } + window.dispatchEvent(new CustomEvent(MCP_TOOLS_CHANGED_EVENT, { detail: { workspaceId } })) return } From a953ba72f83672dc0edd96a2b5037ff04bc74095 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 13:52:38 -0600 Subject: [PATCH 262/284] fix(workspaces): create default workspace when user has none Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/workspaces/service.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/tradinggoose/lib/workspaces/service.ts b/apps/tradinggoose/lib/workspaces/service.ts index fdddbdfda..31b34481c 100644 --- a/apps/tradinggoose/lib/workspaces/service.ts +++ b/apps/tradinggoose/lib/workspaces/service.ts @@ -8,11 +8,7 @@ import { toWorkspaceApiRecord } from '@/lib/workspaces/billing-owner' type WorkspaceRecord = typeof workspace.$inferSelect -export async function getUserWorkspaces({ - userId, -}: { - userId: string -}) { +export async function getUserWorkspaces({ userId }: { userId: string }) { const workspaceAccess = buildWorkspaceAccessScope(userId, workspace.id) const userWorkspaces = await db .select({ @@ -24,6 +20,10 @@ export async function getUserWorkspaces({ .where(workspaceAccess.accessFilter) .orderBy(desc(workspace.createdAt)) + if (userWorkspaces.length === 0) { + return [await createDefaultWorkspaceForUser(userId)] + } + return userWorkspaces.map(({ workspace: workspaceDetails, permissionType }) => { const resolvedPermissionType = workspaceDetails.ownerId === userId ? 'admin' : permissionType if (!resolvedPermissionType) { From 820c0c1f48893ccd68d7bce4dc0adc1beca83418 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 13:52:51 -0600 Subject: [PATCH 263/284] fix(stores): clear react query cache during user data reset Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/stores/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/tradinggoose/stores/index.ts b/apps/tradinggoose/stores/index.ts index 124a01d7d..32bfe2f2b 100644 --- a/apps/tradinggoose/stores/index.ts +++ b/apps/tradinggoose/stores/index.ts @@ -1,6 +1,7 @@ 'use client' import { createLogger } from '@/lib/logs/console/logger' +import { getQueryClient } from '@/app/query-provider' import { resetWorkspacePermissionsStore } from '@/hooks/use-workspace-permissions' import { useConsoleStore } from '@/stores/console/store' import { getCopilotStore, useCopilotStore } from '@/stores/copilot/store' @@ -26,6 +27,7 @@ export async function clearUserData(): Promise { // Reset all stores to their initial state resetAllStores() + getQueryClient().clear() // Clear localStorage except for essential app settings (minimal usage) const keysToKeep = ['next-favicon', 'theme'] From 4070299b596159693e9fe453142e914d4e4ba339 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 15:09:19 -0600 Subject: [PATCH 264/284] fix(workspaces): stop auto-creating workspaces on read Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/workspaces/service.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/tradinggoose/lib/workspaces/service.ts b/apps/tradinggoose/lib/workspaces/service.ts index 31b34481c..817a0d773 100644 --- a/apps/tradinggoose/lib/workspaces/service.ts +++ b/apps/tradinggoose/lib/workspaces/service.ts @@ -20,10 +20,6 @@ export async function getUserWorkspaces({ userId }: { userId: string }) { .where(workspaceAccess.accessFilter) .orderBy(desc(workspace.createdAt)) - if (userWorkspaces.length === 0) { - return [await createDefaultWorkspaceForUser(userId)] - } - return userWorkspaces.map(({ workspace: workspaceDetails, permissionType }) => { const resolvedPermissionType = workspaceDetails.ownerId === userId ? 'admin' : permissionType if (!resolvedPermissionType) { From f4906981d7ead59dbb8c58be45ff5c223fdf1757 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 15:09:33 -0600 Subject: [PATCH 265/284] fix(workflows): remap duplicate block references in sub-blocks Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/workflows/db-helpers.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index 2b8cde77b..bea088b8b 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -260,6 +260,16 @@ export async function ensureUniqueBlockIds( ...block, id: nextId, data: nextData, + subBlocks: block.subBlocks + ? Object.fromEntries( + Object.entries(block.subBlocks).map(([subBlockId, subBlock]) => [ + subBlockId, + typeof subBlock?.value === 'string' && remap.has(subBlock.value) + ? { ...subBlock, value: remap.get(subBlock.value)! } + : subBlock, + ]) + ) + : block.subBlocks, } }) From ec0613696a143f78c178f84e82a6433b718bc919 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 15:09:47 -0600 Subject: [PATCH 266/284] fix(api-key): validate encryption key availability Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/api-key/service.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/tradinggoose/lib/api-key/service.ts b/apps/tradinggoose/lib/api-key/service.ts index ad85c62df..d5943fc7e 100644 --- a/apps/tradinggoose/lib/api-key/service.ts +++ b/apps/tradinggoose/lib/api-key/service.ts @@ -8,6 +8,7 @@ import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('ApiKeyService') const API_KEY_SECRET_PATTERN = /^[A-Za-z0-9_-]{32}$/ +const API_ENCRYPTION_KEY_PATTERN = /^[a-fA-F0-9]{64}$/ const API_KEY_PREFIX = 'sk-tradinggoose-' const STORED_API_KEY_SEPARATOR = ':' const DEFAULT_API_KEY_AUTH_TYPES: ApiKeyType[] = ['personal', 'workspace'] @@ -179,14 +180,14 @@ function getApiEncryptionKey(): Buffer { if (!key) { throw new Error('API_ENCRYPTION_KEY is required for API key storage') } - if (!/^[a-fA-F0-9]{64}$/.test(key)) { + if (!API_ENCRYPTION_KEY_PATTERN.test(key)) { throw new Error('API_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)') } return Buffer.from(key, 'hex') } export function isApiKeyStorageAvailable(): boolean { - return Boolean(env.API_ENCRYPTION_KEY) + return Boolean(env.API_ENCRYPTION_KEY && API_ENCRYPTION_KEY_PATTERN.test(env.API_ENCRYPTION_KEY)) } function encryptApiKeyForStorage(apiKey: string): string { From 9ab9b5be9d24dae67cfd1db814a337180c9385d7 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 15:32:01 -0600 Subject: [PATCH 267/284] fix(query-provider): use browser-scoped query client Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/query-provider.tsx | 25 +++++++++++++------ .../tradinggoose/stores/copilot/store.test.ts | 17 +++++-------- .../stores/copilot/tool-registry.test.ts | 10 ++++---- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/apps/tradinggoose/app/query-provider.tsx b/apps/tradinggoose/app/query-provider.tsx index 298cf723a..de1745bd8 100644 --- a/apps/tradinggoose/app/query-provider.tsx +++ b/apps/tradinggoose/app/query-provider.tsx @@ -1,20 +1,31 @@ 'use client' import type { ReactNode } from 'react' +import { useState } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, +let browserQueryClient: QueryClient | undefined + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, }, - }, -}) + }) +} export function getQueryClient() { - return queryClient + if (typeof window === 'undefined') { + return createQueryClient() + } + + browserQueryClient ??= createQueryClient() + return browserQueryClient } export function QueryProvider({ children }: { children: ReactNode }) { + const [queryClient] = useState(getQueryClient) return {children} } diff --git a/apps/tradinggoose/stores/copilot/store.test.ts b/apps/tradinggoose/stores/copilot/store.test.ts index 18eeecfdf..0758ac574 100644 --- a/apps/tradinggoose/stores/copilot/store.test.ts +++ b/apps/tradinggoose/stores/copilot/store.test.ts @@ -1,8 +1,8 @@ +import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ClientToolCallState } from '@/lib/copilot/tools/client/base-tool' import { registerClientTool, unregisterClientTool } from '@/lib/copilot/tools/client/manager' import { encodeSSE } from '@/lib/utils' -import { getQueryClient } from '@/app/query-provider' import { environmentKeys } from '@/hooks/queries/environment' import { getCopilotStore } from '@/stores/copilot/store' import { getCopilotStoreForToolCall } from '@/stores/copilot/store-access' @@ -749,9 +749,7 @@ describe('copilot streaming regressions', () => { role: 'assistant', content: '', timestamp: '2026-04-13T00:00:01.000Z', - contentBlocks: [ - toolBlock('checkoff_todo', 'todo-tool-collision', { id: 'todo-1' }), - ], + contentBlocks: [toolBlock('checkoff_todo', 'todo-tool-collision', { id: 'todo-1' })], }, ] as any @@ -2893,8 +2891,7 @@ describe('copilot tool user action delegation', () => { reviewToken: 'review-token-edit-workflow-order', entityKind: 'workflow', entityId: 'wf-edit-workflow-order', - entityDocument: - 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', + entityDocument: 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', documentFormat: 'tg-workflow-graph-mermaid-v1', workflowState: { blocks: {}, @@ -2953,8 +2950,7 @@ describe('copilot tool user action delegation', () => { expect(parseJsonRequestBody(executeRequest)).toEqual({ toolName: 'edit_workflow', payload: { - entityDocument: - 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', + entityDocument: 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', entityId: 'wf-edit-workflow-order', }, }) @@ -2970,8 +2966,7 @@ describe('copilot tool user action delegation', () => { reviewToken: 'review-token-edit-workflow-review', entityKind: 'workflow', entityId: 'wf-edit-workflow-review', - entityDocument: - 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', + entityDocument: 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', documentFormat: 'tg-workflow-graph-mermaid-v1', workflowState: { blocks: {}, @@ -3129,7 +3124,7 @@ describe('copilot tool user action delegation', () => { const toolCallId = 'set-env-tool' const store = getCopilotStore(channelId) const invalidateQueries = vi - .spyOn(getQueryClient(), 'invalidateQueries') + .spyOn(QueryClient.prototype, 'invalidateQueries') .mockResolvedValue(undefined) const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = typeof input === 'string' ? input : input.toString() diff --git a/apps/tradinggoose/stores/copilot/tool-registry.test.ts b/apps/tradinggoose/stores/copilot/tool-registry.test.ts index 01c18008d..88ca647bd 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.test.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.test.ts @@ -1,5 +1,5 @@ +import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getQueryClient } from '@/app/query-provider' import { MONITOR_DATA_CHANGED_EVENT } from '@/app/workspace/[workspaceId]/monitor/components/data/api' import { environmentKeys } from '@/hooks/queries/environment' import { knowledgeKeys } from '@/hooks/queries/knowledge' @@ -247,7 +247,7 @@ describe('tool-registry', () => { .spyOn(useWorkflowRegistry.getState(), 'loadWorkflows') .mockResolvedValue(undefined) const invalidateQueries = vi - .spyOn(getQueryClient(), 'invalidateQueries') + .spyOn(QueryClient.prototype, 'invalidateQueries') .mockResolvedValue(undefined) await handleCopilotServerToolSuccess('create_workflow', { workspaceId: 'workspace-1' }) @@ -260,7 +260,7 @@ describe('tool-registry', () => { it('invalidates saved-entity list queries after server-managed saved-entity mutations', async () => { const invalidateQueries = vi - .spyOn(getQueryClient(), 'invalidateQueries') + .spyOn(QueryClient.prototype, 'invalidateQueries') .mockResolvedValue(undefined) await handleCopilotServerToolSuccess('edit_skill', { workspaceId: 'workspace-1' }) @@ -272,7 +272,7 @@ describe('tool-registry', () => { it('invalidates the selected knowledge base detail tree after server-managed knowledge mutations', async () => { const invalidateQueries = vi - .spyOn(getQueryClient(), 'invalidateQueries') + .spyOn(QueryClient.prototype, 'invalidateQueries') .mockResolvedValue(undefined) await handleCopilotServerToolSuccess('edit_knowledge_base', { @@ -346,7 +346,7 @@ describe('tool-registry', () => { it('invalidates the matching environment query after server-managed environment mutations', async () => { const invalidateQueries = vi - .spyOn(getQueryClient(), 'invalidateQueries') + .spyOn(QueryClient.prototype, 'invalidateQueries') .mockResolvedValue(undefined) const dispatchEvent = vi.fn() vi.stubGlobal('window', { dispatchEvent }) From ad90f2318aa6f48c646fd75f816557ae97a0ce61 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 15:32:16 -0600 Subject: [PATCH 268/284] feat(workspace): create default workspace for root entry Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/[locale]/workspace/page.test.tsx | 13 +++++++++++++ apps/tradinggoose/app/[locale]/workspace/page.tsx | 10 ++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/tradinggoose/app/[locale]/workspace/page.test.tsx b/apps/tradinggoose/app/[locale]/workspace/page.test.tsx index d4a942ece..66acecce3 100644 --- a/apps/tradinggoose/app/[locale]/workspace/page.test.tsx +++ b/apps/tradinggoose/app/[locale]/workspace/page.test.tsx @@ -7,6 +7,7 @@ const mockRedirect = vi.fn((url: string) => { const mockGetSession = vi.fn() const mockHeaders = vi.fn() const mockGetUserWorkspaces = vi.fn() +const mockCreateDefaultWorkspaceForUser = vi.fn() const mockReadWorkflowAccessContext = vi.fn() function mockLocalizedRedirect({ @@ -38,6 +39,7 @@ vi.mock('@/lib/auth', () => ({ })) vi.mock('@/lib/workspaces/service', () => ({ + createDefaultWorkspaceForUser: (...args: unknown[]) => mockCreateDefaultWorkspaceForUser(...args), getUserWorkspaces: (...args: unknown[]) => mockGetUserWorkspaces(...args), })) @@ -72,6 +74,7 @@ describe('Workspace root page access guard', () => { }, }) mockGetUserWorkspaces.mockResolvedValue([{ id: 'workspace-1' }]) + mockCreateDefaultWorkspaceForUser.mockResolvedValue({ id: 'workspace-created' }) mockReadWorkflowAccessContext.mockResolvedValue(null) }) @@ -148,4 +151,14 @@ describe('Workspace root page access guard', () => { userId: 'user-1', }) }) + + it('repairs authenticated users with no workspace from the workspace entrypoint', async () => { + mockGetUserWorkspaces.mockResolvedValue([]) + + await expect(renderWorkspacePage('en')).rejects.toThrow( + 'redirect:/en/workspace/workspace-created/dashboard' + ) + + expect(mockCreateDefaultWorkspaceForUser).toHaveBeenCalledWith('user-1', 'Ada Lovelace') + }) }) diff --git a/apps/tradinggoose/app/[locale]/workspace/page.tsx b/apps/tradinggoose/app/[locale]/workspace/page.tsx index c308ced44..132bd0ee6 100644 --- a/apps/tradinggoose/app/[locale]/workspace/page.tsx +++ b/apps/tradinggoose/app/[locale]/workspace/page.tsx @@ -2,7 +2,7 @@ import { getSessionCookie } from 'better-auth/cookies' import { headers } from 'next/headers' import { getSession } from '@/lib/auth' import { readWorkflowAccessContext } from '@/lib/workflows/utils' -import { getUserWorkspaces } from '@/lib/workspaces/service' +import { createDefaultWorkspaceForUser, getUserWorkspaces } from '@/lib/workspaces/service' import { redirect } from '@/i18n/navigation' import { type LocaleCode, normalizeCallbackUrl, requireCanonicalCallbackPath } from '@/i18n/utils' @@ -88,10 +88,8 @@ export default async function WorkspacePage({ } const [workspace] = await getUserWorkspaces({ userId }) + const targetWorkspace = + workspace ?? (await createDefaultWorkspaceForUser(userId, session.user.name)) - if (!workspace) { - throw new Error('Authenticated user account has no workspace') - } - - return redirect({ href: `/workspace/${workspace.id}/dashboard`, locale }) + return redirect({ href: `/workspace/${targetWorkspace.id}/dashboard`, locale }) } From 4cd037065f9f213dc36d397b88237665c03d0207 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 16:12:07 -0600 Subject: [PATCH 269/284] feat(workspaces): bootstrap default workspace for MCP users Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/copilot/mcp/route.test.ts | 25 +++++- .../tradinggoose/app/api/copilot/mcp/route.ts | 27 ++++--- .../app/api/workspaces/route.test.ts | 67 +-------------- apps/tradinggoose/lib/workspaces/service.ts | 81 ++++++++----------- 4 files changed, 78 insertions(+), 122 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index ee1e6461f..e5155588e 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -9,6 +9,7 @@ const { mockAuthenticateApiKeyFromHeader, mockCheckApiEndpointRateLimit, mockCheckPublicApiEndpointRateLimit, + mockCreateDefaultWorkspaceForUser, mockGetCopilotRuntimeToolManifest, mockGetMcpServerToolIds, mockGetUserWorkspaces, @@ -18,6 +19,7 @@ const { mockAuthenticateApiKeyFromHeader: vi.fn(), mockCheckApiEndpointRateLimit: vi.fn(), mockCheckPublicApiEndpointRateLimit: vi.fn(), + mockCreateDefaultWorkspaceForUser: vi.fn(), mockGetCopilotRuntimeToolManifest: vi.fn(), mockGetMcpServerToolIds: vi.fn(), mockGetUserWorkspaces: vi.fn(), @@ -46,6 +48,7 @@ vi.mock('@/lib/copilot/tools/server/router', () => ({ })) vi.mock('@/lib/workspaces/service', () => ({ + createDefaultWorkspaceForUser: (...args: unknown[]) => mockCreateDefaultWorkspaceForUser(...args), getUserWorkspaces: (...args: unknown[]) => mockGetUserWorkspaces(...args), })) @@ -99,6 +102,11 @@ describe('Copilot MCP route', () => { { id: 'workspace-1', name: 'Research', permissions: 'admin' }, { id: 'workspace-2', name: 'Ops', permissions: 'read' }, ]) + mockCreateDefaultWorkspaceForUser.mockResolvedValue({ + id: 'workspace-created', + name: 'My Workspace', + permissions: 'admin', + }) mockGetMcpServerToolIds.mockReturnValue(['list_workflows', 'read_workflow']) mockGetCopilotRuntimeToolManifest.mockResolvedValue({ version: 'v1', @@ -160,6 +168,18 @@ describe('Copilot MCP route', () => { expect(body.result.instructions).not.toContain('No accessible workspaces') }) + it('repairs workspace-less authenticated users during initialize', async () => { + const { POST } = await import('./route') + mockGetUserWorkspaces.mockResolvedValueOnce([]) + + const response = await POST(createMcpRequest(initializeRequest())) + const body = await response.json() + + expect(response.status).toBe(200) + expect(mockCreateDefaultWorkspaceForUser).toHaveBeenCalledWith('user-1') + expect(body.result.instructions).toContain('workspaceId=workspace-created, permissions=admin') + }) + it('accepts a case-insensitive bearer auth scheme', async () => { const { POST } = await import('./route') @@ -357,7 +377,10 @@ describe('Copilot MCP route', () => { expect((await invalidInitializeResponse.json()).error.code).toBe(-32602) const unsupportedVersionBody = await unsupportedVersionResponse.json() expect(unsupportedVersionBody.error.code).toBe(-32000) - expect(unsupportedVersionBody.error.data.supportedProtocolVersions).toEqual(['2025-06-18', '2025-03-26']) + expect(unsupportedVersionBody.error.data.supportedProtocolVersions).toEqual([ + '2025-06-18', + '2025-03-26', + ]) expect(notificationResponse.status).toBe(202) expect(wrongProtocolHeaderResponse.status).toBe(400) expect((await wrongProtocolHeaderResponse.json()).error.message).toBe( diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index 3a1ab16fd..b0cf00655 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -8,13 +8,16 @@ import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-ke import { getCopilotRuntimeToolManifest } from '@/lib/copilot/runtime-tool-manifest' import { buildCopilotServerToolErrorResponse } from '@/lib/copilot/server-tool-errors' import { getMcpServerToolIds, routeExecution } from '@/lib/copilot/tools/server/router' -import { getUserWorkspaces } from '@/lib/workspaces/service' +import { createDefaultWorkspaceForUser, getUserWorkspaces } from '@/lib/workspaces/service' export const dynamic = 'force-dynamic' const MCP_PROTOCOL_VERSION = '2025-03-26' const MCP_NEGOTIABLE_PROTOCOL_VERSIONS = ['2025-06-18', MCP_PROTOCOL_VERSION] -const MCP_ACCEPTED_RESPONSE_INIT = { status: 202, headers: { 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION } } +const MCP_ACCEPTED_RESPONSE_INIT = { + status: 202, + headers: { 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION }, +} const SERVER_NAME = 'TradingGoose' const SERVER_VERSION = '0.1.0' const MAX_JSON_RPC_BATCH_SIZE = 10 @@ -113,10 +116,11 @@ async function authenticateCopilotMcpRequest( } async function buildInstructions(userId: string) { - const workspaces = await getUserWorkspaces({ userId }) - if (workspaces.length === 0) { - throw new Error('Authenticated TradingGoose users must have at least one workspace') - } + const existingWorkspaces = await getUserWorkspaces({ userId }) + const workspaces = + existingWorkspaces.length > 0 + ? existingWorkspaces + : [await createDefaultWorkspaceForUser(userId)] const workspaceLines = workspaces.map( (workspace) => `- ${workspace.name}: workspaceId=${workspace.id}, permissions=${workspace.permissions}` @@ -310,12 +314,13 @@ export async function POST(request: NextRequest) { } const requestProtocolVersion = request.headers.get('MCP-Protocol-Version') - const isInitialize = Array.isArray(body) ? body.some(isInitializeRequest) : isInitializeRequest(body) + const isInitialize = Array.isArray(body) + ? body.some(isInitializeRequest) + : isInitializeRequest(body) if (requestProtocolVersion && !isInitialize && requestProtocolVersion !== MCP_PROTOCOL_VERSION) { - return mcpJsonResponse( - jsonRpcError(null, -32000, 'Unsupported MCP protocol version'), - { status: 400 } - ) + return mcpJsonResponse(jsonRpcError(null, -32000, 'Unsupported MCP protocol version'), { + status: 400, + }) } if (Array.isArray(body)) { diff --git a/apps/tradinggoose/app/api/workspaces/route.test.ts b/apps/tradinggoose/app/api/workspaces/route.test.ts index 9718a45cb..c720aee00 100644 --- a/apps/tradinggoose/app/api/workspaces/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/route.test.ts @@ -5,15 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('Workspaces API Route', () => { const transactionMock = vi.fn() - const txInsertValuesMock = vi.fn() - const txInsertMock = vi.fn(() => ({ - values: txInsertValuesMock, - })) - const deleteWhereMock = vi.fn() - const deleteMock = vi.fn((_table: unknown) => ({ - where: deleteWhereMock, - })) - const mockSaveWorkflowToNormalizedTables = vi.fn() let userWorkspaces: Array<{ workspace: Record permissionType: 'admin' | 'write' | 'read' | null @@ -24,16 +15,8 @@ describe('Workspaces API Route', () => { vi.clearAllMocks() userWorkspaces = [] - txInsertValuesMock.mockResolvedValue(undefined) - transactionMock.mockImplementation(async (callback) => - callback({ insert: txInsertMock, delete: deleteMock }) - ) - deleteWhereMock.mockResolvedValue(undefined) - mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) - vi.doMock('@tradinggoose/db', () => ({ db: { - delete: deleteMock, select: vi.fn(() => ({ from: vi.fn(() => ({ leftJoin: vi.fn(() => ({ @@ -49,6 +32,9 @@ describe('Workspaces API Route', () => { })), })), transaction: transactionMock, + insert: vi.fn(() => ({ + values: vi.fn().mockResolvedValue(undefined), + })), }, })) @@ -59,11 +45,6 @@ describe('Workspaces API Route', () => { entityType: 'permissions.entityType', entityId: 'permissions.entityId', }, - workflow: { - id: 'workflow.id', - userId: 'workflow.userId', - workspaceId: 'workflow.workspaceId', - }, workspace: { id: 'workspace.id', ownerId: 'workspace.ownerId', @@ -87,21 +68,6 @@ describe('Workspaces API Route', () => { })), })) - vi.doMock('@/lib/workflows/defaults', () => ({ - buildDefaultWorkflowArtifacts: vi.fn(() => ({ - workflowState: { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - }, - })), - })) - - vi.doMock('@/lib/workflows/db-helpers', () => ({ - saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, - })) - vi.doMock('@/lib/workspaces/billing-owner', () => ({ toWorkspaceApiRecord: vi.fn((workspace) => ({ ...workspace, @@ -119,16 +85,6 @@ describe('Workspaces API Route', () => { vi.clearAllMocks() }) - async function postWorkspace() { - const { POST } = await import('@/app/api/workspaces/route') - return POST( - new Request('http://localhost/api/workspaces', { - method: 'POST', - body: JSON.stringify({ name: 'New Workspace' }), - }) - ) - } - it('returns an empty list without creating a default workspace during reads', async () => { const { GET } = await import('@/app/api/workspaces/route') @@ -208,21 +164,4 @@ describe('Workspaces API Route', () => { ]) expect(transactionMock).not.toHaveBeenCalled() }) - - it('removes a newly created workspace when default workflow state persistence fails', async () => { - mockSaveWorkflowToNormalizedTables.mockResolvedValue({ - success: false, - error: 'normalized state unavailable', - }) - - const response = await postWorkspace() - - expect(response.status).toBe(500) - expect(await response.json()).toEqual({ error: 'Failed to create workspace' }) - expect(deleteMock.mock.calls.map(([table]) => table)).toEqual([ - expect.objectContaining({ workspaceId: 'workflow.workspaceId' }), - expect.objectContaining({ ownerId: 'workspace.ownerId' }), - ]) - expect(deleteWhereMock).toHaveBeenCalledTimes(2) - }) }) diff --git a/apps/tradinggoose/lib/workspaces/service.ts b/apps/tradinggoose/lib/workspaces/service.ts index 817a0d773..346f08383 100644 --- a/apps/tradinggoose/lib/workspaces/service.ts +++ b/apps/tradinggoose/lib/workspaces/service.ts @@ -1,12 +1,11 @@ import { db } from '@tradinggoose/db' -import { permissions, workflow, workspace } from '@tradinggoose/db/schema' -import { desc, eq } from 'drizzle-orm' +import { permissions, workspace } from '@tradinggoose/db/schema' +import { desc, eq, sql } from 'drizzle-orm' import { buildWorkspaceAccessScope } from '@/lib/permissions/utils' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' -import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { toWorkspaceApiRecord } from '@/lib/workspaces/billing-owner' type WorkspaceRecord = typeof workspace.$inferSelect +const DEFAULT_WORKSPACE_BOOTSTRAP_LOCK_NAMESPACE = 1_904_202_615 export async function getUserWorkspaces({ userId }: { userId: string }) { const workspaceAccess = buildWorkspaceAccessScope(userId, workspace.id) @@ -36,14 +35,34 @@ export async function getUserWorkspaces({ userId }: { userId: string }) { export async function createDefaultWorkspaceForUser(userId: string, userName?: string | null) { const firstName = userName?.split(' ')[0] || null - return createWorkspace(userId, firstName ? `${firstName}'s Workspace` : 'My Workspace') + const name = firstName ? `${firstName}'s Workspace` : 'My Workspace' + + return db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(${DEFAULT_WORKSPACE_BOOTSTRAP_LOCK_NAMESPACE}, hashtext(${userId}))` + ) + + const [existingWorkspace] = await tx + .select() + .from(workspace) + .where(eq(workspace.ownerId, userId)) + .orderBy(desc(workspace.createdAt)) + .limit(1) + + if (existingWorkspace) { + return toOwnedWorkspaceApiRecord(existingWorkspace) + } + + const workspaceDetails = buildWorkspaceRecord(userId, name) + await tx.insert(workspace).values(workspaceDetails) + return toOwnedWorkspaceApiRecord(workspaceDetails) + }) } -export async function createWorkspace(userId: string, name: string) { +function buildWorkspaceRecord(userId: string, name: string): WorkspaceRecord { const workspaceId = crypto.randomUUID() - const workflowId = crypto.randomUUID() const now = new Date() - const workspaceDetails = { + return { id: workspaceId, name, ownerId: userId, @@ -54,48 +73,18 @@ export async function createWorkspace(userId: string, name: string) { createdAt: now, updatedAt: now, } satisfies WorkspaceRecord +} - await db.transaction(async (tx) => { - await tx.insert(workspace).values(workspaceDetails) - - await tx.insert(workflow).values({ - id: workflowId, - userId, - workspaceId, - folderId: null, - name: 'default-agent', - description: 'Your first workflow - start building here!', - color: '#3972F6', - lastSynced: now, - createdAt: now, - updatedAt: now, - isDeployed: false, - collaborators: [], - runCount: 0, - variables: {}, - isPublished: false, - marketplaceData: null, - }) - }) - - const { workflowState } = buildDefaultWorkflowArtifacts() - - try { - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState) - if (!saveResult.success) { - throw new Error(saveResult.error || 'Failed to persist default workflow state') - } - } catch (error) { - await db.transaction(async (tx) => { - await tx.delete(workflow).where(eq(workflow.id, workflowId)) - await tx.delete(workspace).where(eq(workspace.id, workspaceId)) - }) - throw error - } - +function toOwnedWorkspaceApiRecord(workspaceDetails: WorkspaceRecord) { return { ...toWorkspaceApiRecord(workspaceDetails), role: 'owner', permissions: 'admin', } } + +export async function createWorkspace(userId: string, name: string) { + const workspaceDetails = buildWorkspaceRecord(userId, name) + await db.insert(workspace).values(workspaceDetails) + return toOwnedWorkspaceApiRecord(workspaceDetails) +} From 3423264220ae4b36a6191841b6a48015c51384b7 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 16:37:55 -0600 Subject: [PATCH 270/284] feat(mcp): use configured app URL for auth and install flows Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/auth/mcp/authorize/route.test.ts | 8 +++----- .../tradinggoose/app/api/auth/mcp/authorize/route.ts | 12 ++++++------ .../app/api/auth/mcp/start/route.test.ts | 2 +- apps/tradinggoose/app/api/auth/mcp/start/route.ts | 2 +- .../app/mcp/[[...command]]/route.test.ts | 6 +++--- apps/tradinggoose/app/mcp/[[...command]]/route.ts | 2 +- apps/tradinggoose/lib/urls/utils.test.ts | 6 ------ apps/tradinggoose/lib/urls/utils.ts | 8 +------- 8 files changed, 16 insertions(+), 30 deletions(-) diff --git a/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts index c0c846db4..13e5974ab 100644 --- a/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts @@ -55,9 +55,7 @@ function createAuthorizeRequest( describe('MCP authorize route', () => { beforeEach(() => { vi.clearAllMocks() - mockGetBaseUrl.mockImplementation((request?: NextRequest) => - request ? new URL(request.url).origin : 'https://studio.example.test' - ) + mockGetBaseUrl.mockReturnValue('https://studio.example.test') mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) mockGetSessionCookie.mockReturnValue(null) mockApproveMcpDeviceLogin.mockResolvedValue({ @@ -78,14 +76,14 @@ describe('MCP authorize route', () => { code: 'login-code', locale: 'es', }, - {}, + { origin: 'https://studio.example.test' }, 'https://preview.example.test' ) ) expect(response.status).toBe(307) expect(response.headers.get('location')).toBe( - 'https://preview.example.test/es/mcp/authorize?status=approved' + 'https://studio.example.test/es/mcp/authorize?status=approved' ) expect(mockApproveMcpDeviceLogin).toHaveBeenCalledWith({ approvalToken: 'approval-token', diff --git a/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts b/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts index 0bf1baf02..f1d715cce 100644 --- a/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts @@ -7,15 +7,15 @@ import { normalizeLocaleCode } from '@/i18n/utils' export const dynamic = 'force-dynamic' -function redirectToAuthorizeStatus(request: NextRequest, locale: string, status: string) { - const url = new URL(`/${normalizeLocaleCode(locale)}/mcp/authorize`, getBaseUrl(request)) +function redirectToAuthorizeStatus(locale: string, status: string) { + const url = new URL(`/${normalizeLocaleCode(locale)}/mcp/authorize`, getBaseUrl()) url.searchParams.set('status', status) return NextResponse.redirect(url) } function redirectToLogin(request: NextRequest, locale: string, code: string) { const normalizedLocale = normalizeLocaleCode(locale) - const url = new URL(`/${normalizedLocale}/login`, getBaseUrl(request)) + const url = new URL(`/${normalizedLocale}/login`, getBaseUrl()) if (getSessionCookie(request.headers)) { url.searchParams.set('reauth', '1') } @@ -27,7 +27,7 @@ function redirectToLogin(request: NextRequest, locale: string, code: string) { } function hasTrustedFormOrigin(request: NextRequest) { - const trustedOrigin = new URL(getBaseUrl(request)).origin + const trustedOrigin = new URL(getBaseUrl()).origin const submittedOrigin = request.headers.get('origin') if (submittedOrigin) { try { @@ -65,7 +65,7 @@ export async function POST(request: NextRequest) { !approvalToken || !hasTrustedFormOrigin(request) ) { - return redirectToAuthorizeStatus(request, locale, 'invalid') + return redirectToAuthorizeStatus(locale, 'invalid') } const session = await getSession(request.headers) @@ -78,5 +78,5 @@ export async function POST(request: NextRequest) { ? await approveMcpDeviceLogin({ approvalToken, code, userId: session.user.id }) : await cancelMcpDeviceLogin({ approvalToken, code, userId: session.user.id }) - return redirectToAuthorizeStatus(request, locale, result.status) + return redirectToAuthorizeStatus(locale, result.status) } diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts index b9e88b4c4..2ac1dede4 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts @@ -65,7 +65,7 @@ describe('MCP login start route', () => { verificationKey: 'verification-key', expiresAt: '2026-06-19T12:00:00.000Z', intervalSeconds: 2, - authorizeUrl: 'https://preview.example.test/mcp/authorize?code=login-code', + authorizeUrl: 'https://studio.example.test/mcp/authorize?code=login-code', }) expect(mockCheckPublicApiEndpointRateLimit).toHaveBeenCalledWith(request, 'mcp-auth-start') expect(mockStartMcpDeviceLogin).toHaveBeenCalledWith() diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.ts index a7911b4ab..54b53299a 100644 --- a/apps/tradinggoose/app/api/auth/mcp/start/route.ts +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.ts @@ -16,7 +16,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'API key access is not configured' }, { status: 503 }) } - const baseUrl = getBaseUrl(request) + const baseUrl = getBaseUrl() const login = await startMcpDeviceLogin() const authorizeUrl = new URL('/mcp/authorize', baseUrl) authorizeUrl.searchParams.set('code', login.code) diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts index 5e639985f..e0216da45 100644 --- a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts @@ -104,7 +104,7 @@ describe('MCP install route', () => { expect(script).toContain('TARGETS="codex"') }) - it('uses request-origin and quoted installer base URLs', async () => { + it('uses configured and quoted installer base URLs', async () => { const response = await callInstaller( '/mcp', undefined, @@ -113,8 +113,8 @@ describe('MCP install route', () => { ) const script = await response.text() - expect(script).toContain("BASE_URL='https://request.example.test'") - expect(script).not.toContain("BASE_URL='https://studio.example.test'") + expect(script).toContain("BASE_URL='https://studio.example.test'") + expect(script).not.toContain("BASE_URL='https://request.example.test'") const shellScript = buildMcpInstallScript( "https://studio.example.test/$(touch pwn)`bad`'quote", diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.ts index 538491b0b..8bd242b79 100644 --- a/apps/tradinggoose/app/mcp/[[...command]]/route.ts +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.ts @@ -58,7 +58,7 @@ export async function GET( const format = resolveScriptFormat(request) - return new NextResponse(buildMcpInstallScript(getBaseUrl(request), { ...options, format }), { + return new NextResponse(buildMcpInstallScript(getBaseUrl(), { ...options, format }), { headers: { 'Cache-Control': 'no-store', 'Content-Type': diff --git a/apps/tradinggoose/lib/urls/utils.test.ts b/apps/tradinggoose/lib/urls/utils.test.ts index 73061ad17..bb6abc2c5 100644 --- a/apps/tradinggoose/lib/urls/utils.test.ts +++ b/apps/tradinggoose/lib/urls/utils.test.ts @@ -22,12 +22,6 @@ describe('url helpers', () => { expect(getBaseUrl()).toBe('https://www.tradinggoose.ai') }) - it('uses the request origin when a request is provided', () => { - const request = new Request('https://preview.example.test/api/auth/mcp/start') - - expect(getBaseUrl(request)).toBe('https://preview.example.test') - }) - it('treats preview and production as configured app URLs', () => { mockEnv.NEXT_PUBLIC_APP_URL = 'https://preview.tradinggoose.ai' diff --git a/apps/tradinggoose/lib/urls/utils.ts b/apps/tradinggoose/lib/urls/utils.ts index 1c46faddf..d78da6c2b 100644 --- a/apps/tradinggoose/lib/urls/utils.ts +++ b/apps/tradinggoose/lib/urls/utils.ts @@ -1,12 +1,6 @@ import { getEnv } from '@/lib/env' -type RequestOrigin = Pick - -export function getBaseUrl(request?: RequestOrigin): string { - if (request) { - return new URL(request.url).origin - } - +export function getBaseUrl(): string { const value = getEnv('NEXT_PUBLIC_APP_URL')?.trim() if (!value) { From d311c8591bb1d58ba97d48d8a403e991cf829518 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 16:38:08 -0600 Subject: [PATCH 271/284] feat(mcp): support MCP 2025-06-18 negotiation Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/copilot/mcp/route.test.ts | 32 +++++++++++++++++-- .../tradinggoose/app/api/copilot/mcp/route.ts | 32 ++++++++++++++++--- apps/tradinggoose/lib/mcp/client.ts | 2 +- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index e5155588e..63ebb8272 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -148,7 +148,7 @@ describe('Copilot MCP route', () => { const response = await POST(createMcpRequest(initializeRequest())) const body = await response.json() - expect(response.headers.get('MCP-Protocol-Version')).toBe('2025-03-26') + expect(response.headers.get('MCP-Protocol-Version')).toBe('2025-06-18') expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('sk-tradinggoose-test', { keyTypes: ['personal'], }) @@ -168,6 +168,16 @@ describe('Copilot MCP route', () => { expect(body.result.instructions).not.toContain('No accessible workspaces') }) + it('keeps older supported MCP protocol negotiation internally consistent', async () => { + const { POST } = await import('./route') + + const response = await POST(createMcpRequest(initializeRequest(2, '2025-03-26'))) + const body = await response.json() + + expect(response.headers.get('MCP-Protocol-Version')).toBe('2025-03-26') + expect(body.result.protocolVersion).toBe('2025-03-26') + }) + it('repairs workspace-less authenticated users during initialize', async () => { const { POST } = await import('./route') mockGetUserWorkspaces.mockResolvedValueOnce([]) @@ -364,13 +374,20 @@ describe('Copilot MCP route', () => { const notificationResponse = await POST( createMcpRequest({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }) ) - const wrongProtocolHeaderResponse = await POST( + const negotiatedProtocolHeaderResponse = await POST( createMcpRequest( { jsonrpc: '2.0', id: 11, method: 'tools/list' }, 'Bearer sk-tradinggoose-test', { 'MCP-Protocol-Version': '2025-06-18' } ) ) + const wrongProtocolHeaderResponse = await POST( + createMcpRequest( + { jsonrpc: '2.0', id: 12, method: 'tools/list' }, + 'Bearer sk-tradinggoose-test', + { 'MCP-Protocol-Version': '1.0' } + ) + ) expect((await invalidJsonRpcResponse.json()).error.code).toBe(-32600) expect((await nullIdResponse.json()).error.code).toBe(-32600) @@ -382,12 +399,23 @@ describe('Copilot MCP route', () => { '2025-03-26', ]) expect(notificationResponse.status).toBe(202) + expect(negotiatedProtocolHeaderResponse.status).toBe(200) expect(wrongProtocolHeaderResponse.status).toBe(400) expect((await wrongProtocolHeaderResponse.json()).error.message).toBe( 'Unsupported MCP protocol version' ) }) + it('explicitly rejects GET streams because this MCP endpoint is POST-only', async () => { + const { GET } = await import('./route') + + const response = await GET() + + expect(response.status).toBe(405) + expect(response.headers.get('allow')).toBe('POST') + expect(response.headers.get('MCP-Protocol-Version')).toBe('2025-06-18') + }) + it('returns per-entry invalid request errors for malformed batches', async () => { const { POST } = await import('./route') diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index b0cf00655..5b11fd3a3 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -12,8 +12,8 @@ import { createDefaultWorkspaceForUser, getUserWorkspaces } from '@/lib/workspac export const dynamic = 'force-dynamic' -const MCP_PROTOCOL_VERSION = '2025-03-26' -const MCP_NEGOTIABLE_PROTOCOL_VERSIONS = ['2025-06-18', MCP_PROTOCOL_VERSION] +const MCP_PROTOCOL_VERSION = '2025-06-18' +const MCP_NEGOTIABLE_PROTOCOL_VERSIONS = [MCP_PROTOCOL_VERSION, '2025-03-26'] const MCP_ACCEPTED_RESPONSE_INIT = { status: 202, headers: { 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION }, @@ -57,7 +57,15 @@ function jsonRpcError(id: JsonRpcId | null, code: number, message: string, data? function mcpJsonResponse(body: unknown, init?: ResponseInit) { const headers = new Headers(init?.headers) - headers.set('MCP-Protocol-Version', MCP_PROTOCOL_VERSION) + const responseProtocolVersion = (body as { result?: { protocolVersion?: unknown } } | null) + ?.result?.protocolVersion + headers.set( + 'MCP-Protocol-Version', + typeof responseProtocolVersion === 'string' && + MCP_NEGOTIABLE_PROTOCOL_VERSIONS.includes(responseProtocolVersion) + ? responseProtocolVersion + : MCP_PROTOCOL_VERSION + ) return NextResponse.json(body, { ...init, @@ -225,7 +233,7 @@ async function handleJsonRpcRequest(entry: unknown, auth: AuthenticatedMcpUser) } return jsonRpcResult(id, { - protocolVersion: MCP_PROTOCOL_VERSION, + protocolVersion, capabilities: { tools: {}, }, @@ -317,7 +325,11 @@ export async function POST(request: NextRequest) { const isInitialize = Array.isArray(body) ? body.some(isInitializeRequest) : isInitializeRequest(body) - if (requestProtocolVersion && !isInitialize && requestProtocolVersion !== MCP_PROTOCOL_VERSION) { + if ( + requestProtocolVersion && + !isInitialize && + !MCP_NEGOTIABLE_PROTOCOL_VERSIONS.includes(requestProtocolVersion) + ) { return mcpJsonResponse(jsonRpcError(null, -32000, 'Unsupported MCP protocol version'), { status: 400, }) @@ -350,3 +362,13 @@ export async function POST(request: NextRequest) { const response = await handleJsonRpcRequest(body, auth) return response ? mcpJsonResponse(response) : new NextResponse(null, MCP_ACCEPTED_RESPONSE_INIT) } + +export async function GET() { + return new NextResponse(null, { + status: 405, + headers: { + Allow: 'POST', + 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION, + }, + }) +} diff --git a/apps/tradinggoose/lib/mcp/client.ts b/apps/tradinggoose/lib/mcp/client.ts index f5e2d0084..95168f90d 100644 --- a/apps/tradinggoose/lib/mcp/client.ts +++ b/apps/tradinggoose/lib/mcp/client.ts @@ -2,7 +2,7 @@ * MCP (Model Context Protocol) JSON-RPC 2.0 Client * * Implements the client side of MCP protocol with support for: - * - Streamable HTTP transport (MCP 2025-03-26) + * - Streamable HTTP transport (MCP 2025-06-18 and 2025-03-26) * - Connection lifecycle management * - Tool execution and discovery * - Session management with Mcp-Session-Id header From d56019bb91a9aecbcc67755b235946ffd3f39e70 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 16:59:29 -0600 Subject: [PATCH 272/284] fix(api-key): handle unconfigured storage consistently Return a consistent error when API-key storage is unavailable, short-circuit matching helpers, cover the behavior in tests, and rename the MCP setup key label to match the personal API key flow. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/lib/api-key/service.test.ts | 25 +++++++++++++++++-- apps/tradinggoose/lib/api-key/service.ts | 12 +++++++-- apps/tradinggoose/lib/mcp/auth.ts | 2 +- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/apps/tradinggoose/lib/api-key/service.test.ts b/apps/tradinggoose/lib/api-key/service.test.ts index b6de414b3..5b6614dd1 100644 --- a/apps/tradinggoose/lib/api-key/service.test.ts +++ b/apps/tradinggoose/lib/api-key/service.test.ts @@ -4,8 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockDbSelect } = vi.hoisted(() => ({ +const { mockDbSelect, mockEnv } = vi.hoisted(() => ({ mockDbSelect: vi.fn(), + mockEnv: { API_ENCRYPTION_KEY: 'a'.repeat(64) } as { API_ENCRYPTION_KEY?: string }, })) vi.mock('@tradinggoose/db', () => ({ @@ -34,7 +35,7 @@ vi.mock('drizzle-orm', () => ({ like: vi.fn((field, value) => ({ field, value })), })) -vi.mock('@/lib/env', () => ({ env: { API_ENCRYPTION_KEY: 'a'.repeat(64) } })) +vi.mock('@/lib/env', () => ({ env: mockEnv })) vi.mock('@/lib/logs/console/logger', () => ({ createLogger: () => ({ debug: vi.fn(), @@ -58,6 +59,7 @@ describe('API key service', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() + mockEnv.API_ENCRYPTION_KEY = 'a'.repeat(64) mockApiKeyRows([]) }) @@ -71,6 +73,25 @@ describe('API key service', () => { expect(mockDbSelect).not.toHaveBeenCalled() }) + it('disables API-key authentication when encrypted storage is not configured', async () => { + mockEnv.API_ENCRYPTION_KEY = undefined + const { authenticateApiKeyFromHeader, storedApiKeyMatches } = await import('./service') + + await expect( + authenticateApiKeyFromHeader(`sk-tradinggoose-${'a'.repeat(32)}`) + ).resolves.toMatchObject({ + success: false, + error: 'API key access is not configured', + }) + await expect( + storedApiKeyMatches( + `sk-tradinggoose-${'a'.repeat(32)}`, + 'sk-tradinggoose-...aaaa:'.concat('b'.repeat(64), ':iv:encrypted:tag') + ) + ).resolves.toBe(false) + expect(mockDbSelect).not.toHaveBeenCalled() + }) + it('rejects retired plaintext API-key prefixes before reading key records', async () => { const { authenticateApiKeyFromHeader } = await import('./service') diff --git a/apps/tradinggoose/lib/api-key/service.ts b/apps/tradinggoose/lib/api-key/service.ts index d5943fc7e..73dc96d66 100644 --- a/apps/tradinggoose/lib/api-key/service.ts +++ b/apps/tradinggoose/lib/api-key/service.ts @@ -11,6 +11,7 @@ const API_KEY_SECRET_PATTERN = /^[A-Za-z0-9_-]{32}$/ const API_ENCRYPTION_KEY_PATTERN = /^[a-fA-F0-9]{64}$/ const API_KEY_PREFIX = 'sk-tradinggoose-' const STORED_API_KEY_SEPARATOR = ':' +const API_KEY_ACCESS_NOT_CONFIGURED = 'API key access is not configured' const DEFAULT_API_KEY_AUTH_TYPES: ApiKeyType[] = ['personal', 'workspace'] // Canonical stored shape: display:lookupDigest:iv:ciphertext:authTag. // Retired plaintext/encrypted rows are intentionally not authenticated. @@ -61,6 +62,9 @@ export async function authenticateApiKeyFromHeader( if (!apiKey) { return { success: false, error: 'API key required' } } + if (!isApiKeyStorageAvailable()) { + return { success: false, error: API_KEY_ACCESS_NOT_CONFIGURED } + } if (!isApiKeyFormat(apiKey)) { return { success: false, error: 'Invalid API key' } } @@ -178,7 +182,7 @@ function getApiKeyLookupDigest(apiKey: string): string { function getApiEncryptionKey(): Buffer { const key = env.API_ENCRYPTION_KEY if (!key) { - throw new Error('API_ENCRYPTION_KEY is required for API key storage') + throw new Error(API_KEY_ACCESS_NOT_CONFIGURED) } if (!API_ENCRYPTION_KEY_PATTERN.test(key)) { throw new Error('API_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)') @@ -238,7 +242,11 @@ function constantTimeEqual(left: string, right: string): boolean { } export async function storedApiKeyMatches(apiKey: string, storedApiKey: string): Promise { - if (!isApiKeyFormat(apiKey) || !isStoredApiKeyFormat(storedApiKey)) { + if ( + !isApiKeyStorageAvailable() || + !isApiKeyFormat(apiKey) || + !isStoredApiKeyFormat(storedApiKey) + ) { return false } const [, lookupDigest] = storedApiKey.split(STORED_API_KEY_SEPARATOR) diff --git a/apps/tradinggoose/lib/mcp/auth.ts b/apps/tradinggoose/lib/mcp/auth.ts index f627d4366..d0fd5b072 100644 --- a/apps/tradinggoose/lib/mcp/auth.ts +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -477,7 +477,7 @@ export async function acknowledgeMcpDeviceLogin({ id: nanoid(), userId: approvedState.userId, workspaceId: null, - name: `TradingGoose MCP Access ${now.toISOString()}`, + name: `TradingGoose Personal API Key (MCP setup) ${now.toISOString()}`, key: storedKey, type: 'personal', createdAt: now, From ebc62089546fb51b6189fdb2cac2d2b924886657 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 17:32:08 -0600 Subject: [PATCH 273/284] fix(mcp): normalize protocol negotiation and JSON-RPC handling Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/copilot/mcp/route.test.ts | 18 ++++++ .../tradinggoose/app/api/copilot/mcp/route.ts | 61 +++++++++++++------ 2 files changed, 59 insertions(+), 20 deletions(-) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts index 63ebb8272..77733d2d7 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -207,6 +207,7 @@ describe('Copilot MCP route', () => { const response = await POST(createMcpRequest({ jsonrpc: '2.0', id: 2, method: 'tools/list' })) const body = await response.json() + expect(response.headers.get('MCP-Protocol-Version')).toBe('2025-03-26') expect(body.result.tools).toEqual([ { name: 'list_workflows', @@ -388,6 +389,17 @@ describe('Copilot MCP route', () => { { 'MCP-Protocol-Version': '1.0' } ) ) + const invalidToolArgumentsResponse = await POST( + createMcpRequest({ + jsonrpc: '2.0', + id: 13, + method: 'tools/call', + params: { name: 'list_workflows', arguments: [] }, + }) + ) + const jsonRpcResponseMessage = await POST( + createMcpRequest({ jsonrpc: '2.0', id: 14, result: {} }) + ) expect((await invalidJsonRpcResponse.json()).error.code).toBe(-32600) expect((await nullIdResponse.json()).error.code).toBe(-32600) @@ -404,6 +416,12 @@ describe('Copilot MCP route', () => { expect((await wrongProtocolHeaderResponse.json()).error.message).toBe( 'Unsupported MCP protocol version' ) + const invalidToolArgumentsBody = await invalidToolArgumentsResponse.json() + expect(invalidToolArgumentsBody.error.code).toBe(-32602) + expect(invalidToolArgumentsBody.error.message).toBe('Invalid tools/call params') + expect(jsonRpcResponseMessage.status).toBe(202) + expect(await jsonRpcResponseMessage.text()).toBe('') + expect(mockRouteExecution).not.toHaveBeenCalled() }) it('explicitly rejects GET streams because this MCP endpoint is POST-only', async () => { diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts index 5b11fd3a3..f4886f020 100644 --- a/apps/tradinggoose/app/api/copilot/mcp/route.ts +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -13,11 +13,8 @@ import { createDefaultWorkspaceForUser, getUserWorkspaces } from '@/lib/workspac export const dynamic = 'force-dynamic' const MCP_PROTOCOL_VERSION = '2025-06-18' -const MCP_NEGOTIABLE_PROTOCOL_VERSIONS = [MCP_PROTOCOL_VERSION, '2025-03-26'] -const MCP_ACCEPTED_RESPONSE_INIT = { - status: 202, - headers: { 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION }, -} +const MCP_DEFAULT_PROTOCOL_VERSION = '2025-03-26' +const MCP_NEGOTIABLE_PROTOCOL_VERSIONS = [MCP_PROTOCOL_VERSION, MCP_DEFAULT_PROTOCOL_VERSION] const SERVER_NAME = 'TradingGoose' const SERVER_VERSION = '0.1.0' const MAX_JSON_RPC_BATCH_SIZE = 10 @@ -55,7 +52,11 @@ function jsonRpcError(id: JsonRpcId | null, code: number, message: string, data? } } -function mcpJsonResponse(body: unknown, init?: ResponseInit) { +function mcpJsonResponse( + body: unknown, + init?: ResponseInit, + protocolVersion = MCP_DEFAULT_PROTOCOL_VERSION +) { const headers = new Headers(init?.headers) const responseProtocolVersion = (body as { result?: { protocolVersion?: unknown } } | null) ?.result?.protocolVersion @@ -64,7 +65,7 @@ function mcpJsonResponse(body: unknown, init?: ResponseInit) { typeof responseProtocolVersion === 'string' && MCP_NEGOTIABLE_PROTOCOL_VERSIONS.includes(responseProtocolVersion) ? responseProtocolVersion - : MCP_PROTOCOL_VERSION + : protocolVersion ) return NextResponse.json(body, { @@ -73,6 +74,13 @@ function mcpJsonResponse(body: unknown, init?: ResponseInit) { }) } +function mcpAcceptedResponse(protocolVersion: string) { + return new NextResponse(null, { + status: 202, + headers: { 'MCP-Protocol-Version': protocolVersion }, + }) +} + function mcpRateLimitResponse(result: RateLimitResult) { const headers: Record = { 'X-RateLimit-Limit': result.limit.toString(), @@ -170,6 +178,9 @@ function getToolCallParams(params: unknown) { if (typeof name !== 'string' || name.trim().length === 0) { return null } + if (args !== undefined && (!args || typeof args !== 'object' || Array.isArray(args))) { + return null + } return { name, @@ -181,6 +192,13 @@ function isJsonRpcRequest(value: unknown): value is JsonRpcRequest { return !!value && typeof value === 'object' && !Array.isArray(value) } +function isJsonRpcResponse(value: unknown) { + if (!isJsonRpcRequest(value) || value.jsonrpc !== '2.0' || value.method !== undefined) { + return false + } + return 'result' in value || 'error' in value +} + function getResponseId(request: JsonRpcRequest): JsonRpcId | null { return typeof request.id === 'string' || typeof request.id === 'number' ? request.id : null } @@ -325,42 +343,45 @@ export async function POST(request: NextRequest) { const isInitialize = Array.isArray(body) ? body.some(isInitializeRequest) : isInitializeRequest(body) - if ( - requestProtocolVersion && - !isInitialize && - !MCP_NEGOTIABLE_PROTOCOL_VERSIONS.includes(requestProtocolVersion) - ) { - return mcpJsonResponse(jsonRpcError(null, -32000, 'Unsupported MCP protocol version'), { + const protocolVersion = + requestProtocolVersion ?? (isInitialize ? MCP_PROTOCOL_VERSION : MCP_DEFAULT_PROTOCOL_VERSION) + const json = (body: unknown, init?: ResponseInit) => mcpJsonResponse(body, init, protocolVersion) + const accepted = () => mcpAcceptedResponse(protocolVersion) + + if (!isInitialize && !MCP_NEGOTIABLE_PROTOCOL_VERSIONS.includes(protocolVersion)) { + return json(jsonRpcError(null, -32000, 'Unsupported MCP protocol version'), { status: 400, }) } if (Array.isArray(body)) { if (body.length === 0) { - return mcpJsonResponse(jsonRpcError(null, -32600, 'Invalid JSON-RPC request')) + return json(jsonRpcError(null, -32600, 'Invalid JSON-RPC request')) } if (body.length > MAX_JSON_RPC_BATCH_SIZE) { - return mcpJsonResponse( + return json( jsonRpcError(null, -32600, `JSON-RPC batch size cannot exceed ${MAX_JSON_RPC_BATCH_SIZE}`) ) } if (body.some(isInitializeRequest)) { - return mcpJsonResponse(jsonRpcError(null, -32600, 'initialize cannot be batched')) + return json(jsonRpcError(null, -32600, 'initialize cannot be batched')) } const responses = [] for (const entry of body) { + if (isJsonRpcResponse(entry)) continue const response = await handleJsonRpcRequest(entry, auth) if (response) responses.push(response) } - return responses.length > 0 - ? mcpJsonResponse(responses) - : new NextResponse(null, MCP_ACCEPTED_RESPONSE_INIT) + return responses.length > 0 ? json(responses) : accepted() } + if (isJsonRpcResponse(body)) { + return accepted() + } const response = await handleJsonRpcRequest(body, auth) - return response ? mcpJsonResponse(response) : new NextResponse(null, MCP_ACCEPTED_RESPONSE_INIT) + return response ? json(response) : accepted() } export async function GET() { From 7f38fb76ca9b5fa6b03b2a4e9aadb057b2f60583 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 18:41:39 -0600 Subject: [PATCH 274/284] fix(workflows): return conflict when editable workflow state is missing Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../api/workflows/[id]/status/route.test.ts | 25 +++++++++++++++++++ .../app/api/workflows/[id]/status/route.ts | 7 +++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts index 11bfc07f6..bc5b265b0 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts @@ -199,4 +199,29 @@ describe('Workflow Status API Route', () => { const data = await response.json() expect(data.data.needsRedeployment).toBe(true) }) + + it('returns conflict when deployed workflow editable state is missing', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + error: null, + workflow: { + isDeployed: true, + deployedAt: null, + isPublished: false, + }, + }) + mockLoadWorkflowState.mockResolvedValue(null) + mockLimit.mockResolvedValue([{ state: { blocks: {}, edges: [], loops: {}, parallels: {} } }]) + + const request = new NextRequest('http://localhost:3000/api/workflows/workflow-123/status') + const params = Promise.resolve({ id: 'workflow-123' }) + + const { GET } = await import('@/app/api/workflows/[id]/status/route') + const response = await GET(request, { params }) + + expect(response.status).toBe(409) + expect(await response.json()).toMatchObject({ + success: false, + error: 'Workflow state is missing', + }) + }) }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts index 4b59869a5..a50b260c7 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts @@ -46,7 +46,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .limit(1), ]) - if (currentState && active?.state) { + if (!currentState) { + logger.warn(`[${requestId}] Workflow ${id} is missing editable state`) + return createErrorResponse('Workflow state is missing', 409) + } + + if (active?.state) { needsRedeployment = hasWorkflowChanged(currentState as any, active.state as any) } } From 5556d4b8684e4fdd78771333e8e4cb75a16e80ef Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 18:42:10 -0600 Subject: [PATCH 275/284] feat(mcp): allow disabled server drafts without a url Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/mcp/servers/schema.ts | 10 ++++++++-- .../lib/copilot/entity-documents.ts | 12 ++++++++---- .../tools/server/entities/shared.test.ts | 19 +++++++++++++++++++ .../widgets/editor_mcp/editor-mcp-body.tsx | 11 ++++++++++- .../widgets/widgets/list_mcp/index.tsx | 1 + 5 files changed, 46 insertions(+), 7 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/schema.ts b/apps/tradinggoose/app/api/mcp/servers/schema.ts index e2ad305e4..dd121e5c3 100644 --- a/apps/tradinggoose/app/api/mcp/servers/schema.ts +++ b/apps/tradinggoose/app/api/mcp/servers/schema.ts @@ -4,7 +4,7 @@ const McpServerBaseSchema = z.object({ name: z.string().trim().min(1), description: z.string().optional(), transport: z.enum(['http', 'sse', 'streamable-http']), - url: z.string().min(1), + url: z.string().optional(), headers: z.record(z.string()).optional(), command: z.string().optional(), args: z.array(z.string()).optional(), @@ -14,7 +14,13 @@ const McpServerBaseSchema = z.object({ enabled: z.boolean().optional(), }) -export const CreateMcpServerSchema = McpServerBaseSchema +export const CreateMcpServerSchema = McpServerBaseSchema.refine( + (server) => server.enabled === false || !!server.url?.trim(), + { + message: 'URL is required when an MCP server is enabled', + path: ['url'], + } +) export const UpdateMcpServerSchema = McpServerBaseSchema.partial() .extend({ diff --git a/apps/tradinggoose/lib/copilot/entity-documents.ts b/apps/tradinggoose/lib/copilot/entity-documents.ts index b03a692d2..1f02ddd37 100644 --- a/apps/tradinggoose/lib/copilot/entity-documents.ts +++ b/apps/tradinggoose/lib/copilot/entity-documents.ts @@ -171,9 +171,13 @@ export function normalizeEntityFields( throw new Error(`Invalid MCP server transport "${String(source.transport ?? '')}"`) } + const enabled = typeof source.enabled === 'boolean' ? source.enabled : true const rawUrl = typeof source.url === 'string' ? source.url.trim() : '' - const validation = validateMcpServerUrl(rawUrl) - if (!validation.isValid) { + const validation = rawUrl ? validateMcpServerUrl(rawUrl) : null + if (!rawUrl && enabled) { + throw new Error('Invalid MCP server URL: URL is required and must be a string') + } + if (validation && !validation.isValid) { throw new Error(`Invalid MCP server URL: ${validation.error}`) } @@ -181,7 +185,7 @@ export function normalizeEntityFields( name: typeof source.name === 'string' ? source.name.trim() : '', description: typeof source.description === 'string' ? source.description.trim() : '', transport: source.transport, - url: validation.normalizedUrl ?? rawUrl, + url: validation?.normalizedUrl ?? rawUrl, headers: normalizeHttpHeaderRecord(source.headers), command: typeof source.command === 'string' ? source.command.trim() : '', args: Array.isArray(source.args) @@ -190,7 +194,7 @@ export function normalizeEntityFields( env: normalizeStringRecord(source.env), timeout: typeof source.timeout === 'number' ? source.timeout : 30000, retries: typeof source.retries === 'number' ? source.retries : 3, - enabled: typeof source.enabled === 'boolean' ? source.enabled : true, + enabled, } } case 'knowledge_base': diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts index d820fd6e0..4efadde9c 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { INDICATOR_DOCUMENT_FORMAT, MCP_SERVER_DOCUMENT_FORMAT, + normalizeEntityFields, SKILL_DOCUMENT_FORMAT, } from '@/lib/copilot/entity-documents' import { hashServerToolReviewBase } from '@/lib/copilot/tools/server/base-tool' @@ -181,6 +182,24 @@ const length = input.int(14, 'Length', 1, 50, 1) expect(create).not.toHaveBeenCalled() }) + it('allows disabled MCP server drafts without a URL', () => { + expect( + normalizeEntityFields('mcp_server', { + name: 'Draft MCP', + description: '', + transport: 'streamable-http', + url: '', + headers: {}, + command: '', + args: [], + env: {}, + timeout: 30000, + retries: 3, + enabled: false, + }) + ).toMatchObject({ name: 'Draft MCP', url: '', enabled: false }) + }) + it('rejects MCP server edit documents without a URL before persisting state', async () => { await expect( executeUpdateEntityDocumentMutation( diff --git a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx index 9705fa66a..eb19f50d4 100644 --- a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx @@ -277,7 +277,14 @@ export function EditorMcpWidgetBody({ }, [copy.unnamedServer, formDataState, selectedServerId, testConnection, workspaceId]) const handleRefreshTools = useCallback(async () => { - if (!workspaceId || !selectedServerId) return + if ( + !workspaceId || + !selectedServerId || + formDataState.enabled === false || + !formDataState.url?.trim() + ) { + return + } try { const refreshResult = await refreshServerApi( @@ -295,6 +302,8 @@ export function EditorMcpWidgetBody({ }, [ copy.failedToRefreshMcpServer, fetchServers, + formDataState.enabled, + formDataState.url, refreshServer, refreshTools, selectedServerId, diff --git a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx index 771abc408..2cfc1d003 100644 --- a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx @@ -47,6 +47,7 @@ import { resolveMcpServerId } from '@/widgets/widgets/_shared/mcp/utils' const buildDefaultMcpServer = (name: string) => ({ ...MCP_SERVER_DEFAULTS, + enabled: false, name, transport: 'streamable-http' as const, }) From 933199d18350e4f5094e864ec0d45d7397381c49 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 19:19:34 -0600 Subject: [PATCH 276/284] fix(mcp): align server mutations with realtime review sessions Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/mcp/servers/[id]/route.ts | 53 +++++++++---------- .../tradinggoose/app/api/mcp/servers/route.ts | 49 ++++++++--------- .../app/api/mcp/servers/schema.ts | 11 ++-- .../widgets/editor_mcp/editor-mcp-body.tsx | 9 +++- 4 files changed, 63 insertions(+), 59 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts index 49b619fae..85061f75b 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts @@ -1,23 +1,24 @@ -import { db } from '@tradinggoose/db' -import { mcpServers } from '@tradinggoose/db/schema' -import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { buildSavedEntityDescriptor } from '@/lib/copilot/review-sessions/identity' +import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' import { createLogger } from '@/lib/logs/console/logger' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { savedEntityRowToFields } from '@/lib/yjs/entity-state' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' import { applySavedEntityState, SavedEntityPersistenceError, } from '@/lib/yjs/server/apply-entity-state' -import { UpdateMcpServerSchema } from '../schema' +import { readBootstrappedSavedEntityFields } from '@/lib/yjs/server/bootstrap-review-target' +import { RenameMcpServerSchema } from '../schema' const logger = createLogger('McpServerAPI') export const dynamic = 'force-dynamic' /** - * PATCH - Update an MCP server in the workspace (requires write permission) + * PATCH - Rename an MCP server in the workspace (requires write permission). + * Full config edits are saved through the MCP saved-entity Yjs session. */ export const PATCH = withMcpAuth('write')( async ( @@ -30,7 +31,7 @@ export const PATCH = withMcpAuth('write')( try { const rawBody = getParsedBody(request) || (await request.json()) - const parseResult = UpdateMcpServerSchema.safeParse(rawBody) + const parseResult = RenameMcpServerSchema.safeParse(rawBody) if (!parseResult.success) { return createMcpErrorResponse( new Error(`Invalid request body: ${parseResult.error.message}`), @@ -43,22 +44,15 @@ export const PATCH = withMcpAuth('write')( logger.info(`[${requestId}] Updating MCP server: ${serverId} in workspace: ${workspaceId}`, { userId, - updates: Object.keys(body).filter((k) => k !== 'workspaceId'), + updates: ['name'], }) - const [server] = await db - .select() - .from(mcpServers) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) - ) - .limit(1) - - if (!server) { + const access = await verifyReviewTargetAccess( + userId, + buildSavedEntityDescriptor('mcp_server', serverId, workspaceId), + 'write' + ) + if (!access.hasAccess || access.workspaceId !== workspaceId) { return createMcpErrorResponse( new Error('Server not found or access denied'), 'Server not found', @@ -66,21 +60,26 @@ export const PATCH = withMcpAuth('write')( ) } - const { workspaceId: _bodyWorkspaceId, ...updates } = body - const fields = savedEntityRowToFields('mcp_server', server) - const nextFields = { ...fields, ...updates } - await applySavedEntityState('mcp_server', serverId, nextFields) + const currentFields = await readBootstrappedSavedEntityFields( + 'mcp_server', + serverId, + workspaceId + ) + await applySavedEntityState('mcp_server', serverId, { ...currentFields, name: body.name }) logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`) return createMcpSuccessResponse({ server: { id: serverId, workspaceId, - name: String(nextFields.name ?? ''), - enabled: nextFields.enabled !== false, + name: body.name, }, }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return createMcpErrorResponse(error, error.message, error.status) + } + if (error instanceof SavedEntityPersistenceError) { return createMcpErrorResponse(error, error.message, error.status) } diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 332433e7e..5e7a63e1c 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -2,6 +2,8 @@ import { db } from '@tradinggoose/db' import { mcpServers } from '@tradinggoose/db/schema' import { and, eq, inArray, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { buildSavedEntityDescriptor } from '@/lib/copilot/review-sessions/identity' +import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' import { createLogger } from '@/lib/logs/console/logger' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { McpServerConfigError, mcpService } from '@/lib/mcp/service' @@ -173,18 +175,12 @@ export const DELETE = withMcpAuth('write')( logger.info(`[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}`) - const [deletedServer] = await db - .delete(mcpServers) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) - ) - .returning({ id: mcpServers.id }) - - if (!deletedServer) { + const access = await verifyReviewTargetAccess( + userId, + buildSavedEntityDescriptor('mcp_server', serverId, workspaceId), + 'write' + ) + if (!access.hasAccess || access.workspaceId !== workspaceId) { return createMcpErrorResponse( new Error('Server not found or access denied'), 'Server not found', @@ -192,23 +188,28 @@ export const DELETE = withMcpAuth('write')( ) } - const cleanupResults = await Promise.allSettled([ - deleteYjsSessionInSocketServer(deletedServer.id), - notifyEntityListMemberRemoved('mcp_server', workspaceId, deletedServer.id), + await Promise.all([ + deleteYjsSessionInSocketServer(serverId), + notifyEntityListMemberRemoved('mcp_server', workspaceId, serverId), ]) - const cleanupFailure = cleanupResults.find((result) => result.status === 'rejected') - if (cleanupFailure) { - logger.warn(`[${requestId}] Deleted MCP server but failed realtime cleanup`, { - error: cleanupFailure.reason, - serverId: deletedServer.id, - }) - } + await db + .delete(mcpServers) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) + ) + ) - logger.info(`[${requestId}] Successfully deleted MCP server: ${deletedServer.id}`) + logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) return createMcpSuccessResponse({ - message: `Server ${deletedServer.id} deleted successfully`, + message: `Server ${serverId} deleted successfully`, }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return createMcpErrorResponse(error, error.message, error.status) + } logger.error(`[${requestId}] Error deleting MCP server:`, error) return createMcpErrorResponse( error instanceof Error ? error : new Error('Failed to delete MCP server'), diff --git a/apps/tradinggoose/app/api/mcp/servers/schema.ts b/apps/tradinggoose/app/api/mcp/servers/schema.ts index dd121e5c3..52e5fd81c 100644 --- a/apps/tradinggoose/app/api/mcp/servers/schema.ts +++ b/apps/tradinggoose/app/api/mcp/servers/schema.ts @@ -22,10 +22,7 @@ export const CreateMcpServerSchema = McpServerBaseSchema.refine( } ) -export const UpdateMcpServerSchema = McpServerBaseSchema.partial() - .extend({ - workspaceId: z.string().optional(), - }) - .refine(({ workspaceId: _workspaceId, ...updates }) => Object.keys(updates).length > 0, { - message: 'At least one MCP server field is required', - }) +export const RenameMcpServerSchema = z.object({ + name: z.string().trim().min(1), + workspaceId: z.string().optional(), +}) diff --git a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx index eb19f50d4..774227d1b 100644 --- a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx @@ -323,7 +323,12 @@ export function EditorMcpWidgetBody({ try { await serverSession.save() initialFormDataRef.current = formDataState - await handleRefreshTools() + if (formDataState.enabled === false || !formDataState.url?.trim()) { + await fetchServers(workspaceId) + await refreshTools() + } else { + await handleRefreshTools() + } } catch (error) { console.error('Failed to save MCP server', error) setSaveError(copy.failedToSaveMcpServer) @@ -331,8 +336,10 @@ export function EditorMcpWidgetBody({ }, [ copy.failedToSaveMcpServer, copy.serverNameRequired, + fetchServers, formDataState, handleRefreshTools, + refreshTools, serverSession.doc, serverSession.save, selectedServerId, From 7c61fdfc212e80e19f28c6a6159a67609b430c7c Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 19:41:56 -0600 Subject: [PATCH 277/284] docs(changelog): add June 28 2026 staging changelog Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- changelog/June-28-2026.md | 91 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 changelog/June-28-2026.md diff --git a/changelog/June-28-2026.md b/changelog/June-28-2026.md new file mode 100644 index 000000000..601cc87cc --- /dev/null +++ b/changelog/June-28-2026.md @@ -0,0 +1,91 @@ +# June-28-2026 + +## feat/copilot-mcp @ 933199d1 vs upstream/staging + +### Summary +- Adds a TradingGoose Copilot MCP surface for trusted personal coding agents, including JSON-RPC tool listing/calling at `apps/tradinggoose/app/api/copilot/mcp/route.ts`, local installer scripts at `apps/tradinggoose/app/mcp/[[...command]]/route.ts`, and browser-approved device login under `apps/tradinggoose/app/api/auth/mcp/*`. +- Moves Copilot entity, workflow, monitor, knowledge, credential, and MCP server tool execution behind the server tool router in `apps/tradinggoose/lib/copilot/tools/server/router.ts`, with review staging for Studio and full-access execution for authenticated MCP calls. +- Makes Yjs the required editable-state path for workflows and saved entities through `apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts`, `apps/tradinggoose/socket-server/routes/http.ts`, and the saved-entity helpers in `apps/tradinggoose/lib/yjs/server/apply-entity-state.ts`. +- Simplifies API-key authentication to current encrypted `sk-tradinggoose-...` keys in `apps/tradinggoose/lib/api-key/service.ts` and removes legacy plaintext/key migration behavior. + +### Branch Scope +- Compared `61dc75e4449ae1b9f7a9aaa55cc25bf0c46cfb71..933199d18350e4f5094e864ec0d45d7397381c49`; `61dc75e4` is both the merge base and the refreshed `origin/staging` commit in this checkout. +- The requested base was `upstream/staging`, but this clone has no `upstream` remote and no `refs/remotes/upstream/staging`; `git fetch upstream staging` failed with `fatal: 'upstream' does not appear to be a git repository`. The available staging comparison was therefore refreshed with `git fetch origin staging` and documented here against that concrete staging ref. +- `git status --short --untracked-files=all`, `git diff --stat HEAD`, and `git diff --name-status HEAD` were clean before this changelog file was created, so no pre-existing dirty feature edits were included despite the dirty-tree request. +- Main areas touched: Copilot MCP routes and auth, API-key storage and rate limiting, server-side Copilot tool routing, saved entity document contracts, workflow/Yjs persistence, MCP server UI/store/editor flows, workspace bootstrap, monitors, knowledge bases, deployment/runtime configuration, and focused tests. + +### Key Changes +- `apps/tradinggoose/app/api/copilot/mcp/route.ts` implements the external MCP JSON-RPC endpoint with `initialize`, `ping`, `tools/list`, `tools/call`, `resources/list`, and `prompts/list`, negotiates `2025-06-18` and `2025-03-26`, rejects oversized batches, and authenticates only personal API keys through `authenticateApiKeyFromHeader(..., { keyTypes: ['personal'] })`. +- `apps/tradinggoose/app/api/copilot/mcp/route.ts` exposes only IDs returned by `getMcpServerToolIds()` and dispatches calls through `routeExecution()` with `{ userId, accessLevel: 'full' }`, while `buildInstructions()` lists accessible workspaces and creates a default workspace through `createDefaultWorkspaceForUser()` when an MCP user has none. +- `apps/tradinggoose/app/mcp/[[...command]]/route.ts`, `apps/tradinggoose/lib/mcp/install-script.ts`, and `apps/tradinggoose/lib/mcp/local-config-writer-script.ts` add shell and PowerShell setup/login scripts for Codex, Cursor, Claude, and OpenCode. Local config stores the MCP URL plus `Authorization: Bearer `, not workspace or entity targets. +- `apps/tradinggoose/lib/mcp/auth.ts` owns the device-login contract: `startMcpDeviceLogin()`, `createMcpDeviceLoginApprovalChallenge()`, `pollMcpDeviceLogin()`, `acknowledgeMcpDeviceLogin()`, `approveMcpDeviceLogin()`, and `cancelMcpDeviceLogin()` store signed pending/approved/cancelled states in `verification` rows and issue personal API keys only after browser approval. +- `apps/tradinggoose/app/api/auth/mcp/start/route.ts`, `apps/tradinggoose/app/api/auth/mcp/poll/route.ts`, `apps/tradinggoose/app/api/auth/mcp/authorize/route.ts`, and `apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx` wrap the device login flow with public rate limits, API-key-storage availability checks, trusted form-origin validation, localized status copy, and login redirects that preserve locale. +- `apps/tradinggoose/lib/api-key/service.ts` makes the stored key shape `display:lookupDigest:iv:ciphertext:authTag`, validates only current `sk-tradinggoose-...` secrets, and requires a valid `API_ENCRYPTION_KEY`; `apps/tradinggoose/lib/api/rate-limit.ts` adds scoped limits for `copilot-mcp`, `copilot-mcp-public`, `mcp-auth-start`, and `mcp-auth-poll`. +- `apps/tradinggoose/lib/copilot/tools/server/router.ts` is the canonical server tool registry for workflows, saved entities, monitors, knowledge, Google Drive, credentials, environment variables, search, and MCP servers. It validates `ToolId`, parses `ServerToolArgSchemas`, merges `workspaceId` into `ServerToolExecutionContext` with `withWorkspaceArgContext()`, and checks workspace access before executing tools. +- `apps/tradinggoose/lib/copilot/tools/server/base-tool.ts` and `apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts` define review-safe mutation execution through `shouldStageServerToolMutationForReview()`, `hashServerToolReviewBase()`, `assertAcceptedServerToolReviewBase()`, `stageServerManagedToolReview()`, and `acceptServerManagedToolReview()`. Review tokens store encrypted payloads and are claimed before full-access execution. +- `apps/tradinggoose/lib/copilot/entity-documents.ts` defines the saved-entity document formats `tg-skill-document-v1`, `tg-custom-tool-document-v1`, `tg-indicator-document-v1`, `tg-mcp-server-document-v1`, `tg-knowledge-base-document-v1`, and `tg-workflow-variable-document-v1`, plus `normalizeEntityFields()`, `parseEntityDocument()`, `serializeEntityDocument()`, and `ENTITY_SECRET_PLACEHOLDER`. +- `apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts` centralizes saved-entity list/read/create/update behavior with `buildSavedEntityListInfo()`, `executeCreateEntityDocumentMutation()`, `executeUpdateEntityDocumentMutation()`, `readSavedEntityDocumentFields()`, `verifyWorkspaceContext()`, and `verifySavedEntityContext()`. Lists intentionally return only `entityId` and canonical `entityName`. +- `apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts` preserves `[redacted]` header/env placeholders on MCP server edits, rejects placeholders on new server creation, and applies edits through `applySavedEntityState()`. `apps/tradinggoose/app/api/mcp/servers/schema.ts` now allows disabled MCP server drafts without a URL but still requires a URL when `enabled !== false`. +- `apps/tradinggoose/lib/yjs/entity-session.ts` is the live saved-entity document contract: entity sessions own top-level `fields` and `metadata`, entity-list sessions own `members`, and MCP server list members carry `enabled`. `apps/tradinggoose/lib/yjs/entity-state.ts` maps saved DB rows into these fields and defines the `SavedEntityRealtimeRequiredError` contract. +- `apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts` reads workflow, saved-entity, and entity-list snapshots from the socket server, bootstraps missing sessions from canonical DB state, and intentionally maps unavailable saved-entity snapshots to `SavedEntityRealtimeRequiredError` instead of falling back to stale app-side data. +- `apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts` is the Next.js-to-socket bridge for snapshots, workflow patches, saved-entity applies, raw Yjs update applies, entity-list member notifications, and session deletion. `apps/tradinggoose/socket-server/routes/http.ts` exposes the corresponding internal endpoints and uses `applyThroughStaging()` so server-authored mutations persist a detached Yjs copy before updating the live collaborative doc. +- `apps/tradinggoose/lib/workflows/db-helpers.ts` adds `requireWorkflowRealtimeState()`, `WorkflowRealtimeRequiredError`, duplicate block/edge ID remapping, and `saveWorkflowYjsDocToDb()`. `apps/tradinggoose/app/api/workflows/[id]/route.ts` now reads editable workflow state from Yjs and maps bridge failures with `createWorkflowRealtimeRequiredResponse()` from `apps/tradinggoose/app/api/workflows/utils.ts`. +- `apps/tradinggoose/lib/workflows/validation.ts` keeps Agent custom tool selections on canonical runtime IDs by requiring `custom_` via `getCustomToolEntityIdFromRuntimeId()` and sanitizing invalid custom tool entries before normalized persistence. +- `apps/tradinggoose/lib/workspaces/service.ts` adds `createDefaultWorkspaceForUser()` with a PostgreSQL advisory transaction lock, and `apps/tradinggoose/app/[locale]/workspace/page.tsx` uses it to bootstrap a first workspace during root workspace entry. +- `apps/tradinggoose/stores/mcp-servers/store.ts`, `apps/tradinggoose/hooks/use-mcp-tools.ts`, and `apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx` update MCP server UI data flow: enabled/deleted state drives discovery, `MCP_TOOLS_CHANGED_EVENT` invalidates tool discovery, and the editor saves MCP server fields through `useSavedEntityYjsSession('mcp_server', ...)`. +- `apps/tradinggoose/app/api/monitors/update-service.ts` pulls monitor update behavior out of the route so `apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts` can reuse the same validation and persistence path for reviewed Copilot monitor edits. +- `apps/tradinggoose/lib/copilot/runtime-tool-manifest.ts` converts `ToolArgSchemas` into JSON schema for runtime tools and attaches semantic validators, while `apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts` is now the client-side display metadata surface for server-managed tool calls. + +### Design Decisions +- External MCP is a personal-token, full-access server-tool surface. Studio review tokens remain internal to the app review flow, while MCP callers receive sanitized tool results/errors through JSON-RPC `tools/call`. +- The MCP local config is intentionally target-agnostic. Workspace IDs, entity IDs, review targets, and document targets are supplied per tool call from `tools/list` schemas and list/read results, not persisted in local MCP client config. +- Saved entity mutation tools use full document replacement with explicit document format constants. This keeps skill, custom tool, indicator, MCP server, and knowledge base edits on one parse/normalize/serialize path instead of per-tool patch shapes. +- Yjs is the canonical editable-state transport for workflows and saved entities. Normalized tables seed and materialize Yjs state, but user-facing editable reads and server-authored mutations must go through the socket bridge so stale DB fallbacks do not overwrite live collaborative state. +- Saved-entity list tools are discovery surfaces only. They return IDs, canonical names, and MCP `enabled` state from live entity-list Yjs sessions instead of reintroducing per-entity detail mappers into list calls. +- Current API keys are not legacy-compatible. `apps/tradinggoose/lib/api-key/auth.ts` was removed so future auth work extends `apps/tradinggoose/lib/api-key/service.ts` instead of reviving plaintext or dual-format matching. + +### Shared Contracts and Helpers to Reuse +- Use `getMcpServerToolIds()` and `routeExecution()` from `apps/tradinggoose/lib/copilot/tools/server/router.ts` for server tool dispatch; do not create a second MCP-visible tool allowlist. +- Use `ServerToolExecutionContext`, `withWorkspaceArgContext()`, `throwIfServerToolAborted()`, `shouldStageServerToolMutationForReview()`, `hashServerToolReviewBase()`, and `assertAcceptedServerToolReviewBase()` from `apps/tradinggoose/lib/copilot/tools/server/base-tool.ts` for every server tool. +- Use `stageServerManagedToolReview()` and `acceptServerManagedToolReview()` from `apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts` for Studio-reviewed server mutations that must survive stale-base checks. +- Use `COPILOT_TOOL_IDS`, `ToolArgSchemas`, `ServerToolArgSchemas`, and `getCopilotRuntimeToolManifest()` from `apps/tradinggoose/lib/copilot/registry.ts` and `apps/tradinggoose/lib/copilot/runtime-tool-manifest.ts` as the canonical tool schema surface. +- Use the document constants and helpers in `apps/tradinggoose/lib/copilot/entity-documents.ts`: `ENTITY_DOCUMENT_FORMATS`, `normalizeEntityFields()`, `parseEntityDocument()`, `serializeEntityDocument()`, `getEntityDocumentName()`, and `ENTITY_SECRET_PLACEHOLDER`. +- Use `executeCreateEntityDocumentMutation()`, `executeUpdateEntityDocumentMutation()`, `buildSavedEntityListInfo()`, `readSavedEntityDocumentFields()`, `verifyWorkspaceContext()`, and `verifySavedEntityContext()` from `apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts` when adding saved-entity Copilot tools. +- Use `applySavedEntityState()`, `saveSavedEntityYjsDocToDb()`, and `publishCreatedSavedEntityListMembers()` from `apps/tradinggoose/lib/yjs/server/apply-entity-state.ts` for saved-entity materialization and list synchronization. +- Use `getYjsSnapshot()`, `applyWorkflowPatchInSocketServer()`, `applyEntityStateInSocketServer()`, `applyYjsUpdateInSocketServer()`, `notifyEntityListMembersUpserted()`, `notifyEntityListMemberRemoved()`, and `deleteYjsSessionInSocketServer()` from `apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts` instead of direct socket-server HTTP calls. +- Use `requireWorkflowRealtimeState()`, `WorkflowRealtimeRequiredError`, `saveWorkflowToNormalizedTables()`, and `saveWorkflowYjsDocToDb()` from `apps/tradinggoose/lib/workflows/db-helpers.ts` for workflow persistence/read behavior. +- Use `startMcpDeviceLogin()`, `pollMcpDeviceLogin()`, `acknowledgeMcpDeviceLogin()`, `createMcpDeviceLoginApprovalChallenge()`, `approveMcpDeviceLogin()`, and `cancelMcpDeviceLogin()` from `apps/tradinggoose/lib/mcp/auth.ts` for MCP login lifecycle work. +- Use `createApiKey()`, `authenticateApiKeyFromHeader()`, `isApiKeyStorageAvailable()`, `getStoredApiKey()`, and `storedApiKeyMatches()` from `apps/tradinggoose/lib/api-key/service.ts`; do not import or recreate removed API-key auth helpers. +- Use `createDefaultWorkspaceForUser()` from `apps/tradinggoose/lib/workspaces/service.ts` when a signed-in user or MCP-authenticated user needs an initial workspace. +- Use `MCP_TOOLS_CHANGED_EVENT`, `useMcpServersStore()`, and `useMcpTools()` for MCP server/tool UI cache behavior, and keep MCP server editing on `useSavedEntityYjsSession('mcp_server', ...)`. + +### Removed or Replaced Items +- Removed the client-side Copilot entity tool path under `apps/tradinggoose/lib/copilot/tools/client/entities/*`. Use `apps/tradinggoose/lib/copilot/tools/server/entities/*` and `entities/shared.ts` for saved entity list/read/create/edit/rename tools. +- Removed client-side workflow mutation/read tools under `apps/tradinggoose/lib/copilot/tools/client/workflow/*`. Use server workflow tools in `apps/tradinggoose/lib/copilot/tools/server/workflow/*`, `apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts`, and `apps/tradinggoose/lib/copilot/workflow/block-output-utils.ts`. +- Removed client-side monitor and knowledge tool files under `apps/tradinggoose/lib/copilot/tools/client/monitor/*` and `apps/tradinggoose/lib/copilot/tools/client/knowledge/knowledge-base.ts`. Use `apps/tradinggoose/lib/copilot/tools/server/monitor/*` and `apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts`. +- Removed `apps/tradinggoose/app/api/workflows/[id]/state/route.ts` and its test. Do not restore a Next.js full-state save route; editable workflow state should flow through `applyWorkflowState()`, `applyWorkflowPatchInSocketServer()`, and the internal socket-server apply endpoint. +- Removed `apps/tradinggoose/lib/api-key/auth.ts`. Do not reintroduce legacy plaintext or dual-format API-key authentication; extend `apps/tradinggoose/lib/api-key/service.ts`. +- Removed `apps/tradinggoose/lib/workflows/custom-tools-persistence.ts`. Do not extract and upsert custom tools from workflow state on save; custom tools are saved entities and Agent blocks should retain canonical `custom_` references. +- Removed `apps/tradinggoose/socket-server/yjs/persistence.ts` and the old persistence-specific upstream-utils test. Do not bring back Redis/local TTL Yjs session blobs; the socket server owns live docs in `apps/tradinggoose/socket-server/yjs/upstream-utils.ts` and materializes through internal bridge endpoints. +- Renamed `apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tool-utils.ts` to `apps/tradinggoose/lib/copilot/tools/server/monitor/shared.ts`; use the server shared monitor envelope helpers there. +- Moved `apps/tradinggoose/lib/copilot/tools/client/workflow/block-output-utils.ts` to `apps/tradinggoose/lib/copilot/workflow/block-output-utils.ts`; import the neutral workflow helper path from new server/client code. +- No project `*/migration/*` files were part of this branch delta. + +### Future Branch Guardrails +- Do not add new MCP-exposed tools by bypassing `getMcpServerToolIds()`; add the tool to the server router and registry schema so both Studio and MCP surfaces share validation. +- Do not store or infer `workspaceId`, `entityId`, or review targets in local MCP config files. Keep local config limited to the MCP URL and bearer token. +- Do not make external MCP auth accept workspace keys or session cookies. The JSON-RPC endpoint expects personal API keys issued by the MCP device login flow. +- Do not revive app-side DB fallbacks for editable workflow or saved-entity reads. Bridge or realtime failures should surface as `WORKFLOW_REALTIME_REQUIRED` or `SAVED_ENTITY_REALTIME_REQUIRED`. +- Do not reintroduce `/api/workflows/[id]/state` or any route that directly writes full workflow editable state outside the Yjs socket apply path. +- Do not expose MCP server secrets in Copilot documents. Preserve `[redacted]` through `preserveMcpServerSecretPlaceholders()` and reject placeholders on new MCP server values. +- Do not create per-entity list implementations that return full documents. Use entity-list Yjs sessions and reserve detail reads for `read_*` tools. +- Do not accept custom tool schema `function.name` as identity. Saved custom tools use document `title` for display and `custom_` runtime IDs for workflow/tool references. +- Do not introduce legacy API-key support, migration backfill, or plaintext key matching. API-key access is unavailable unless `API_ENCRYPTION_KEY` satisfies the current storage contract. + +### Validation Notes +- Followed the requested `staging-changelog` workflow and `changelog/TEMPLATE.md`, with the explicit user-requested base label of `upstream/staging`. +- Reviewed `git status --short --untracked-files=all`, `git remote -v`, `git fetch upstream staging`, `git show-ref --verify refs/remotes/upstream/staging`, `git show-ref --verify refs/remotes/origin/staging`, `git fetch origin staging`, `git merge-base origin/staging feat/copilot-mcp`, `git log --oneline 61dc75e4..feat/copilot-mcp`, `git diff --stat`, `git diff --name-status --find-renames`, `git diff --summary`, `git diff --dirstat`, `git diff --stat HEAD`, and `git diff --name-status HEAD`. +- Confirmed `upstream/staging` cannot be fetched in this clone because no `upstream` remote is configured; the concrete comparison used refreshed `origin/staging` at `61dc75e4`. +- Inspected `AGENTS.md`, `changelog/TEMPLATE.md`, existing changelog style, package scripts in `package.json`, Copilot MCP route/tests, MCP auth routes/page/service/tests, installer/config writer scripts/tests, API-key service/tests, rate limiting, server tool router/base/review helpers/tests, runtime manifest/registry, saved entity document helpers/tests, Yjs entity/session/apply/bootstrap/snapshot bridge tests, socket-server internal Yjs routes/upstream utils/tests, workflow route/db helpers/validation/tests, MCP server schema/service/store/hook/editor tests, monitor update service/tool tests, knowledge server tools, workspace bootstrap, and deleted paths from the merge base. +- Confirmed there were no pre-existing unstaged or staged feature changes before this changelog update and no changed project `*/migration/*` files in the branch diff. +- No automated test suite was run for this changelog-only update; validation focused on merge-base diff review, related source/test inspection, dirty-tree confirmation, and template conformance. From d60260e8f12d273dd7f9a44b5eb4b6598c376c78 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 20:09:15 -0600 Subject: [PATCH 278/284] fix(mcp): tolerate cleanup failures after MCP server delete Delete the database record first, then run realtime cleanup as a best-effort follow-up so cleanup failures do not block a successful delete. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/mcp/servers/route.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 5e7a63e1c..aa53a7e0d 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -188,10 +188,6 @@ export const DELETE = withMcpAuth('write')( ) } - await Promise.all([ - deleteYjsSessionInSocketServer(serverId), - notifyEntityListMemberRemoved('mcp_server', workspaceId, serverId), - ]) await db .delete(mcpServers) .where( @@ -202,14 +198,16 @@ export const DELETE = withMcpAuth('write')( ) ) + await Promise.allSettled([ + deleteYjsSessionInSocketServer(serverId), + notifyEntityListMemberRemoved('mcp_server', workspaceId, serverId), + ]) + logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully`, }) } catch (error) { - if (error instanceof SavedEntityRealtimeRequiredError) { - return createMcpErrorResponse(error, error.message, error.status) - } logger.error(`[${requestId}] Error deleting MCP server:`, error) return createMcpErrorResponse( error instanceof Error ? error : new Error('Failed to delete MCP server'), From 908cde94e403af0f06f0c9bfe03708d0fe09702a Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 20:34:20 -0600 Subject: [PATCH 279/284] fix(api-key): reject legacy stored api-key rows Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/users/me/api-keys/route.ts | 10 +++---- .../app/api/workspaces/[id]/api-keys/route.ts | 10 +++---- apps/tradinggoose/lib/api-key/service.test.ts | 3 ++- apps/tradinggoose/lib/api-key/service.ts | 26 ++++++++++++------- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/apps/tradinggoose/app/api/users/me/api-keys/route.ts b/apps/tradinggoose/app/api/users/me/api-keys/route.ts index b20080142..af04b24ed 100644 --- a/apps/tradinggoose/app/api/users/me/api-keys/route.ts +++ b/apps/tradinggoose/app/api/users/me/api-keys/route.ts @@ -36,12 +36,10 @@ export async function GET(request: NextRequest) { .where(and(eq(apiKey.userId, userId), eq(apiKey.type, 'personal'))) .orderBy(apiKey.createdAt) - const maskedKeys = await Promise.all( - keys.map(async ({ key, ...apiKey }) => ({ - ...apiKey, - displayKey: await getApiKeyDisplayFormat(key), - })) - ) + const maskedKeys = keys.flatMap(({ key, ...apiKey }) => { + const displayKey = getApiKeyDisplayFormat(key) + return displayKey ? [{ ...apiKey, displayKey }] : [] + }) return NextResponse.json({ keys: maskedKeys }) } catch (error) { diff --git a/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts b/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts index 7fee9dc3c..5db1cceaf 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts @@ -61,12 +61,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .where(and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.type, 'workspace'))) .orderBy(apiKey.createdAt) - const formattedWorkspaceKeys = await Promise.all( - workspaceKeys.map(async ({ key, ...apiKey }) => ({ - ...apiKey, - displayKey: await getApiKeyDisplayFormat(key), - })) - ) + const formattedWorkspaceKeys = workspaceKeys.flatMap(({ key, ...apiKey }) => { + const displayKey = getApiKeyDisplayFormat(key) + return displayKey ? [{ ...apiKey, displayKey }] : [] + }) return NextResponse.json({ keys: formattedWorkspaceKeys, diff --git a/apps/tradinggoose/lib/api-key/service.test.ts b/apps/tradinggoose/lib/api-key/service.test.ts index 5b6614dd1..f84b5815e 100644 --- a/apps/tradinggoose/lib/api-key/service.test.ts +++ b/apps/tradinggoose/lib/api-key/service.test.ts @@ -130,10 +130,11 @@ describe('API key service', () => { }) it('rejects retired stored API-key formats without fallback decryption', async () => { - const { storedApiKeyMatches } = await import('./service') + const { getApiKeyDisplayFormat, storedApiKeyMatches } = await import('./service') await expect( storedApiKeyMatches(`sk-tradinggoose-${'b'.repeat(32)}`, 'iv:ciphertext:authTag') ).resolves.toBe(false) + expect(getApiKeyDisplayFormat('iv:ciphertext:authTag')).toBeNull() }) }) diff --git a/apps/tradinggoose/lib/api-key/service.ts b/apps/tradinggoose/lib/api-key/service.ts index 73dc96d66..8f0f7c511 100644 --- a/apps/tradinggoose/lib/api-key/service.ts +++ b/apps/tradinggoose/lib/api-key/service.ts @@ -13,8 +13,8 @@ const API_KEY_PREFIX = 'sk-tradinggoose-' const STORED_API_KEY_SEPARATOR = ':' const API_KEY_ACCESS_NOT_CONFIGURED = 'API key access is not configured' const DEFAULT_API_KEY_AUTH_TYPES: ApiKeyType[] = ['personal', 'workspace'] -// Canonical stored shape: display:lookupDigest:iv:ciphertext:authTag. -// Retired plaintext/encrypted rows are intentionally not authenticated. +// Current API-key contract: only sk-tradinggoose-* rows stored as +// display:lookupDigest:iv:ciphertext:authTag are authenticated or listed. export type ApiKeyType = 'personal' | 'workspace' @@ -231,10 +231,17 @@ export function getStoredApiKey(apiKey: string): string { return encryptApiKeyForStorage(apiKey) } -function isStoredApiKeyFormat(storedApiKey: string): boolean { +function isCurrentStoredApiKeyFormat(storedApiKey: string): boolean { const [displayKey, lookupDigest, iv, encrypted, authTag, extra] = storedApiKey.split(STORED_API_KEY_SEPARATOR) - return Boolean(displayKey && lookupDigest?.length === 64 && iv && encrypted && authTag && !extra) + return Boolean( + displayKey?.startsWith(API_KEY_PREFIX) && + lookupDigest?.length === 64 && + iv && + encrypted && + authTag && + !extra + ) } function constantTimeEqual(left: string, right: string): boolean { @@ -245,7 +252,7 @@ export async function storedApiKeyMatches(apiKey: string, storedApiKey: string): if ( !isApiKeyStorageAvailable() || !isApiKeyFormat(apiKey) || - !isStoredApiKeyFormat(storedApiKey) + !isCurrentStoredApiKeyFormat(storedApiKey) ) { return false } @@ -261,9 +268,8 @@ export async function storedApiKeyMatches(apiKey: string, storedApiKey: string): } } -export async function getApiKeyDisplayFormat(storedApiKey: string): Promise { - if (!isStoredApiKeyFormat(storedApiKey)) { - return '****' - } - return storedApiKey.split(STORED_API_KEY_SEPARATOR)[0] +export function getApiKeyDisplayFormat(storedApiKey: string): string | null { + return isCurrentStoredApiKeyFormat(storedApiKey) + ? storedApiKey.split(STORED_API_KEY_SEPARATOR)[0] + : null } From 9aef90ef91056d242c87b1b80d9db39c2dce97df Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Sun, 28 Jun 2026 21:06:26 -0600 Subject: [PATCH 280/284] docs(readme): add Copilot-MCP setup instructions Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- README.md | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e0db1f359..dc053ec78 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,33 @@ It is built for analytics, research, charting, monitoring, and workflow automati Project Overview +--- + +### Copilot-MCP + +You can install TradingGoose MCP to use any local agentic tool like Codex, Claude Code, Cursor, ZCode as Copilot to perform TradingGoose-Studio operations + +#### Mac/Linux: +connect to the hosted instance: +``` +curl -fsSL https://TradingGoose.ai/mcp/setup | sh +``` +connect to self-hosted instance: +``` +curl -fsSL http://localhost:3000/mcp/setup | sh +``` + +#### Windows +connect to the hosted instance: +``` +irm https://TradingGoose.ai/mcp/setup | iex +``` + +connect to self-hosted instance: +``` +irm http://localhost:3000/mcp/setup | iex +``` ## Quick Start @@ -86,12 +112,10 @@ cd ../../packages/db && cp .env.example .env #### 4. Run database migrations ``` -cd packages/db -bunx drizzle-kit migrate --config=./drizzle.config.ts +bun run db:migrate ``` -#### 5. Start development servers +#### 5. Start full development servers ``` -cd ../.. bun run dev:full ``` From 036be1d06ca87990fe018db8bc56c251a857e403 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Mon, 29 Jun 2026 00:11:48 -0600 Subject: [PATCH 281/284] feat(workflows): persist workflow state through Yjs materialization Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../app/api/templates/[id]/use/route.ts | 28 +++++----- .../workflows/[id]/duplicate/route.test.ts | 34 ++++++------ .../app/api/workflows/[id]/duplicate/route.ts | 35 ++++++------ .../app/api/workflows/route.test.ts | 46 +++++++++------- apps/tradinggoose/app/api/workflows/route.ts | 16 ++++-- .../copilot/tools/server/entities/workflow.ts | 1 - .../lib/workflows/db-helpers.test.ts | 30 +++++++--- apps/tradinggoose/lib/workflows/db-helpers.ts | 55 +++++++++---------- .../lib/workflows/studio-workflow-mermaid.ts | 2 +- apps/tradinggoose/lib/yjs/workflow-session.ts | 2 +- 10 files changed, 137 insertions(+), 112 deletions(-) diff --git a/apps/tradinggoose/app/api/templates/[id]/use/route.ts b/apps/tradinggoose/app/api/templates/[id]/use/route.ts index d06afd2fa..c8f635eb5 100644 --- a/apps/tradinggoose/app/api/templates/[id]/use/route.ts +++ b/apps/tradinggoose/app/api/templates/[id]/use/route.ts @@ -6,12 +6,11 @@ import { v4 as uuidv4 } from 'uuid' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { - regenerateWorkflowStateIds, - saveWorkflowToNormalizedTables, -} from '@/lib/workflows/db-helpers' +import { regenerateWorkflowStateIds } from '@/lib/workflows/db-helpers' import { remapVariableIds } from '@/lib/workflows/import-export' import { normalizeVariables } from '@/lib/workflows/variable-utils' +import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' const logger = createLogger('TemplateUseAPI') @@ -82,29 +81,30 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const templateVariables = normalizeVariables(templateState?.variables) const remappedVariables = remapVariableIds(templateVariables, newWorkflowId) + const workflowName = `${templateData.name} (copy)` await db.insert(workflow).values({ id: newWorkflowId, workspaceId: workspaceId, - name: `${templateData.name} (copy)`, + name: workflowName, description: templateData.description, color: templateData.color, userId: session.user.id, - variables: remappedVariables, createdAt: now, updatedAt: now, lastSynced: now, }) const regeneratedState = regenerateWorkflowStateIds(templateState) - const { variables: _templateVars, ...stateWithoutTemplateVars } = regeneratedState as any - const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, { - ...stateWithoutTemplateVars, - lastSaved: now.toISOString(), - }) - - if (!saveResult.success) { - logger.error(`[${requestId}] Failed to save workflow state for template use`) + try { + await applyWorkflowState( + newWorkflowId, + createWorkflowSnapshot(regeneratedState), + remappedVariables, + { name: workflowName, description: templateData.description } + ) + } catch (error) { + logger.error(`[${requestId}] Failed to save workflow state for template use`, error) await db.delete(workflow).where(eq(workflow.id, newWorkflowId)) return NextResponse.json( { error: 'Failed to create workflow from template' }, diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts index 93a03f213..9f79c7af1 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('Workflow Duplicate API Route', () => { let loadWorkflowStateMock: ReturnType let regenerateWorkflowStateIdsMock: ReturnType - let saveWorkflowToNormalizedTablesMock: ReturnType + let applyWorkflowStateMock: ReturnType let insertValuesMock: ReturnType let deleteWhereMock: ReturnType @@ -43,10 +43,7 @@ describe('Workflow Duplicate API Route', () => { loadWorkflowStateMock = vi.fn() regenerateWorkflowStateIdsMock = vi.fn((state) => JSON.parse(JSON.stringify(state))) - saveWorkflowToNormalizedTablesMock = vi.fn(async (_workflowId, state) => ({ - success: true, - normalizedState: state, - })) + applyWorkflowStateMock = vi.fn().mockResolvedValue(undefined) insertValuesMock = vi.fn().mockResolvedValue(undefined) deleteWhereMock = vi.fn().mockResolvedValue(undefined) @@ -113,9 +110,12 @@ describe('Workflow Duplicate API Route', () => { isWorkflowRealtimeRequiredError: vi.fn(() => false), requireWorkflowRealtimeState: loadWorkflowStateMock, regenerateWorkflowStateIds: regenerateWorkflowStateIdsMock, - saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', })) + + vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ + applyWorkflowState: applyWorkflowStateMock, + })) }) afterEach(() => { @@ -160,11 +160,12 @@ describe('Workflow Duplicate API Route', () => { expect(response.status).toBe(201) expect(insertValuesMock).toHaveBeenCalledOnce() - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() const insertedWorkflow = insertValuesMock.mock.calls[0][0] - const persistedWorkflowId = saveWorkflowToNormalizedTablesMock.mock.calls[0][0] - const persistedState = saveWorkflowToNormalizedTablesMock.mock.calls[0][1] + const persistedWorkflowId = applyWorkflowStateMock.mock.calls[0][0] + const persistedState = applyWorkflowStateMock.mock.calls[0][1] + const persistedVariables = applyWorkflowStateMock.mock.calls[0][2] expect(insertedWorkflow.id).toBe(persistedWorkflowId) expect(persistedState.blocks).toEqual( @@ -174,18 +175,18 @@ describe('Workflow Duplicate API Route', () => { }), }) ) - expect(Object.keys(insertedWorkflow.variables)).toHaveLength(1) - expect(Object.values(insertedWorkflow.variables)).toEqual([ + expect(Object.keys(persistedVariables)).toHaveLength(1) + expect(Object.values(persistedVariables)).toEqual([ expect.objectContaining({ name: 'liveVar', value: 'live value', workflowId: persistedWorkflowId, }), ]) - expect((Object.values(insertedWorkflow.variables)[0] as { id: string }).id).not.toBe('live-var') + expect((Object.values(persistedVariables)[0] as { id: string }).id).not.toBe('live-var') }) - it('rolls back the duplicate when normalized state persistence fails', async () => { + it('rolls back the duplicate when Yjs state materialization fails', async () => { loadWorkflowStateMock.mockResolvedValue({ blocks: {}, edges: [], @@ -194,10 +195,7 @@ describe('Workflow Duplicate API Route', () => { variables: {}, lastSaved: Date.now(), }) - saveWorkflowToNormalizedTablesMock.mockResolvedValueOnce({ - success: false, - error: 'normalized state unavailable', - }) + applyWorkflowStateMock.mockRejectedValueOnce(new Error('realtime unavailable')) const { POST } = await import('@/app/api/workflows/[id]/duplicate/route') const response = await POST( @@ -208,7 +206,7 @@ describe('Workflow Duplicate API Route', () => { ) expect(response.status).toBe(500) - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() expect(deleteWhereMock).toHaveBeenCalledOnce() }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts index 92dc27e0a..5194a0903 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -11,10 +11,11 @@ import { generateRequestId } from '@/lib/utils' import { regenerateWorkflowStateIds, requireWorkflowRealtimeState, - saveWorkflowToNormalizedTables, } from '@/lib/workflows/db-helpers' import { remapVariableIds } from '@/lib/workflows/import-export' import { normalizeVariables } from '@/lib/workflows/variable-utils' +import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' import type { Variable } from '@/stores/variables/types' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -111,6 +112,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const duplicatedWorkflowState = regenerateWorkflowStateIds(sourceArtifacts.workflowState) const duplicatedVariables = remapVariableIds(sourceArtifacts.variables, newWorkflowId) + const resolvedDescription = description || source.description await db.insert(workflow).values({ id: newWorkflowId, @@ -118,7 +120,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: workspaceId, folderId: folderId || null, name, - description: description || source.description, + description: resolvedDescription, color: resolvedColor, lastSynced: now, createdAt: now, @@ -126,26 +128,27 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: isDeployed: false, collaborators: [], runCount: 0, - variables: duplicatedVariables, isPublished: false, marketplaceData: null, }) - const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, { - ...duplicatedWorkflowState, - lastSaved: now.getTime(), - }) - if (!saveResult.success) { + try { + await applyWorkflowState( + newWorkflowId, + createWorkflowSnapshot(duplicatedWorkflowState), + duplicatedVariables, + { name, description: resolvedDescription, folderId: folderId || null } + ) + } catch (error) { await db.delete(workflow).where(eq(workflow.id, newWorkflowId)) - throw new Error(saveResult.error || 'Failed to persist duplicated workflow state') + throw error } - const persistedDuplicatedState = saveResult.normalizedState ?? duplicatedWorkflowState logger.info(`[${requestId}] Duplicated editable workflow state from Yjs`, { sourceWorkflowId, newWorkflowId, - blocksCount: Object.keys(persistedDuplicatedState.blocks || {}).length, - edgesCount: persistedDuplicatedState.edges?.length || 0, + blocksCount: Object.keys(duplicatedWorkflowState.blocks || {}).length, + edgesCount: duplicatedWorkflowState.edges?.length || 0, variablesCount: Object.keys(duplicatedVariables).length, }) @@ -162,11 +165,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: color: resolvedColor, workspaceId, folderId: folderId || null, - blocksCount: Object.keys(persistedDuplicatedState.blocks || {}).length, - edgesCount: persistedDuplicatedState.edges?.length || 0, + blocksCount: Object.keys(duplicatedWorkflowState.blocks || {}).length, + edgesCount: duplicatedWorkflowState.edges?.length || 0, subflowsCount: - Object.keys(persistedDuplicatedState.loops || {}).length + - Object.keys(persistedDuplicatedState.parallels || {}).length, + Object.keys(duplicatedWorkflowState.loops || {}).length + + Object.keys(duplicatedWorkflowState.parallels || {}).length, }, { status: 201 } ) diff --git a/apps/tradinggoose/app/api/workflows/route.test.ts b/apps/tradinggoose/app/api/workflows/route.test.ts index 4b12669a5..0398452a7 100644 --- a/apps/tradinggoose/app/api/workflows/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/route.test.ts @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('Workflow API Route', () => { const insertValuesMock = vi.fn() const deleteWhereMock = vi.fn() - const saveWorkflowToNormalizedTablesMock = vi.fn() + const applyWorkflowStateMock = vi.fn() const randomUUIDMock = vi.fn() const createRequest = (body: Record) => @@ -25,10 +25,7 @@ describe('Workflow API Route', () => { insertValuesMock.mockResolvedValue(undefined) deleteWhereMock.mockResolvedValue(undefined) - saveWorkflowToNormalizedTablesMock.mockImplementation(async (_workflowId, state) => ({ - success: true, - normalizedState: state, - })) + applyWorkflowStateMock.mockResolvedValue(undefined) randomUUIDMock.mockReset() randomUUIDMock.mockReturnValueOnce('workflow-123').mockReturnValueOnce('variable-123') vi.stubGlobal('crypto', { @@ -87,8 +84,8 @@ describe('Workflow API Route', () => { generateRequestId: vi.fn(() => 'request-id'), })) - vi.doMock('@/lib/workflows/db-helpers', () => ({ - saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, + vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ + applyWorkflowState: applyWorkflowStateMock, })) vi.doMock('@/lib/telemetry/tracer', () => ({ @@ -100,7 +97,7 @@ describe('Workflow API Route', () => { vi.unstubAllGlobals() }) - it('persists initial workflow state to normalized tables', async () => { + it('applies initial workflow state through Yjs materialization', async () => { const initialWorkflowState = { blocks: { 'block-1': { @@ -139,11 +136,13 @@ describe('Workflow API Route', () => { expect(response.status).toBe(200) expect(insertValuesMock).toHaveBeenCalledOnce() - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() const insertedWorkflow = insertValuesMock.mock.calls[0][0] + const persistedState = applyWorkflowStateMock.mock.calls[0][1] + const persistedVariables = applyWorkflowStateMock.mock.calls[0][2] - const insertedVariableValues = Object.values(insertedWorkflow.variables as Record) + const insertedVariableValues = Object.values(persistedVariables as Record) expect(insertedVariableValues).toHaveLength(1) expect(insertedVariableValues[0]).toEqual({ id: 'variable-123', @@ -152,23 +151,25 @@ describe('Workflow API Route', () => { type: 'plain', value: 'secret', }) - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledWith( + expect(applyWorkflowStateMock).toHaveBeenCalledWith( insertedWorkflow.id, expect.objectContaining({ blocks: initialWorkflowState.blocks, edges: initialWorkflowState.edges, loops: initialWorkflowState.loops, parallels: initialWorkflowState.parallels, - lastSaved: expect.any(Number), + }), + persistedVariables, + expect.objectContaining({ + name: 'Workflow Copy', + description: 'Created from seed', + folderId: null, }) ) }) it('rolls back the workflow row when initial state persistence fails', async () => { - saveWorkflowToNormalizedTablesMock.mockResolvedValueOnce({ - success: false, - error: 'normalized state unavailable', - }) + applyWorkflowStateMock.mockRejectedValueOnce(new Error('realtime unavailable')) const { POST } = await import('@/app/api/workflows/route') const response = await POST( @@ -186,7 +187,7 @@ describe('Workflow API Route', () => { ) expect(response.status).toBe(500) - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() expect(deleteWhereMock).toHaveBeenCalledOnce() }) @@ -200,18 +201,21 @@ describe('Workflow API Route', () => { ) expect(response.status).toBe(200) - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() const insertedWorkflow = insertValuesMock.mock.calls[0][0] - expect(insertedWorkflow.variables).toEqual({}) - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledWith( + const persistedVariables = applyWorkflowStateMock.mock.calls[0][2] + expect(persistedVariables).toEqual({}) + expect(applyWorkflowStateMock).toHaveBeenCalledWith( insertedWorkflow.id, expect.objectContaining({ blocks: {}, edges: [], loops: {}, parallels: {}, - }) + }), + {}, + expect.any(Object) ) }) diff --git a/apps/tradinggoose/app/api/workflows/route.ts b/apps/tradinggoose/app/api/workflows/route.ts index 3ec4f7ab1..8f1694cbd 100644 --- a/apps/tradinggoose/app/api/workflows/route.ts +++ b/apps/tradinggoose/app/api/workflows/route.ts @@ -8,10 +8,11 @@ import { getStableVibrantColor } from '@/lib/colors' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { remapVariableIds } from '@/lib/workflows/import-export' import { normalizeVariables } from '@/lib/workflows/variable-utils' +import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowAPI') @@ -188,15 +189,20 @@ export async function POST(req: NextRequest) { isDeployed: false, collaborators: [], runCount: 0, - variables: remappedVariables, isPublished: false, marketplaceData: null, }) - const saveResult = await saveWorkflowToNormalizedTables(workflowId, initialState.canonicalState) - if (!saveResult.success) { + try { + await applyWorkflowState( + workflowId, + createWorkflowSnapshot(initialState.canonicalState), + remappedVariables, + { name, description, folderId: folderId || null } + ) + } catch (error) { await db.delete(workflow).where(eq(workflow.id, workflowId)) - throw new Error(saveResult.error || 'Failed to persist initial workflow state') + throw error } logger.info(`[${requestId}] Successfully created workflow ${workflowId}`) diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index 2e95de727..608f40107 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -528,7 +528,6 @@ export const createWorkflowServerTool: BaseServerTool< isDeployed: false, collaborators: [], runCount: 0, - variables: {}, isPublished: false, marketplaceData: null, }) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index c010578ad..57d77cfc6 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -554,19 +554,35 @@ describe('Database Helpers', () => { }) it('should successfully save workflow data to normalized tables', async () => { - const mockTransaction = vi - .fn() - .mockImplementation(async (callback) => callback(createMockTx())) + const variables = { + 'var-1': { id: 'var-1', workflowId: mockWorkflowId, name: 'risk', value: '1' }, + } + let capturedWorkflowUpdate: Record | undefined + const mockTransaction = vi.fn().mockImplementation(async (callback) => + callback( + createMockTx({ + update: vi.fn((table) => ({ + set: vi.fn((data: Record) => ({ + where: vi.fn().mockImplementation(async () => { + if (table === mockWorkflowTable) capturedWorkflowUpdate = data + return [] + }), + })), + })), + }) + ) + ) mockDb.transaction = mockTransaction - const result = await dbHelpers.saveWorkflowToNormalizedTables( - mockWorkflowId, - mockWorkflowState - ) + const result = await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, { + ...mockWorkflowState, + variables, + }) expect(result.success).toBe(true) expect(result.normalizedState).toEqual(mockWorkflowState) + expect(capturedWorkflowUpdate).toEqual(expect.objectContaining({ variables })) // Verify transaction was called expect(mockTransaction).toHaveBeenCalledTimes(1) diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index bea088b8b..ccb8769a3 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -28,11 +28,12 @@ import { SUBFLOW_TYPES } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowDBHelpers') -type WorkflowDbTransaction = Parameters[0]>[0] -type WorkflowNormalizedCommit = ( - tx: WorkflowDbTransaction, - normalizedState: WorkflowState -) => Promise +type PersistableWorkflowState = WorkflowState & { + name?: string + description?: string | null + folderId?: string | null + variables?: Record +} const resolveLockedFromBlockData = (data: unknown): boolean => { if (!data || typeof data !== 'object' || Array.isArray(data)) { @@ -694,14 +695,15 @@ export async function loadWorkflowFromNormalizedTables( */ export async function saveWorkflowToNormalizedTables( workflowId: string, - state: WorkflowState, - commit?: WorkflowNormalizedCommit + state: PersistableWorkflowState ): Promise<{ success: boolean; error?: string; normalizedState?: WorkflowState }> { try { - const stateWithUniqueBlockIds = await ensureUniqueBlockIds(workflowId, state) + const { name, description, folderId, variables, ...graphState } = state + const stateWithUniqueBlockIds = await ensureUniqueBlockIds(workflowId, graphState) const stateWithUniqueEdgeIds = await ensureUniqueEdgeIds(workflowId, stateWithUniqueBlockIds) const { blocks } = sanitizeAgentToolsInBlocks(stateWithUniqueEdgeIds.blocks || {}) const normalizedState = { ...stateWithUniqueEdgeIds, blocks } + const savedAt = new Date(state.lastSaved ?? Date.now()) const sanitizeNumberForDecimal = (value: unknown): string => { if (typeof value !== 'number' || !Number.isFinite(value)) { @@ -854,7 +856,17 @@ export async function saveWorkflowToNormalizedTables( await tx.insert(workflowSubflows).values(subflowInserts) } - await commit?.(tx, normalizedState) + await tx + .update(workflow) + .set({ + lastSynced: savedAt, + updatedAt: savedAt, + ...(name !== undefined ? { name } : {}), + ...(description !== undefined ? { description } : {}), + ...(folderId !== undefined ? { folderId } : {}), + ...(variables !== undefined ? { variables } : {}), + }) + .where(eq(workflow.id, workflowId)) }) return { success: true, normalizedState } @@ -878,33 +890,20 @@ export async function saveWorkflowToNormalizedTables( export async function saveWorkflowYjsDocToDb(workflowId: string, doc: Y.Doc): Promise { const state = extractPersistedStateFromDoc(doc) const syncedAt = new Date() - const workflowState: WorkflowState = { + const workflowState: PersistableWorkflowState = { ...(state.direction !== undefined ? { direction: state.direction } : {}), blocks: state.blocks, edges: state.edges, loops: state.loops, parallels: state.parallels, + ...(state.name != null ? { name: state.name } : {}), + ...(state.description !== undefined ? { description: state.description } : {}), + ...(state.folderId !== undefined ? { folderId: state.folderId } : {}), + variables: state.variables, lastSaved: syncedAt.toISOString(), } - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState, async (tx) => { - const [updatedWorkflow] = await tx - .update(workflow) - .set({ - lastSynced: syncedAt, - updatedAt: syncedAt, - ...(state.name !== undefined ? { name: state.name } : {}), - ...(state.description !== undefined ? { description: state.description } : {}), - ...(state.folderId !== undefined ? { folderId: state.folderId } : {}), - variables: state.variables, - }) - .where(eq(workflow.id, workflowId)) - .returning({ id: workflow.id }) - - if (!updatedWorkflow) { - throw new Error('Workflow not found') - } - }) + const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState) if (!saveResult.success) { throw new Error(saveResult.error || 'Failed to materialize workflow Yjs state') diff --git a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts index a7dda46bd..ab3ebb1bb 100644 --- a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts +++ b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts @@ -1920,7 +1920,7 @@ export function serializeWorkflowToTgMermaid( toCommentLine(TG_WORKFLOW_PREFIX, { version: TG_MERMAID_DOCUMENT_FORMAT, direction, - ...(workflowState.lastSaved ? { lastSaved: workflowState.lastSaved } : {}), + ...(workflowState.lastSaved ? { lastSaved: String(workflowState.lastSaved) } : {}), } satisfies WorkflowDocumentMetadata), ] diff --git a/apps/tradinggoose/lib/yjs/workflow-session.ts b/apps/tradinggoose/lib/yjs/workflow-session.ts index dcefbafe9..7f75b3406 100644 --- a/apps/tradinggoose/lib/yjs/workflow-session.ts +++ b/apps/tradinggoose/lib/yjs/workflow-session.ts @@ -237,7 +237,7 @@ export interface WorkflowSnapshot { edges: Edge[] loops: Record parallels: Record - lastSaved?: string + lastSaved?: string | number } export type WorkflowMetadataPatch = { From 711f20873aec88013ad7aaf1a1f5b49fc2f32e23 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Mon, 29 Jun 2026 00:39:10 -0600 Subject: [PATCH 282/284] fix(workflows): persist auto-layout durably Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- .../api/workflows/[id]/autolayout/route.ts | 42 +++++++++++-------- apps/tradinggoose/app/api/workflows/route.ts | 4 ++ .../socket-server/yjs/upstream-utils.ts | 2 + .../components/control-bar/auto-layout.ts | 26 ++++-------- .../components/control-bar/control-bar.tsx | 5 +-- .../workflow-editor/workflow-canvas.tsx | 7 ++-- 6 files changed, 43 insertions(+), 43 deletions(-) diff --git a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts index 3e3873dda..a64185652 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts @@ -5,6 +5,8 @@ import { generateRequestId } from '@/lib/utils' import { applyAutoLayout } from '@/lib/workflows/autolayout' import { requireWorkflowRealtimeState } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' +import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' export const dynamic = 'force-dynamic' @@ -52,22 +54,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ userId, }) - let currentWorkflowData: { blocks: Record; edges: any[] } | null + const currentWorkflowState = await requireWorkflowRealtimeState(workflowId) + + if (!currentWorkflowState) { + logger.error(`[${requestId}] Could not load workflow ${workflowId} for autolayout`) + return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 }) + } + + const layoutInput = + layoutOptions.blocks && layoutOptions.edges + ? { blocks: layoutOptions.blocks, edges: layoutOptions.edges } + : { blocks: currentWorkflowState.blocks, edges: currentWorkflowState.edges } if (layoutOptions.blocks && layoutOptions.edges) { logger.info(`[${requestId}] Using provided blocks with live measurements`) - currentWorkflowData = { - blocks: layoutOptions.blocks, - edges: layoutOptions.edges, - } } else { logger.info(`[${requestId}] Loading blocks from current workflow state`) - currentWorkflowData = await requireWorkflowRealtimeState(workflowId) - } - - if (!currentWorkflowData) { - logger.error(`[${requestId}] Could not load workflow ${workflowId} for autolayout`) - return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 }) } const autoLayoutOptions = { @@ -80,11 +82,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ alignment: layoutOptions.alignment ?? 'center', } - const layoutResult = applyAutoLayout( - currentWorkflowData.blocks, - currentWorkflowData.edges, - autoLayoutOptions - ) + const layoutResult = applyAutoLayout(layoutInput.blocks, layoutInput.edges, autoLayoutOptions) if (!layoutResult.success || !layoutResult.blocks) { logger.error(`[${requestId}] Auto layout failed:`, { @@ -99,6 +97,17 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ) } + await applyWorkflowState( + workflowId, + createWorkflowSnapshot({ + direction: currentWorkflowState.direction, + blocks: layoutResult.blocks, + edges: layoutInput.edges, + loops: currentWorkflowState.loops, + parallels: currentWorkflowState.parallels, + }) + ) + const elapsed = Date.now() - startTime const blockCount = Object.keys(layoutResult.blocks).length @@ -113,7 +122,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ data: { blockCount, elapsed: `${elapsed}ms`, - layoutedBlocks: layoutResult.blocks, }, }) } catch (error) { diff --git a/apps/tradinggoose/app/api/workflows/route.ts b/apps/tradinggoose/app/api/workflows/route.ts index 8f1694cbd..e751fa9f8 100644 --- a/apps/tradinggoose/app/api/workflows/route.ts +++ b/apps/tradinggoose/app/api/workflows/route.ts @@ -13,6 +13,7 @@ import { remapVariableIds } from '@/lib/workflows/import-export' import { normalizeVariables } from '@/lib/workflows/variable-utils' import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowAPI') @@ -218,6 +219,9 @@ export async function POST(req: NextRequest) { updatedAt: now, }) } catch (error) { + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse + if (error instanceof z.ZodError) { logger.warn(`[${requestId}] Invalid workflow creation data`, { errors: error.errors, diff --git a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts index 8de512ade..0599cb3a9 100644 --- a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts +++ b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts @@ -133,6 +133,8 @@ function runDocumentPersistence(doc: WSSharedDoc): void { } }) .catch((error) => { + doc.hasUnsavedChanges = true + doc.needsPersist = true console.error('[yjs upstream-utils] Failed to persist live document', error) }) .finally(() => { diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts index 7660fbefc..d960a0107 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts @@ -21,7 +21,6 @@ export async function applyAutoLayoutToWorkflow( options: AutoLayoutOptions = {} ): Promise<{ success: boolean - layoutedBlocks?: Record error?: string }> { try { @@ -84,14 +83,11 @@ export async function applyAutoLayoutToWorkflow( logger.info('Successfully applied auto layout', { workflowId, originalBlockCount: Object.keys(blocks).length, - layoutedBlockCount: result.data?.layoutedBlocks - ? Object.keys(result.data.layoutedBlocks).length - : 0, + layoutedBlockCount: result.data?.blockCount ?? 0, }) return { success: true, - layoutedBlocks: result.data?.layoutedBlocks || blocks, } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown auto layout error' @@ -104,17 +100,17 @@ export async function applyAutoLayoutToWorkflow( } } -interface ApplyAutoLayoutAndUpdateStoreParams { +interface ApplyAutoLayoutParams { workflowId: string channelId?: string options?: AutoLayoutOptions } -export async function applyAutoLayoutAndUpdateStore({ +export async function applyAutoLayoutToActiveWorkflow({ workflowId, channelId, options = {}, -}: ApplyAutoLayoutAndUpdateStoreParams): Promise<{ +}: ApplyAutoLayoutParams): Promise<{ success: boolean error?: string }> { @@ -122,8 +118,7 @@ export async function applyAutoLayoutAndUpdateStore({ try { const { getRegisteredWorkflowSession } = await import('@/lib/yjs/workflow-session-registry') - const { readWorkflowSnapshot, readWorkflowMap } = await import('@/lib/yjs/workflow-session') - const { YJS_ORIGINS } = await import('@/lib/yjs/transaction-origins') + const { readWorkflowSnapshot } = await import('@/lib/yjs/workflow-session') const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') const registryState = useWorkflowRegistry.getState() @@ -178,18 +173,11 @@ export async function applyAutoLayoutAndUpdateStore({ const result = await applyAutoLayoutToWorkflow(resolvedWorkflowId, blocks, edges, options) - if (!result.success || !result.layoutedBlocks) { + if (!result.success) { return { success: false, error: result.error } } - const doc = session.doc - doc.transact(() => { - const wMap = readWorkflowMap(doc) - wMap.set('blocks', result.layoutedBlocks!) - wMap.set('lastSaved', Date.now()) - }, YJS_ORIGINS.USER) - - logger.info('Successfully updated Yjs doc with auto layout', { + logger.info('Successfully applied durable auto layout', { workflowId: resolvedWorkflowId, channelId, }) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx index e684d7eb0..bcd8dd20b 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx @@ -352,12 +352,11 @@ export function ControlBar({ setIsAutoLayouting(true) setAutoLayoutError(null) try { - // Use the shared auto layout utility for immediate frontend updates - const { applyAutoLayoutAndUpdateStore } = await import( + const { applyAutoLayoutToActiveWorkflow } = await import( '@/widgets/widgets/editor_workflow/components/control-bar/auto-layout' ) - const result = await applyAutoLayoutAndUpdateStore({ + const result = await applyAutoLayoutToActiveWorkflow({ workflowId: activeWorkflowId!, channelId, }) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/workflow-canvas.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/workflow-canvas.tsx index ab3c9a7ad..0443b8d88 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/workflow-canvas.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/workflow-canvas.tsx @@ -448,17 +448,16 @@ const WorkflowCanvas = React.memo( [getNodes, blocks] ) - // Auto-layout handler - now uses frontend auto layout for immediate updates + // Auto-layout handler const handleAutoLayout = useCallback(async () => { if (Object.keys(blocks).length === 0) return try { - // Use the shared auto layout utility for immediate frontend updates - const { applyAutoLayoutAndUpdateStore } = await import( + const { applyAutoLayoutToActiveWorkflow } = await import( '@/widgets/widgets/editor_workflow/components/control-bar/auto-layout' ) - const result = await applyAutoLayoutAndUpdateStore({ + const result = await applyAutoLayoutToActiveWorkflow({ workflowId: activeWorkflowId!, channelId: resolvedChannelId, }) From f33664eee503222b966a77d055b3f5f48fab2cd7 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Mon, 29 Jun 2026 00:51:15 -0600 Subject: [PATCH 283/284] test: stabilize workflow and heatmap assertions Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/workflows/route.test.ts | 4 ++++ .../widgets/heatmap/components/heatmap-treemap-chart.test.tsx | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/tradinggoose/app/api/workflows/route.test.ts b/apps/tradinggoose/app/api/workflows/route.test.ts index 0398452a7..d388e6b16 100644 --- a/apps/tradinggoose/app/api/workflows/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/route.test.ts @@ -88,6 +88,10 @@ describe('Workflow API Route', () => { applyWorkflowState: applyWorkflowStateMock, })) + vi.doMock('@/app/api/workflows/utils', () => ({ + createWorkflowRealtimeRequiredResponse: vi.fn(() => null), + })) + vi.doMock('@/lib/telemetry/tracer', () => ({ trackPlatformEvent: vi.fn(), })) diff --git a/apps/tradinggoose/widgets/widgets/heatmap/components/heatmap-treemap-chart.test.tsx b/apps/tradinggoose/widgets/widgets/heatmap/components/heatmap-treemap-chart.test.tsx index 60ec2f4a1..8e1889d6c 100644 --- a/apps/tradinggoose/widgets/widgets/heatmap/components/heatmap-treemap-chart.test.tsx +++ b/apps/tradinggoose/widgets/widgets/heatmap/components/heatmap-treemap-chart.test.tsx @@ -323,7 +323,7 @@ describe('HeatmapTreemapChart', () => { const button = container.querySelector('button') const icon = container.querySelector('img') - expect(button?.textContent?.trim()).toBe('') + await vi.waitFor(() => expect(button?.textContent?.trim()).toBe('')) expect(icon?.style.width).toBe('16px') expect(icon?.style.height).toBe('16px') }) From dccab734dd3f1266dceaac105ee2215b9090d855 Mon Sep 17 00:00:00 2001 From: BWJ2310-backup Date: Mon, 29 Jun 2026 01:03:59 -0600 Subject: [PATCH 284/284] feat(copilot): surface entity enabled state and tighten rename validation Update shared entity list contracts to carry enabled state and make rename payloads strict. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --- apps/tradinggoose/app/api/mcp/servers/schema.ts | 3 +-- apps/tradinggoose/lib/copilot/registry.ts | 3 ++- .../lib/copilot/tools/server/entities/shared.ts | 9 +++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/tradinggoose/app/api/mcp/servers/schema.ts b/apps/tradinggoose/app/api/mcp/servers/schema.ts index 52e5fd81c..f877c3ae4 100644 --- a/apps/tradinggoose/app/api/mcp/servers/schema.ts +++ b/apps/tradinggoose/app/api/mcp/servers/schema.ts @@ -24,5 +24,4 @@ export const CreateMcpServerSchema = McpServerBaseSchema.refine( export const RenameMcpServerSchema = z.object({ name: z.string().trim().min(1), - workspaceId: z.string().optional(), -}) +}).strict() diff --git a/apps/tradinggoose/lib/copilot/registry.ts b/apps/tradinggoose/lib/copilot/registry.ts index a58c5c5d2..05ec807b5 100644 --- a/apps/tradinggoose/lib/copilot/registry.ts +++ b/apps/tradinggoose/lib/copilot/registry.ts @@ -712,10 +712,11 @@ const WorkflowVariableDocumentEnvelope = WorkflowTargetEnvelope.extend({ variables: z.record(z.any()), }) -// A list is a discovery surface: only id + canonical name, no per-entity details. +// A list is a discovery surface: id, canonical name, and basic usability state. const GenericEntityListEntry = z.object({ entityId: z.string(), entityName: z.string().optional(), + enabled: z.boolean().optional(), }) const GenericEntityListResult = z.object({ diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts index dae330128..801a70615 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -35,12 +35,13 @@ export type EntityDocumentArgs = { } /** - * Canonical list_* entry. A list is a discovery surface — "what exists" — so it - * carries only the entity's id and canonical name, never per-entity details. + * Canonical list_* entry. A list is a discovery surface — "what exists" — plus + * the minimum state needed to know whether a listed item is usable. */ export type EntityListEntry = { entityId: string entityName: string + enabled?: boolean } export type CopilotIndicatorListEntry = { @@ -198,8 +199,8 @@ export async function readSavedEntityDocumentFields( /** * Canonical read for every saved-entity list_* tool: the workspace's membership - * through the live Yjs list session (id + canonical name only). Reflects realtime - * create/delete by any user — one read for all kinds, no per-tool list mapper. + * through the live Yjs list session. Reflects realtime create/delete/rename and + * basic usability state by any user — one read for all kinds, no per-tool mapper. */ export function buildSavedEntityListInfo( entityKind: SavedEntityKind,