From 8bfe6750fa277ee4c32241abb771551d0692395f Mon Sep 17 00:00:00 2001 From: Bruzzz BackUp <149516937+BWJ2310-backup@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:35:04 -0600 Subject: [PATCH 01/18] fix(copilot): consolidate completion billing and graph-only workflow edits (#141) * fix(copilot): update context usage handling and remove unnecessary billing logic * feat(copilot): record local completion usage Add local completion usage flushing for chat and mark-complete flows, with retryable reservation locks for the local billing path.\n\nCo-authored-by: Codex * feat(copilot): switch workflow edits to graph-only mermaid Rewrite edit_workflow to accept minimal Mermaid graphs, add removedBlockIds for intentional deletions, and update the matching manifest and error mapping.\n\nCo-authored-by: Codex * fix(copilot): refactor tool approval logic and update related tests * fix(copilot): remove local completion billing logic and update context usage handling * fix(copilot): remove color field from indicator and workflow schemas, update related documentation * fix(copilot): update copilot usage handling and improve test coverage for malformed completion commits * fix(copilot): introduce workflow graph document format and update related handling in tools and tests * fix(copilot): enhance edit_workflow tool with semantic validators and improve error handling for workflow graphs * fix(copilot): update editWorkflowServerTool tests to reflect changes in block naming and metadata handling * fix(copilot): enhance editWorkflowServerTool with block position preservation and validation for internal fields in graph-only edits * fix(copilot): update inline tool call tests and logic for review controls and entity diffs; enhance workflow graph parsing with condition edge validation * fix(copilot): improve subgraph node handling in parseVisibleWorkflowEdges; enhance error reporting for unknown edge references * fix(copilot): remove unused color properties from entity types and update related tests; enhance workflow edge parsing logic * fix(copilot): update edit_workflow description for clarity on block deletion; adjust error message for block type validation; modify default horizontal handle behavior in preview node * fix(copilot): remove color property from indicators and workflows; update related logic and tests for consistency * fix(copilot): remove color property from indicators and workflows; update related logic and tests for consistency * feat(import-export): bump TradingGoose export envelope to v2 Update the unified export envelope version and align import/export tests and types with the new version.\n\nCo-authored-by: Codex * feat(copilot): clarify workflow edit topology semantics Tighten edit_workflow guidance and errors so block ids are treated as stable identities, and preserve overlay metadata when parsing graph-only workflow Mermaid.\n\nCo-authored-by: Codex * fix(copilot): update workflow document formats and descriptions; adjust tests for consistency * fix(copilot): enhance workflow validation and update document format references * fix(import-export): update export version from 2 to 1 across various modules * fix(export): update export version from 2 to 1 in various components * fix(copilot): enhance workflow documentation and validation; update tests for consistency * fix(copilot): implement billing completion usage reporting and update related tests * fix(copilot): enhance completion usage reporting with validation for invalid reports and billing failures * fix(import-export): update tests to ignore generated fields in transfer records and remove strict validation * fix(copilot): refine workflow graph handling and improve Mermaid documentation clarity * fix(copilot): improve error handling in completion usage reporting and update related tests * fix(copilot): rename commitLocalCopilotCompletionUsageReports to mirrorLocalCopilotCompletionUsageReports and update related usages and tests * fix(copilot): consolidate usage reservation commits Replace the deprecated adjust path with a commit wrapper that reserves and releases Copilot usage around billing operations, and pass workspace context through the store. Co-authored-by: Codex * fix(workflows): allow condition source handles Co-authored-by: Codex Co-authored-by: BWJ2310 * docs(changelog): add June 11 2026 branch summary Co-authored-by: Codex Co-authored-by: BWJ2310 * refactor(copilot): extract completion billing helpers Move completion billing and mirror logic into a shared lib module used by copilot routes.\n\nCo-authored-by: Codex * fix(copilot): forward workspace id in context usage Pass workspaceId through the copilot usage context flow so workspace-bounded requests keep their billing context.\n\nCo-authored-by: Codex --------- Co-authored-by: Codex Co-authored-by: BWJ2310 --- .../app/api/copilot/chat/route.ts | 28 + .../api/copilot/tools/mark-complete/route.ts | 29 +- .../app/api/copilot/usage/route.test.ts | 633 +++++++---------- .../app/api/copilot/usage/route.ts | 643 ++---------------- .../indicators/custom/import/route.test.ts | 2 - .../app/api/indicators/custom/route.ts | 1 - .../app/api/workflows/[id]/duplicate/route.ts | 8 +- .../app/api/workflows/[id]/route.test.ts | 22 + .../app/api/workflows/[id]/route.ts | 16 +- apps/tradinggoose/app/api/workflows/route.ts | 8 +- .../[workspaceId]/templates/[id]/template.tsx | 1 - apps/tradinggoose/hooks/queries/indicators.ts | 15 +- apps/tradinggoose/hooks/queries/workflows.ts | 6 +- .../tradinggoose/lib/copilot/access-policy.ts | 2 +- .../lib/copilot/agent/utils.test.ts | 3 + apps/tradinggoose/lib/copilot/agent/utils.ts | 5 + .../lib/copilot/completion-usage-billing.ts | 363 ++++++++++ .../lib/copilot/entity-documents.ts | 2 - .../lib/copilot/inline-tool-call.test.tsx | 4 +- apps/tradinggoose/lib/copilot/registry.ts | 35 +- .../runtime-tool-manifest-enrichment.ts | 139 ---- .../lib/copilot/runtime-tool-manifest.test.ts | 66 +- .../lib/copilot/runtime-tool-manifest.ts | 27 +- .../lib/copilot/server-tool-errors.test.ts | 60 +- .../lib/copilot/server-tool-errors.ts | 87 +-- .../lib/copilot/tool-prompt-metadata.ts | 4 +- .../entities/entity-document-tool-utils.ts | 10 - .../client/entities/entity-tools.test.ts | 4 - .../tools/client/workflow/create-workflow.ts | 4 +- .../client/workflow/edit-workflow.test.ts | 42 +- .../tools/client/workflow/edit-workflow.ts | 5 +- .../workflow/workflow-review-tool-utils.ts | 3 +- .../lib/copilot/tools/server/router.test.ts | 14 +- .../server/workflow/edit-workflow.test.ts | 463 +++++++------ .../tools/server/workflow/edit-workflow.ts | 250 ++++++- .../workflow/workflow-mutation-utils.ts | 13 +- .../lib/copilot/usage-reservations.ts | 183 ++--- .../lib/indicators/custom/operations.ts | 24 +- .../generated/copilot-indicator-reference.ts | 36 +- .../lib/indicators/import-export.test.ts | 11 +- .../lib/indicators/import-export.ts | 14 +- apps/tradinggoose/lib/watchlists/types.ts | 12 +- .../lib/workflows/document-format.ts | 1 + .../lib/workflows/import-export.test.ts | 19 +- .../lib/workflows/import-export.ts | 8 - .../tradinggoose/lib/workflows/import.test.ts | 6 - apps/tradinggoose/lib/workflows/import.ts | 2 - .../workflows/studio-workflow-mermaid.test.ts | 73 ++ .../lib/workflows/studio-workflow-mermaid.ts | 446 ++++++++++-- .../lib/workflows/subblock-values.ts | 26 + .../lib/workflows/workflow-direction.ts | 6 +- apps/tradinggoose/lib/yjs/use-workflow-doc.ts | 22 +- .../tradinggoose/stores/copilot/store.test.ts | 8 +- apps/tradinggoose/stores/copilot/store.ts | 36 +- apps/tradinggoose/stores/copilot/types.ts | 2 +- .../stores/workflows/json/importer.test.ts | 6 - .../stores/workflows/json/importer.ts | 1 - .../stores/workflows/json/store.ts | 1 - .../stores/workflows/registry/store.ts | 11 +- .../stores/workflows/registry/types.ts | 6 +- .../stores/workflows/workflow/store.ts | 14 +- .../widgets/editor_indicator/index.test.tsx | 1 - .../workflow-editor/preview/preview-node.tsx | 2 +- .../indicator-list/indicator-list.tsx | 1 - .../widgets/list_indicator/index.test.tsx | 1 - changelog/June-11-2026.md | 79 +++ .../indicators/generate-copilot-reference.ts | 5 - 67 files changed, 2142 insertions(+), 1938 deletions(-) create mode 100644 apps/tradinggoose/lib/copilot/completion-usage-billing.ts create mode 100644 changelog/June-11-2026.md diff --git a/apps/tradinggoose/app/api/copilot/chat/route.ts b/apps/tradinggoose/app/api/copilot/chat/route.ts index 266c1726d..ae9472678 100644 --- a/apps/tradinggoose/app/api/copilot/chat/route.ts +++ b/apps/tradinggoose/app/api/copilot/chat/route.ts @@ -17,6 +17,7 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/auth' +import { mirrorLocalCopilotCompletionUsageReports } from '@/lib/copilot/completion-usage-billing' import { normalizeFunctionCallArguments } from '@/lib/copilot/function-call-args' import { mapSessionToApiResponse, @@ -264,6 +265,7 @@ async function persistChatMessages( function generateAndPersistTitle(params: { reviewSessionId: string message: string + userId: string model: string provider?: ProviderId requestId: string @@ -271,6 +273,7 @@ function generateAndPersistTitle(params: { }): void { requestCopilotTitle({ message: params.message, + userId: params.userId, model: params.model, provider: params.provider, }) @@ -942,6 +945,10 @@ export async function POST(req: NextRequest) { enqueueTurnState('in_progress', 'streaming') const forwardClientEvent = (event: Record) => { + if (event.type === 'billing.completion_usage') { + return + } + if (event.type === 'awaiting_tools') { latestTurnStatus = 'in_progress' enqueueTurnState('in_progress', 'waiting_for_tools') @@ -990,6 +997,12 @@ export async function POST(req: NextRequest) { } const event = JSON.parse(jsonStr) + if (event.type === 'billing.completion_usage') { + await mirrorLocalCopilotCompletionUsageReports({ + userId: authenticatedUserId, + reports: [event.report], + }) + } switch (event.type) { case 'tool_result': @@ -1023,6 +1036,7 @@ export async function POST(req: NextRequest) { generateAndPersistTitle({ reviewSessionId: actualReviewSessionId!, message, + userId: authenticatedUserId, model, provider: runtimeProvider, requestId: tracker.requestId, @@ -1127,6 +1141,12 @@ export async function POST(req: NextRequest) { try { const jsonStr = buffer.slice(6) const event = JSON.parse(jsonStr) + if (event.type === 'billing.completion_usage') { + await mirrorLocalCopilotCompletionUsageReports({ + userId: authenticatedUserId, + reports: [event.report], + }) + } if (event.type === 'tool_result') { streamCapture.captureToolResult(event as Record) } @@ -1304,6 +1324,13 @@ export async function POST(req: NextRequest) { } }) : undefined + await mirrorLocalCopilotCompletionUsageReports({ + userId: authenticatedUserId, + reports: Array.isArray(responseData.completionUsageReports) + ? responseData.completionUsageReports + : [], + }) + responseData.completionUsageReports = undefined if (currentSession && (responseData.content || contentBlocks?.length)) { await persistChatMessages({ @@ -1324,6 +1351,7 @@ export async function POST(req: NextRequest) { generateAndPersistTitle({ reviewSessionId: actualReviewSessionId, message, + userId: authenticatedUserId, model: providerConfig?.model ?? model, provider: providerConfig?.provider, requestId: tracker.requestId, 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 3ff6e0fe7..cb7a7741a 100644 --- a/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.ts +++ b/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.ts @@ -7,6 +7,7 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/auth' +import { mirrorLocalCopilotCompletionUsageReports } from '@/lib/copilot/completion-usage-billing' import { createLogger } from '@/lib/logs/console/logger' import { encodeSSE, SSE_HEADERS } from '@/lib/utils' import { getCopilotApiUrl, proxyCopilotRequest } from '@/app/api/copilot/proxy' @@ -22,7 +23,11 @@ const MarkCompleteSchema = z.object({ data: z.any().optional(), }) -function createTurnStateStream(body: ReadableStream, abortUpstream: () => void) { +function createTurnStateStream( + body: ReadableStream, + abortUpstream: () => void, + userId: string +) { let reader: ReadableStreamDefaultReader | null = null return new ReadableStream({ @@ -44,7 +49,15 @@ function createTurnStateStream(body: ReadableStream, abortUpstream: ) } - const forwardEvent = (event: Record) => { + const forwardEvent = async (event: Record) => { + if (event.type === 'billing.completion_usage') { + await mirrorLocalCopilotCompletionUsageReports({ + userId, + reports: [event.report], + }) + return + } + if (event.type === 'awaiting_tools') { enqueueTurnState('in_progress', 'waiting_for_tools') } else if (event.type === 'response.completed') { @@ -80,7 +93,7 @@ function createTurnStateStream(body: ReadableStream, abortUpstream: } const event = JSON.parse(payload) as Record - forwardEvent(event) + await forwardEvent(event) } } @@ -92,7 +105,7 @@ function createTurnStateStream(body: ReadableStream, abortUpstream: } const event = JSON.parse(payload) as Record - forwardEvent(event) + await forwardEvent(event) } } catch (error) { controller.error(error) @@ -182,7 +195,7 @@ export async function POST(req: NextRequest) { toolCallId: parsed.id, toolName: parsed.name, }) - return new NextResponse(createTurnStateStream(agentRes.body, abortUpstream), { + return new NextResponse(createTurnStateStream(agentRes.body, abortUpstream, userId), { status: agentRes.status, headers: { ...SSE_HEADERS, @@ -211,6 +224,12 @@ export async function POST(req: NextRequest) { }) if (agentRes.ok) { + await mirrorLocalCopilotCompletionUsageReports({ + userId, + reports: Array.isArray(agentJson?.completionUsageReports) + ? agentJson.completionUsageReports + : [], + }) return NextResponse.json({ success: true }) } diff --git a/apps/tradinggoose/app/api/copilot/usage/route.test.ts b/apps/tradinggoose/app/api/copilot/usage/route.test.ts index 4026201b4..81b2bd188 100644 --- a/apps/tradinggoose/app/api/copilot/usage/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/usage/route.test.ts @@ -7,7 +7,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' describe('Copilot Usage API - Context', () => { const mockCheckInternalApiKey = vi.fn() - const mockIsHosted = vi.fn() const mockProxyCopilotRequest = vi.fn() const mockIsBillingEnabledForRuntime = vi.fn() const mockGetPersonalEffectiveSubscription = vi.fn() @@ -18,8 +17,9 @@ describe('Copilot Usage API - Context', () => { const mockMarkMessageAsProcessed = vi.fn() const mockCalculateCost = vi.fn() const mockReserveCopilotUsage = vi.fn() - const mockAdjustCopilotUsageReservation = vi.fn() + const mockCommitCopilotUsageReservation = vi.fn() const mockReleaseCopilotUsageReservation = vi.fn() + const mockIsHosted = vi.fn() const createTier = (copilotCostMultiplier: number) => ({ id: `tier-${copilotCostMultiplier}`, @@ -57,7 +57,6 @@ describe('Copilot Usage API - Context', () => { vi.resetModules() mockProxyCopilotRequest.mockReset() mockCheckInternalApiKey.mockReset() - mockIsHosted.mockReset() mockIsBillingEnabledForRuntime.mockReset() mockGetPersonalEffectiveSubscription.mockReset() mockGetTierCopilotCostMultiplier.mockReset() @@ -67,13 +66,16 @@ describe('Copilot Usage API - Context', () => { mockMarkMessageAsProcessed.mockReset() mockCalculateCost.mockReset() mockReserveCopilotUsage.mockReset() - mockAdjustCopilotUsageReservation.mockReset() + mockCommitCopilotUsageReservation.mockReset() mockReleaseCopilotUsageReservation.mockReset() + mockIsHosted.mockReset() mockIsBillingEnabledForRuntime.mockResolvedValue(false) + mockIsHosted.mockReturnValue(true) mockGetPersonalEffectiveSubscription.mockResolvedValue(null) mockGetTierCopilotCostMultiplier.mockImplementation( - (tier: { copilotCostMultiplier?: number } | null | undefined) => tier?.copilotCostMultiplier ?? 1 + (tier: { copilotCostMultiplier?: number } | null | undefined) => + tier?.copilotCostMultiplier ?? 1 ) mockAccrueUserUsageCost.mockResolvedValue(true) mockResolveWorkflowBillingContext.mockResolvedValue({ @@ -98,18 +100,7 @@ describe('Copilot Usage API - Context', () => { scopeType: 'user', scopeId: 'user-1', }) - mockAdjustCopilotUsageReservation.mockResolvedValue({ - allowed: true, - status: 200, - reservationId: 'reservation-1', - reservedUsd: 3, - currentUsage: 8, - limit: 10, - remaining: 0, - activeReservedUsd: 3, - scopeType: 'user', - scopeId: 'user-1', - }) + mockCommitCopilotUsageReservation.mockImplementation(async ({ operation }) => operation()) mockReleaseCopilotUsageReservation.mockResolvedValue({ released: true, reservationId: 'reservation-1', @@ -119,7 +110,6 @@ describe('Copilot Usage API - Context', () => { }) mockCheckInternalApiKey.mockReturnValue({ success: false }) - mockIsHosted.mockReturnValue(true) vi.doMock('@tradinggoose/db', () => ({ db: {}, @@ -144,10 +134,6 @@ describe('Copilot Usage API - Context', () => { checkInternalApiKey: (...args: any[]) => mockCheckInternalApiKey(...args), })) - vi.doMock('@/lib/environment', () => ({ - isHosted: mockIsHosted(), - })) - vi.doMock('@/app/api/copilot/proxy', () => ({ proxyCopilotRequest: (...args: any[]) => mockProxyCopilotRequest(...args), getCopilotApiUrl: vi.fn(() => 'https://copilot.example.test/api/get-context-usage'), @@ -198,6 +184,10 @@ describe('Copilot Usage API - Context', () => { calculateCost: (...args: any[]) => mockCalculateCost(...args), })) + vi.doMock('@/lib/environment', () => ({ + isHosted: mockIsHosted(), + })) + vi.doMock('@/lib/billing/usage-accrual', () => ({ accrueUserUsageCost: (...args: any[]) => mockAccrueUserUsageCost(...args), })) @@ -208,8 +198,7 @@ describe('Copilot Usage API - Context', () => { vi.doMock('@/lib/copilot/usage-reservations', () => ({ reserveCopilotUsage: (...args: any[]) => mockReserveCopilotUsage(...args), - adjustCopilotUsageReservation: (...args: any[]) => - mockAdjustCopilotUsageReservation(...args), + commitCopilotUsageReservation: (...args: any[]) => mockCommitCopilotUsageReservation(...args), releaseCopilotUsageReservation: (...args: any[]) => mockReleaseCopilotUsageReservation(...args), })) @@ -237,6 +226,7 @@ describe('Copilot Usage API - Context', () => { kind: 'context', conversationId: 'conversation-1', model: 'gpt-5.4', + workspaceId: 'workspace-1', }), }) @@ -263,6 +253,7 @@ describe('Copilot Usage API - Context', () => { apiKey: 'test-copilot-key', }, userId: 'user-1', + workspaceId: 'workspace-1', }, }) expect(mockGetPersonalEffectiveSubscription).not.toHaveBeenCalled() @@ -270,283 +261,85 @@ describe('Copilot Usage API - Context', () => { expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() }) - it('does not bill context usage for hosted browser-session requests even when bill is requested', async () => { - mockIsBillingEnabledForRuntime.mockResolvedValue(true) - mockIsHosted.mockReturnValue(true) - mockProxyCopilotRequest.mockResolvedValue( - new Response( - JSON.stringify({ - tokensUsed: 100, - model: 'gpt-5.4', - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - } + it.each([true, false])( + 'returns display-only context usage for hosted=%s browser sessions', + async (hosted) => { + mockIsHosted.mockReturnValue(hosted) + mockIsBillingEnabledForRuntime.mockResolvedValue(true) + mockProxyCopilotRequest.mockResolvedValue( + new Response( + JSON.stringify({ + tokensUsed: 100, + percentage: 0.1, + model: 'gpt-5.4', + contextWindow: 128000, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) ) - ) - - const request = new NextRequest('http://localhost:3000/api/copilot/usage', { - method: 'POST', - body: JSON.stringify({ - kind: 'context', - conversationId: 'conversation-browser-bill', - model: 'gpt-5.4', - bill: true, - assistantMessageId: 'assistant-message-browser', - }), - }) - - const { POST } = await import('@/app/api/copilot/usage/route') - const response = await POST(request) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - tokensUsed: 100, - model: 'gpt-5.4', - }) - expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() - expect(mockMarkMessageAsProcessed).not.toHaveBeenCalled() - }) - - it('records local context billing for self-hosted browser-session requests', async () => { - mockIsBillingEnabledForRuntime.mockResolvedValue(true) - mockIsHosted.mockReturnValue(false) - mockGetPersonalEffectiveSubscription.mockResolvedValue({ - id: 'subscription-personal', - tier: createTier(2), - }) - mockProxyCopilotRequest.mockResolvedValue( - new Response( - JSON.stringify({ - tokensUsed: 100, - model: 'gpt-5.4', - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - - const request = new NextRequest('http://localhost:3000/api/copilot/usage', { - method: 'POST', - body: JSON.stringify({ - kind: 'context', - conversationId: 'conversation-self-host-bill', - model: 'gpt-5.4', - bill: true, - assistantMessageId: 'assistant-message-self-host', - }), - }) - - const { POST } = await import('@/app/api/copilot/usage/route') - const response = await POST(request) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - tokensUsed: 100, - model: 'gpt-5.4', - billing: { - billed: true, - duplicate: false, - tokens: 100, - model: 'gpt-5.4', - cost: 3, - }, - }) - expect(mockAccrueUserUsageCost).toHaveBeenCalledWith({ - userId: 'user-1', - workflowId: undefined, - cost: 3, - extraUpdates: expect.any(Object), - reason: 'copilot_context_usage', - }) - expect(mockMarkMessageAsProcessed).toHaveBeenCalledWith( - 'copilot-billing:assistant-message-self-host', - 60 * 60 * 24 * 30 - ) - }) - - it('returns exact personal billing metadata for committed context usage', async () => { - mockIsBillingEnabledForRuntime.mockResolvedValue(true) - mockCheckInternalApiKey.mockReturnValue({ success: true }) - mockGetPersonalEffectiveSubscription.mockResolvedValue({ - id: 'subscription-personal', - tier: createTier(2), - }) - mockProxyCopilotRequest.mockResolvedValue( - new Response( - JSON.stringify({ - tokensUsed: 100, + const request = new NextRequest('http://localhost:3000/api/copilot/usage', { + method: 'POST', + body: JSON.stringify({ + kind: 'context', + conversationId: `conversation-${hosted ? 'hosted' : 'self-hosted'}`, model: 'gpt-5.4', }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - - const request = new NextRequest('http://localhost:3000/api/copilot/usage', { - method: 'POST', - body: JSON.stringify({ - action: 'commit', - kind: 'context', - conversationId: 'conversation-2', - model: 'gpt-5.4', - userId: 'user-1', - assistantMessageId: 'assistant-message-1', - reservationId: 'reservation-1', - }), - }) + }) - const { POST } = await import('@/app/api/copilot/usage/route') - const response = await POST(request) + const { POST } = await import('@/app/api/copilot/usage/route') + const response = await POST(request) - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - tokensUsed: 100, - model: 'gpt-5.4', - billing: { - billed: true, - duplicate: false, - tokens: 100, + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + tokensUsed: 100, + percentage: 0.1, model: 'gpt-5.4', - cost: 3, - }, - }) - expect(mockGetPersonalEffectiveSubscription).toHaveBeenCalledWith('user-1') - expect(mockResolveWorkflowBillingContext).not.toHaveBeenCalled() - expect(mockAccrueUserUsageCost).toHaveBeenCalledWith({ - userId: 'user-1', - workflowId: undefined, - cost: 3, - extraUpdates: expect.any(Object), - reason: 'copilot_context_usage', - }) - expect(mockMarkMessageAsProcessed).toHaveBeenCalledWith( - 'copilot-billing:assistant-message-1', - 60 * 60 * 24 * 30 - ) - expect(mockReleaseCopilotUsageReservation).toHaveBeenCalledWith({ - reservationId: 'reservation-1', - }) - }) - - it('commits workflow context usage with the workflow subscription tier', async () => { - mockIsBillingEnabledForRuntime.mockResolvedValue(true) + contextWindow: 128000, + }) + expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() + expect(mockMarkMessageAsProcessed).not.toHaveBeenCalled() + } + ) + + it('rejects context usage inspection without a browser session even with internal auth', async () => { mockCheckInternalApiKey.mockReturnValue({ success: true }) - mockProxyCopilotRequest.mockResolvedValue( - new Response( - JSON.stringify({ - tokensUsed: 100, - model: 'gpt-5.4', - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - - const request = new NextRequest('http://localhost:3000/api/copilot/usage', { - method: 'POST', - body: JSON.stringify({ - action: 'commit', - kind: 'context', - conversationId: 'conversation-3', - model: 'gpt-5.4', - userId: 'user-1', - workflowId: 'workflow-1', - assistantMessageId: 'assistant-message-2', - reservationId: 'reservation-1', - }), - }) - - const { POST } = await import('@/app/api/copilot/usage/route') - const response = await POST(request) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toMatchObject({ - billing: { - billed: true, - cost: 4.5, - }, - }) - expect(mockResolveWorkflowBillingContext).toHaveBeenCalledWith({ - workflowId: 'workflow-1', - actorUserId: 'user-1', - }) - expect(mockGetPersonalEffectiveSubscription).not.toHaveBeenCalled() - expect(mockAccrueUserUsageCost).toHaveBeenCalledWith({ - userId: 'user-1', - workflowId: 'workflow-1', - cost: 4.5, - extraUpdates: expect.any(Object), - reason: 'copilot_context_usage', - }) - expect(mockReleaseCopilotUsageReservation).toHaveBeenCalledWith({ - reservationId: 'reservation-1', - }) - }) - - it('returns 500 for committed context billing when Studio cannot resolve a tier', async () => { - mockIsBillingEnabledForRuntime.mockResolvedValue(true) - mockCheckInternalApiKey.mockReturnValue({ success: true }) - mockGetPersonalEffectiveSubscription.mockResolvedValue(null) - mockProxyCopilotRequest.mockResolvedValue( - new Response( - JSON.stringify({ - tokensUsed: 100, - model: 'gpt-5.4', - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue(null), + })) const request = new NextRequest('http://localhost:3000/api/copilot/usage', { method: 'POST', body: JSON.stringify({ - action: 'commit', kind: 'context', - conversationId: 'conversation-4', + conversationId: 'conversation-1', model: 'gpt-5.4', userId: 'user-1', - assistantMessageId: 'assistant-message-3', - reservationId: 'reservation-1', }), }) const { POST } = await import('@/app/api/copilot/usage/route') const response = await POST(request) - expect(response.status).toBe(500) - expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() - expect(mockMarkMessageAsProcessed).not.toHaveBeenCalled() - expect(mockReleaseCopilotUsageReservation).toHaveBeenCalledWith({ - reservationId: 'reservation-1', - }) + expect(response.status).toBe(401) + expect(mockProxyCopilotRequest).not.toHaveBeenCalled() }) - it('releases the reservation when committed context usage throws before billing completes', async () => { + it('rejects context usage commit requests because context usage is inspection-only', async () => { mockCheckInternalApiKey.mockReturnValue({ success: true }) - mockIsBillingEnabledForRuntime.mockResolvedValue(true) - mockProxyCopilotRequest.mockRejectedValue(new Error('copilot unavailable')) const request = new NextRequest('http://localhost:3000/api/copilot/usage', { method: 'POST', body: JSON.stringify({ action: 'commit', kind: 'context', - conversationId: 'conversation-5', + conversationId: 'conversation-2', model: 'gpt-5.4', userId: 'user-1', - assistantMessageId: 'assistant-message-4', + assistantMessageId: 'assistant-message-1', reservationId: 'reservation-1', }), }) @@ -554,12 +347,10 @@ describe('Copilot Usage API - Context', () => { const { POST } = await import('@/app/api/copilot/usage/route') const response = await POST(request) - expect(response.status).toBe(500) + expect(response.status).toBe(400) + expect(mockProxyCopilotRequest).not.toHaveBeenCalled() expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() - expect(mockMarkMessageAsProcessed).not.toHaveBeenCalled() - expect(mockReleaseCopilotUsageReservation).toHaveBeenCalledWith({ - reservationId: 'reservation-1', - }) + expect(mockReleaseCopilotUsageReservation).not.toHaveBeenCalled() }) it('reserves shared usage budget through the internal reserve action', async () => { @@ -693,89 +484,6 @@ describe('Copilot Usage API - Context', () => { expect(mockGetPersonalEffectiveSubscription).not.toHaveBeenCalled() }) - it('adjusts shared usage budget through the internal adjust action using Studio pricing', async () => { - mockCheckInternalApiKey.mockReturnValue({ success: true }) - mockIsBillingEnabledForRuntime.mockResolvedValue(true) - mockGetPersonalEffectiveSubscription.mockResolvedValue({ - id: 'subscription-personal', - tier: createTier(2), - }) - - const request = new NextRequest('http://localhost:3000/api/copilot/usage', { - method: 'POST', - body: JSON.stringify({ - action: 'adjust', - reservationId: 'reservation-1', - userId: 'user-1', - model: 'openai/gpt-5.4', - estimatedPromptTokens: 100, - reservedCompletionTokens: 25, - reason: 'copilot_turn_model_call', - }), - }) - - const { POST } = await import('@/app/api/copilot/usage/route') - const response = await POST(request) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - allowed: true, - status: 200, - reservationId: 'reservation-1', - reservedUsd: 3, - currentUsage: 8, - limit: 10, - remaining: 0, - activeReservedUsd: 3, - scopeType: 'user', - scopeId: 'user-1', - }) - expect(mockAdjustCopilotUsageReservation).toHaveBeenCalledWith({ - reservationId: 'reservation-1', - userId: 'user-1', - workflowId: undefined, - requestedUsd: 3, - reason: 'copilot_turn_model_call', - }) - }) - - it('no-ops adjust requests when billing is disabled', async () => { - mockCheckInternalApiKey.mockReturnValue({ success: true }) - mockIsBillingEnabledForRuntime.mockResolvedValue(false) - - const request = new NextRequest('http://localhost:3000/api/copilot/usage', { - method: 'POST', - body: JSON.stringify({ - action: 'adjust', - reservationId: 'reservation-1', - userId: 'user-1', - model: 'openai/gpt-5.4', - estimatedPromptTokens: 100, - reservedCompletionTokens: 25, - reason: 'copilot_turn_model_call', - }), - }) - - const { POST } = await import('@/app/api/copilot/usage/route') - const response = await POST(request) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - allowed: true, - status: 200, - reservationId: 'reservation-1', - reservedUsd: 0, - currentUsage: 0, - limit: Number.MAX_SAFE_INTEGER, - remaining: Number.MAX_SAFE_INTEGER, - activeReservedUsd: 0, - scopeType: 'user', - scopeId: 'user-1', - }) - expect(mockAdjustCopilotUsageReservation).not.toHaveBeenCalled() - expect(mockGetPersonalEffectiveSubscription).not.toHaveBeenCalled() - }) - it('releases reservations through the internal release action', async () => { mockCheckInternalApiKey.mockReturnValue({ success: true }) mockIsBillingEnabledForRuntime.mockResolvedValue(true) @@ -866,8 +574,9 @@ describe('Copilot Usage API - Completion', () => { const mockHasProcessedMessage = vi.fn() const mockMarkMessageAsProcessed = vi.fn() const mockCalculateCost = vi.fn() - const mockAdjustCopilotUsageReservation = vi.fn() + const mockCommitCopilotUsageReservation = vi.fn() const mockReleaseCopilotUsageReservation = vi.fn() + const mockIsHosted = vi.fn() const createTier = (copilotCostMultiplier: number) => ({ id: `tier-${copilotCostMultiplier}`, @@ -912,17 +621,20 @@ describe('Copilot Usage API - Completion', () => { mockHasProcessedMessage.mockReset() mockMarkMessageAsProcessed.mockReset() mockCalculateCost.mockReset() - mockAdjustCopilotUsageReservation.mockReset() + mockCommitCopilotUsageReservation.mockReset() mockReleaseCopilotUsageReservation.mockReset() + mockIsHosted.mockReset() mockCheckInternalApiKey.mockReturnValue({ success: true }) mockIsBillingEnabledForRuntime.mockResolvedValue(true) + mockIsHosted.mockReturnValue(true) mockGetPersonalEffectiveSubscription.mockResolvedValue({ id: 'subscription-personal', tier: createTier(2), }) mockGetTierCopilotCostMultiplier.mockImplementation( - (tier: { copilotCostMultiplier?: number } | null | undefined) => tier?.copilotCostMultiplier ?? 1 + (tier: { copilotCostMultiplier?: number } | null | undefined) => + tier?.copilotCostMultiplier ?? 1 ) mockAccrueUserUsageCost.mockResolvedValue(true) mockResolveWorkflowBillingContext.mockResolvedValue({ @@ -935,6 +647,15 @@ describe('Copilot Usage API - Completion', () => { mockHasProcessedMessage.mockResolvedValue(false) mockMarkMessageAsProcessed.mockResolvedValue(undefined) mockCalculateCost.mockReturnValue({ total: 1.5 }) + mockCommitCopilotUsageReservation.mockImplementation(async ({ reservationId, operation }) => { + try { + return await operation() + } finally { + if (reservationId) { + await mockReleaseCopilotUsageReservation({ reservationId }) + } + } + }) mockReleaseCopilotUsageReservation.mockResolvedValue({ released: true, reservationId: 'reservation-1', @@ -954,6 +675,10 @@ describe('Copilot Usage API - Completion', () => { checkInternalApiKey: (...args: any[]) => mockCheckInternalApiKey(...args), })) + vi.doMock('@/lib/environment', () => ({ + isHosted: mockIsHosted(), + })) + vi.doMock('@/lib/billing/settings', () => ({ isBillingEnabledForRuntime: (...args: any[]) => mockIsBillingEnabledForRuntime(...args), })) @@ -977,8 +702,7 @@ describe('Copilot Usage API - Completion', () => { vi.doMock('@/lib/copilot/usage-reservations', () => ({ reserveCopilotUsage: vi.fn(), - adjustCopilotUsageReservation: (...args: any[]) => - mockAdjustCopilotUsageReservation(...args), + commitCopilotUsageReservation: (...args: any[]) => mockCommitCopilotUsageReservation(...args), releaseCopilotUsageReservation: (...args: any[]) => mockReleaseCopilotUsageReservation(...args), })) @@ -1001,15 +725,15 @@ describe('Copilot Usage API - Completion', () => { })) }) - it('records internal completion billing with canonical dotted Claude model ids', async () => { + it('records internal completion billing with canonical provider model ids', async () => { const request = new NextRequest('http://localhost:3000/api/copilot/usage', { method: 'POST', body: JSON.stringify({ action: 'commit', kind: 'completion', userId: 'user-1', - model: 'claude-sonnet-4.6', - remoteModel: 'anthropic/claude-sonnet-4.6', + model: 'anthropic/claude-sonnet-4.6', + remoteModel: 'claude-4.6-sonnet-20260217', completionId: 'completion-1', reservationId: 'reservation-1', usage: { @@ -1031,13 +755,11 @@ describe('Copilot Usage API - Completion', () => { billed: true, duplicate: false, tokens: 125, - model: 'claude-sonnet-4.6', + model: 'anthropic/claude-sonnet-4.6', cost: 3, }, }) - expect(mockHasProcessedMessage).toHaveBeenCalledWith( - 'copilot-completion-billing:completion-1' - ) + expect(mockHasProcessedMessage).toHaveBeenCalledWith('copilot-completion-billing:completion-1') expect(mockAccrueUserUsageCost).toHaveBeenCalledWith({ userId: 'user-1', workflowId: undefined, @@ -1049,12 +771,152 @@ describe('Copilot Usage API - Completion', () => { 'copilot-completion-billing:completion-1', 60 * 60 * 24 * 30 ) - expect(mockCalculateCost).toHaveBeenCalledWith('claude-sonnet-4.6', 100, 25, false) + expect(mockCalculateCost).toHaveBeenCalledWith('anthropic/claude-sonnet-4.6', 100, 25, false) + expect(mockCommitCopilotUsageReservation).toHaveBeenCalledWith({ + userId: 'user-1', + workflowId: undefined, + reservationId: 'reservation-1', + operation: expect.any(Function), + }) expect(mockReleaseCopilotUsageReservation).toHaveBeenCalledWith({ reservationId: 'reservation-1', }) }) + it('mirrors hosted Copilot completion reports into self-hosted Studio usage', async () => { + mockIsHosted.mockReturnValue(false) + mockIsBillingEnabledForRuntime.mockResolvedValue(true) + mockGetPersonalEffectiveSubscription.mockResolvedValue({ + id: 'subscription-personal', + tier: createTier(2), + }) + + const { mirrorLocalCopilotCompletionUsageReports } = await import( + '@/lib/copilot/completion-usage-billing' + ) + await mirrorLocalCopilotCompletionUsageReports({ + userId: 'user-1', + reports: [ + { + kind: 'completion', + model: 'gpt-5.4', + remoteModel: 'openai/gpt-5.4', + completionId: 'local-completion-1', + usage: { + prompt_tokens: 100, + completion_tokens: 25, + total_tokens: 125, + }, + }, + ], + }) + + expect(mockAccrueUserUsageCost).toHaveBeenCalledWith({ + userId: 'user-1', + workflowId: undefined, + cost: 3, + extraUpdates: expect.any(Object), + reason: 'copilot_completion_usage', + }) + expect(mockMarkMessageAsProcessed).toHaveBeenCalledWith( + 'copilot-completion-billing:local-completion-1', + 60 * 60 * 24 * 30 + ) + expect(mockCommitCopilotUsageReservation).toHaveBeenCalledWith({ + userId: 'user-1', + workflowId: undefined, + operation: expect.any(Function), + }) + }) + + it('ignores invalid self-hosted Copilot completion mirror reports', async () => { + mockIsHosted.mockReturnValue(false) + mockIsBillingEnabledForRuntime.mockResolvedValue(true) + + const { mirrorLocalCopilotCompletionUsageReports } = await import( + '@/lib/copilot/completion-usage-billing' + ) + await mirrorLocalCopilotCompletionUsageReports({ + userId: 'user-1', + reports: [ + { + kind: 'completion', + model: 'gpt-5.4', + usage: { + prompt_tokens: 100, + completion_tokens: 25, + total_tokens: 125, + }, + }, + ], + }) + + expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() + expect(mockHasProcessedMessage).not.toHaveBeenCalled() + expect(mockCommitCopilotUsageReservation).not.toHaveBeenCalled() + }) + + it('isolates self-hosted Copilot completion mirror billing failures', async () => { + mockIsHosted.mockReturnValue(false) + mockIsBillingEnabledForRuntime.mockResolvedValue(true) + mockGetPersonalEffectiveSubscription.mockResolvedValue({ + id: 'subscription-personal', + tier: createTier(2), + }) + mockCalculateCost.mockImplementation(() => { + throw new Error('pricing unavailable') + }) + + const { mirrorLocalCopilotCompletionUsageReports } = await import( + '@/lib/copilot/completion-usage-billing' + ) + await mirrorLocalCopilotCompletionUsageReports({ + userId: 'user-1', + reports: [ + { + kind: 'completion', + model: 'gpt-5.4', + completionId: 'local-completion-2', + usage: { + prompt_tokens: 100, + completion_tokens: 25, + total_tokens: 125, + }, + }, + ], + }) + + expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() + expect(mockCommitCopilotUsageReservation).toHaveBeenCalledWith({ + userId: 'user-1', + workflowId: undefined, + operation: expect.any(Function), + }) + expect(mockMarkMessageAsProcessed).not.toHaveBeenCalled() + }) + + it('does not mirror hosted Copilot completion reports on hosted Studio', async () => { + mockIsHosted.mockReturnValue(true) + + const { mirrorLocalCopilotCompletionUsageReports } = await import( + '@/lib/copilot/completion-usage-billing' + ) + await mirrorLocalCopilotCompletionUsageReports({ + userId: 'user-1', + reports: [ + { + kind: 'completion', + model: 'gpt-5.4', + completionId: 'hosted-completion-1', + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ], + }) + + expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() + expect(mockCommitCopilotUsageReservation).not.toHaveBeenCalled() + }) + it('does not double-bill duplicate completion ids', async () => { mockHasProcessedMessage.mockResolvedValue(true) @@ -1089,6 +951,12 @@ describe('Copilot Usage API - Completion', () => { }) expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() expect(mockMarkMessageAsProcessed).not.toHaveBeenCalled() + expect(mockCommitCopilotUsageReservation).toHaveBeenCalledWith({ + userId: 'user-1', + workflowId: undefined, + reservationId: 'reservation-1', + operation: expect.any(Function), + }) expect(mockReleaseCopilotUsageReservation).toHaveBeenCalledWith({ reservationId: 'reservation-1', }) @@ -1125,6 +993,31 @@ describe('Copilot Usage API - Completion', () => { }) }) + it('does not release reservations for malformed completion commits', async () => { + const request = new NextRequest('http://localhost:3000/api/copilot/usage', { + method: 'POST', + body: JSON.stringify({ + action: 'commit', + kind: 'completion', + userId: 'user-1', + reservationId: 'reservation-1', + usage: { + prompt_tokens: 100, + completion_tokens: 25, + total_tokens: 125, + }, + }), + headers: { 'Content-Type': 'application/json' }, + }) + + const { POST } = await import('@/app/api/copilot/usage/route') + const response = await POST(request) + + expect(response.status).toBe(400) + expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() + expect(mockReleaseCopilotUsageReservation).not.toHaveBeenCalled() + }) + it('releases the reservation when completion billing is disabled', async () => { mockIsBillingEnabledForRuntime.mockResolvedValue(false) diff --git a/apps/tradinggoose/app/api/copilot/usage/route.ts b/apps/tradinggoose/app/api/copilot/usage/route.ts index c808a23fa..4ec4441c0 100644 --- a/apps/tradinggoose/app/api/copilot/usage/route.ts +++ b/apps/tradinggoose/app/api/copilot/usage/route.ts @@ -1,29 +1,23 @@ -import { sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' -import { getPersonalEffectiveSubscription } from '@/lib/billing/core/subscription' import { isBillingEnabledForRuntime } from '@/lib/billing/settings' -import { getTierCopilotCostMultiplier } from '@/lib/billing/tiers' -import { accrueUserUsageCost } from '@/lib/billing/usage-accrual' -import { resolveWorkflowBillingContext } from '@/lib/billing/workspace-billing' import { - adjustCopilotUsageReservation, - releaseCopilotUsageReservation, - reserveCopilotUsage, -} from '@/lib/copilot/usage-reservations' + calculateCopilotReservationUsdFromEstimate, + recordCopilotCompletionUsage, +} from '@/lib/copilot/completion-usage-billing' import { COPILOT_RUNTIME_MODELS } from '@/lib/copilot/runtime-models' import { COPILOT_RUNTIME_PROVIDER_IDS } from '@/lib/copilot/runtime-provider' import { buildCopilotRuntimeProviderConfig } from '@/lib/copilot/runtime-provider.server' +import { + commitCopilotUsageReservation, + releaseCopilotUsageReservation, + reserveCopilotUsage, +} from '@/lib/copilot/usage-reservations' import { checkInternalApiKey } from '@/lib/copilot/utils' -import { isHosted } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' -import { hasProcessedMessage, markMessageAsProcessed } from '@/lib/redis' import { getCopilotApiUrl, proxyCopilotRequest } from '@/app/api/copilot/proxy' -import { calculateCost } from '@/providers/ai/utils' -const BILLING_EVENT_TTL_SECONDS = 60 * 60 * 24 * 30 // 30 days -const DEFAULT_ESTIMATED_RESERVATION_USD = 1 const BILLING_DISABLED_RESERVATION_ID = 'billing-disabled' const logger = createLogger('CopilotUsageAPI') @@ -32,11 +26,8 @@ const ContextUsageRequestSchema = z.object({ conversationId: z.string(), model: z.enum(COPILOT_RUNTIME_MODELS), workflowId: z.string().optional(), + workspaceId: z.string().optional(), provider: z.enum(COPILOT_RUNTIME_PROVIDER_IDS).optional(), - bill: z.boolean().optional(), - assistantMessageId: z.string().optional(), - billingModel: z.string().optional(), - userId: z.string().optional(), }) const UsageEstimateSchema = z.object({ @@ -53,39 +44,20 @@ const ReserveUsageUsdRequestSchema = z.object({ reason: z.string().min(1).optional(), }) -const ReserveUsageEstimatedRequestSchema = z.object({ - action: z.literal('reserve'), - userId: z.string().min(1, 'userId is required'), - workflowId: z.string().min(1).optional(), - reason: z.string().min(1).optional(), -}).merge(UsageEstimateSchema) +const ReserveUsageEstimatedRequestSchema = z + .object({ + action: z.literal('reserve'), + userId: z.string().min(1, 'userId is required'), + workflowId: z.string().min(1).optional(), + reason: z.string().min(1).optional(), + }) + .merge(UsageEstimateSchema) const ReserveUsageRequestSchema = z.union([ ReserveUsageUsdRequestSchema, ReserveUsageEstimatedRequestSchema, ]) -const AdjustUsageRequestSchema = z.object({ - action: z.literal('adjust'), - reservationId: z.string().min(1, 'reservationId is required'), - userId: z.string().min(1, 'userId is required'), - workflowId: z.string().min(1).optional(), - reason: z.string().min(1).optional(), -}).merge(UsageEstimateSchema) - -const ContextCommitRequestSchema = z.object({ - action: z.literal('commit'), - kind: z.literal('context'), - conversationId: z.string(), - model: z.enum(COPILOT_RUNTIME_MODELS), - workflowId: z.string().optional(), - provider: z.enum(COPILOT_RUNTIME_PROVIDER_IDS).optional(), - assistantMessageId: z.string().min(1, 'assistantMessageId is required'), - billingModel: z.string().optional(), - userId: z.string().min(1, 'userId is required'), - reservationId: z.string().min(1).optional(), -}) - const CompletionCommitRequestSchema = z.object({ action: z.literal('commit'), kind: z.literal('completion'), @@ -103,323 +75,15 @@ const ReleaseUsageRequestSchema = z.object({ reservationId: z.string().min(1, 'reservationId is required'), }) -interface TokenMetrics { - promptTokens: number - completionTokens: number - totalTokens: number -} - -type UsageBillingResult = - | { - billed: true - duplicate: false - cost: number - tokens: number - model: string - } - | { - billed: false - duplicate: true - } - | { - billed: false - duplicate?: false - reason: 'billing_disabled' | 'no_token_metrics' | 'zero_cost' | 'ledger_not_found' - } - -function readNumber(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isFinite(value)) { - return value - } - if (typeof value === 'string') { - const parsed = Number.parseFloat(value) - return Number.isFinite(parsed) ? parsed : undefined - } - return undefined -} - -function pickNumber(source: any, keys: string[]): number | undefined { - if (!source || typeof source !== 'object') return undefined - for (const key of keys) { - const candidate = readNumber(source[key]) - if (candidate !== undefined) { - return candidate - } - } - return undefined -} - -function extractTokenMetrics(usage: any): TokenMetrics | null { - const sources = [usage, usage?.tokenUsage, usage?.tokens, usage?.usageDetails] - - let promptTokens: number | undefined - let completionTokens: number | undefined - let totalTokens: number | undefined - - for (const src of sources) { - if (promptTokens === undefined) { - promptTokens = pickNumber(src, [ - 'prompt_tokens', - 'promptTokens', - 'input_tokens', - 'inputTokens', - 'prompt', - ]) - } - if (completionTokens === undefined) { - completionTokens = pickNumber(src, [ - 'completion_tokens', - 'completionTokens', - 'output_tokens', - 'outputTokens', - 'completion', - ]) - } - if (totalTokens === undefined) { - totalTokens = pickNumber(src, [ - 'total_tokens', - 'totalTokens', - 'tokens', - 'token_count', - 'total', - ]) - } - } - - if (totalTokens === undefined) { - totalTokens = readNumber(usage?.tokensUsed) ?? readNumber(usage?.usage) - } - - if (completionTokens === undefined) { - completionTokens = 0 - } - - if (totalTokens !== undefined && promptTokens === undefined) { - promptTokens = totalTokens - completionTokens - } - - if (promptTokens === undefined || totalTokens === undefined) { - return null - } - - const normalizedPrompt = Math.max(0, Math.round(promptTokens)) - const normalizedCompletion = Math.max(0, Math.round(completionTokens ?? 0)) - const normalizedTotal = Math.max( - 0, - Math.round(totalTokens ?? normalizedPrompt + normalizedCompletion) - ) - - if (normalizedTotal <= 0 || (normalizedPrompt === 0 && normalizedCompletion === 0)) { - return null - } - - return { - promptTokens: normalizedPrompt, - completionTokens: normalizedCompletion, - totalTokens: normalizedTotal, - } -} - -function normalizeModelForBilling(model: string): string { - const base = model.includes('/') ? model.split('/').pop() || model : model - return base.toLowerCase() -} - -async function recordBilledUsage(params: { - userId: string - workflowId?: string - usage: any - billingModel: string - remoteModel?: string | null - billingKeyPrefix: 'copilot-billing' | 'copilot-completion-billing' - billingKeyId?: string | null - reason: 'copilot_context_usage' | 'copilot_completion_usage' -}): Promise { - const { userId, workflowId, usage, billingModel, remoteModel, billingKeyPrefix, billingKeyId, reason } = - params - - const metrics = extractTokenMetrics(usage) - if (!metrics) { - logger.info('Skipping copilot billing - no token metrics available', { - billingKeyPrefix, - billingKeyId, - reason, - }) - return { billed: false, reason: 'no_token_metrics' } - } - - const billingKey = billingKeyId ? `${billingKeyPrefix}:${billingKeyId}` : null - if (billingKey && (await hasProcessedMessage(billingKey))) { - logger.info('Copilot billing already processed', { billingKey, reason }) - return { billed: false, duplicate: true } - } - - const { costUsd: costToAdd, normalizedModel, billingContext } = await calculateCopilotCostUsd({ - userId, - workflowId, - billingModel, - remoteModel, - promptTokens: metrics.promptTokens, - completionTokens: metrics.completionTokens, - }) - if (costToAdd <= 0) { - logger.info('Skipping copilot billing - calculated cost is zero', { - userId, - workflowId, - billingKeyId, - model: normalizedModel, - reason, - }) - return { billed: false, reason: 'zero_cost' } - } - - const extraUpdates: Record = { - totalCopilotCost: sql`total_copilot_cost + ${costToAdd}`, - currentPeriodCopilotCost: sql`current_period_copilot_cost + ${costToAdd}`, - totalCopilotCalls: sql`total_copilot_calls + 1`, - } - - if (metrics.totalTokens > 0) { - extraUpdates.totalCopilotTokens = sql`total_copilot_tokens + ${metrics.totalTokens}` - } - - const didAccrue = await accrueUserUsageCost({ - userId, - workflowId, - cost: costToAdd, - extraUpdates, - reason, - }) - - if (!didAccrue) { - logger.warn('Copilot billing skipped - ledger record not found', { - userId, - workflowId, - billingKeyId, - reason, - }) - return { billed: false, reason: 'ledger_not_found' } - } - - if (billingKey) { - await markMessageAsProcessed(billingKey, BILLING_EVENT_TTL_SECONDS) - } - - logger.info('Copilot billing recorded', { - userId, - billingUserId: billingContext?.billingUserId ?? userId, - workflowId, - billingKeyId, - cost: costToAdd, - tokens: metrics.totalTokens, - model: normalizedModel, - reason, - }) - - return { - billed: true, - duplicate: false, - cost: costToAdd, - tokens: metrics.totalTokens, - model: normalizedModel, - } -} - -async function resolveEffectiveCopilotTier(params: { - userId: string - workflowId?: string -}): Promise<{ - effectiveTier: any - billingContext: Awaited> | null -}> { - const billingContext = params.workflowId - ? await resolveWorkflowBillingContext({ - workflowId: params.workflowId, - actorUserId: params.userId, - }) - : null - const effectiveTier = params.workflowId - ? billingContext?.subscription?.tier ?? null - : (await getPersonalEffectiveSubscription(params.userId))?.tier ?? null - - if (!effectiveTier) { - throw new Error( - params.workflowId - ? `No active workflow subscription tier found for billed copilot usage on workflow ${params.workflowId}` - : `No active personal subscription tier found for billed copilot usage for user ${params.userId}` - ) - } - - return { - effectiveTier, - billingContext, - } -} - -async function calculateCopilotCostUsd(params: { - userId: string - workflowId?: string - billingModel: string - remoteModel?: string | null - promptTokens: number - completionTokens: number - fallbackUsd?: number -}): Promise<{ - costUsd: number - normalizedModel: string - billingContext: Awaited> | null -}> { - const modelToUse = - typeof params.remoteModel === 'string' && params.remoteModel.length > 0 - ? params.remoteModel - : params.billingModel - const normalizedModel = normalizeModelForBilling(modelToUse) - const costResult = calculateCost( - normalizedModel, - params.promptTokens, - params.completionTokens, - false - ) - const { effectiveTier, billingContext } = await resolveEffectiveCopilotTier({ - userId: params.userId, - workflowId: params.workflowId, - }) - const rawCostUsd = Number(costResult.total || 0) * getTierCopilotCostMultiplier(effectiveTier) - - return { - costUsd: rawCostUsd > 0 ? rawCostUsd : params.fallbackUsd ?? 0, - normalizedModel, - billingContext, - } -} - -async function calculateReservationUsdFromEstimate(params: { - userId: string - workflowId?: string - model: string - estimatedPromptTokens: number - reservedCompletionTokens: number -}): Promise { - const { costUsd } = await calculateCopilotCostUsd({ - userId: params.userId, - workflowId: params.workflowId, - billingModel: params.model, - promptTokens: params.estimatedPromptTokens, - completionTokens: params.reservedCompletionTokens, - fallbackUsd: DEFAULT_ESTIMATED_RESERVATION_USD, - }) - - return costUsd -} - async function fetchContextUsageFromCopilot(params: { conversationId: string model: z.infer['model'] workflowId?: string + workspaceId?: string provider?: z.infer['provider'] userId: string }) { - const { conversationId, model, workflowId, provider, userId } = params + const { conversationId, model, workflowId, workspaceId, provider, userId } = params const { providerConfig } = await buildCopilotRuntimeProviderConfig({ model, provider, @@ -430,6 +94,7 @@ async function fetchContextUsageFromCopilot(params: { model, userId, ...(workflowId ? { workflowId } : {}), + ...(workspaceId ? { workspaceId } : {}), provider: providerConfig, } @@ -445,14 +110,11 @@ async function fetchContextUsageFromCopilot(params: { } async function handleContextUsage( - req: NextRequest, payload: z.infer ): Promise { - const { conversationId, model, workflowId, provider, bill, assistantMessageId, billingModel } = - payload - const internalAuth = checkInternalApiKey(req) - const session = !internalAuth.success ? await getSession() : null - const userId = internalAuth.success ? payload.userId : session?.user?.id + const { conversationId, model, workflowId, workspaceId, provider } = payload + const session = await getSession() + const userId = session?.user?.id if (!userId) { logger.warn('[Usage API] No session/user ID for context usage') @@ -463,6 +125,7 @@ async function handleContextUsage( conversationId, model, workflowId, + workspaceId, provider, userId, }) @@ -480,76 +143,10 @@ async function handleContextUsage( } const data = await simAgentResponse.json() - - const shouldBill = Boolean(bill && assistantMessageId && !internalAuth.success && !isHosted) - if (!shouldBill) { - return NextResponse.json(data) - } - - if (!(await isBillingEnabledForRuntime())) { - return NextResponse.json({ - ...data, - billing: { billed: false, reason: 'billing_disabled' }, - }) - } - - try { - const billing = await recordBilledUsage({ - userId, - workflowId, - usage: data, - billingModel: billingModel || model, - remoteModel: data?.model, - billingKeyPrefix: 'copilot-billing', - billingKeyId: assistantMessageId, - reason: 'copilot_context_usage', - }) - return NextResponse.json({ - ...data, - billing, - }) - } catch (billingError) { - logger.error('Failed to bill copilot context usage', { - error: billingError, - conversationId, - assistantMessageId, - }) - return NextResponse.json({ - ...data, - billing: { billed: false, reason: 'ledger_not_found' }, - }) - } + return NextResponse.json(data) } -async function releaseCommittedReservation(reservationId?: string): Promise { - if (!reservationId) return - if (reservationId === BILLING_DISABLED_RESERVATION_ID) { - return - } - - await releaseCopilotUsageReservation({ reservationId }).catch((error) => { - logger.warn('Failed to release copilot usage reservation after commit', { - reservationId, - error: error instanceof Error ? error.message : String(error), - }) - }) -} - -async function withCommittedReservationRelease( - reservationId: string | undefined, - operation: () => Promise -): Promise { - try { - return await operation() - } finally { - await releaseCommittedReservation(reservationId) - } -} - -function buildBillingDisabledReservation(params: { - userId: string - reservationId?: string -}) { +function buildBillingDisabledReservation(params: { userId: string; reservationId?: string }) { return { allowed: true, status: 200, @@ -580,7 +177,7 @@ async function handleReserveUsage( const requestedUsd = 'requestedUsd' in payload ? payload.requestedUsd - : await calculateReservationUsdFromEstimate({ + : await calculateCopilotReservationUsdFromEstimate({ userId: payload.userId, workflowId: payload.workflowId, model: payload.model, @@ -598,133 +195,35 @@ async function handleReserveUsage( return NextResponse.json(result, { status: result.status }) } -async function handleAdjustUsage( - req: NextRequest, - payload: z.infer +async function handleCompletionCommit( + payload: z.infer ): Promise { - const auth = checkInternalApiKey(req) - if (!auth.success) { - return new NextResponse(null, { status: 401 }) - } - - if (!(await isBillingEnabledForRuntime())) { - return NextResponse.json( - buildBillingDisabledReservation({ - userId: payload.userId, - reservationId: payload.reservationId, - }) - ) - } - - const requestedUsd = await calculateReservationUsdFromEstimate({ - userId: payload.userId, - workflowId: payload.workflowId, - model: payload.model, - estimatedPromptTokens: payload.estimatedPromptTokens, - reservedCompletionTokens: payload.reservedCompletionTokens, - }) - - const result = await adjustCopilotUsageReservation({ - reservationId: payload.reservationId, + return await commitCopilotUsageReservation({ userId: payload.userId, workflowId: payload.workflowId, - requestedUsd, - reason: payload.reason, - }) - - return NextResponse.json(result, { status: result.status }) -} - -async function handleContextCommit( - req: NextRequest, - payload: z.infer -): Promise { - const auth = checkInternalApiKey(req) - if (!auth.success) { - return new NextResponse(null, { status: 401 }) - } - - return withCommittedReservationRelease(payload.reservationId, async () => { - const simAgentResponse = await fetchContextUsageFromCopilot({ - conversationId: payload.conversationId, - model: payload.model, - workflowId: payload.workflowId, - provider: payload.provider, - userId: payload.userId, - }) - - if (!simAgentResponse.ok) { - const errorText = await simAgentResponse.text().catch(() => '') - logger.warn('[Usage API] TradingGoose agent request failed during commit', { - status: simAgentResponse.status, - error: errorText, - reservationId: payload.reservationId, - }) - return NextResponse.json( - { error: 'Failed to fetch context usage from copilot' }, - { status: simAgentResponse.status } - ) - } - - const data = await simAgentResponse.json() + reservationId: + payload.reservationId === BILLING_DISABLED_RESERVATION_ID ? undefined : payload.reservationId, + operation: async () => { + if (!(await isBillingEnabledForRuntime())) { + return NextResponse.json({ + success: true, + billing: { billed: false, reason: 'billing_disabled' }, + }) + } - if (!(await isBillingEnabledForRuntime())) { - return NextResponse.json({ - ...data, - billing: { billed: false, reason: 'billing_disabled' }, + const billing = await recordCopilotCompletionUsage({ + userId: payload.userId, + workflowId: payload.workflowId, + usage: payload.usage, + billingModel: payload.model, + billingKeyId: payload.completionId, }) - } - - const billing = await recordBilledUsage({ - userId: payload.userId, - workflowId: payload.workflowId, - usage: data, - billingModel: payload.billingModel || payload.model, - remoteModel: data?.model, - billingKeyPrefix: 'copilot-billing', - billingKeyId: payload.assistantMessageId, - reason: 'copilot_context_usage', - }) - - return NextResponse.json({ - ...data, - billing, - }) - }) -} - -async function handleCompletionCommit( - req: NextRequest, - payload: z.infer -): Promise { - const auth = checkInternalApiKey(req) - if (!auth.success) { - return new NextResponse(null, { status: 401 }) - } - return withCommittedReservationRelease(payload.reservationId, async () => { - if (!(await isBillingEnabledForRuntime())) { return NextResponse.json({ success: true, - billing: { billed: false, reason: 'billing_disabled' }, + billing, }) - } - - const billing = await recordBilledUsage({ - userId: payload.userId, - workflowId: payload.workflowId, - usage: payload.usage, - billingModel: payload.model, - remoteModel: payload.remoteModel, - billingKeyPrefix: 'copilot-completion-billing', - billingKeyId: payload.completionId, - reason: 'copilot_completion_usage', - }) - - return NextResponse.json({ - success: true, - billing, - }) + }, }) } @@ -753,7 +252,7 @@ async function handleReleaseUsage( /** * POST /api/copilot/usage - * Unified copilot usage endpoint for context inspection/billing and raw completion billing. + * Unified copilot usage endpoint for context inspection, reservation control, and completion billing. */ export async function POST(req: NextRequest) { try { @@ -769,7 +268,8 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } - const action = body && typeof body === 'object' ? (body as Record).action : null + const action = + body && typeof body === 'object' ? (body as Record).action : null if (action === 'reserve') { const parsed = ReserveUsageRequestSchema.safeParse(body) if (!parsed.success) { @@ -785,48 +285,25 @@ export async function POST(req: NextRequest) { return await handleReserveUsage(req, parsed.data) } - if (action === 'adjust') { - const parsed = AdjustUsageRequestSchema.safeParse(body) - if (!parsed.success) { - logger.warn('Invalid copilot usage adjust request', { errors: parsed.error.errors }) - return NextResponse.json( - { - error: 'Invalid request body', - details: parsed.error.errors, - }, - { status: 400 } - ) + if (action === 'commit') { + const auth = checkInternalApiKey(req) + if (!auth.success) { + return new NextResponse(null, { status: 401 }) } - return await handleAdjustUsage(req, parsed.data) - } - if (action === 'commit') { - const kind = body && typeof body === 'object' ? (body as Record).kind : null - const parsed = - kind === 'context' - ? ContextCommitRequestSchema.safeParse(body) - : kind === 'completion' - ? CompletionCommitRequestSchema.safeParse(body) - : null - - if (!parsed || !parsed.success) { - logger.warn('Invalid copilot usage commit request', { - errors: parsed && !parsed.success ? parsed.error.errors : [{ message: 'Invalid commit kind' }], - }) + const parsed = CompletionCommitRequestSchema.safeParse(body) + if (!parsed.success) { + logger.warn('Invalid copilot usage commit request', { errors: parsed.error.errors }) return NextResponse.json( { error: 'Invalid request body', - details: parsed && !parsed.success ? parsed.error.errors : [{ message: 'Invalid commit kind' }], + details: parsed.error.errors, }, { status: 400 } ) } - if (parsed.data.kind === 'context') { - return await handleContextCommit(req, parsed.data) - } - - return await handleCompletionCommit(req, parsed.data) + return await handleCompletionCommit(parsed.data) } if (action === 'release') { @@ -857,7 +334,7 @@ export async function POST(req: NextRequest) { ) } - return await handleContextUsage(req, parsed.data) + return await handleContextUsage(parsed.data) } catch (error) { logger.error('Failed to process copilot usage request', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) 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 e9de87c8b..fdfe1d765 100644 --- a/apps/tradinggoose/app/api/indicators/custom/import/route.test.ts +++ b/apps/tradinggoose/app/api/indicators/custom/import/route.test.ts @@ -72,7 +72,6 @@ describe('Indicators import route', () => { indicators: [ { name: 'RSI Export Example', - color: '#3972F6', pineCode: "indicator('RSI Export Example')", inputMeta: {}, }, @@ -93,7 +92,6 @@ describe('Indicators import route', () => { indicators: [ { name: 'RSI Export Example', - color: '#3972F6', 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 e904862bc..43d4acd7d 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -36,7 +36,6 @@ const IndicatorSchema = z.object({ z.object({ id: z.string().optional(), name: z.string().min(1, 'Indicator name is required'), - color: z.string().optional(), pineCode: z.string().default(''), inputMeta: z.record(z.any()).optional(), }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts index 26f84daa6..b61846eb3 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -25,7 +25,6 @@ const logger = createLogger('WorkflowDuplicateAPI') const DuplicateRequestSchema = z.object({ name: z.string().min(1, 'Name is required'), description: z.string().optional(), - color: z.string().optional(), workspaceId: z.string().min(1, 'Workspace ID is required'), folderId: z.string().nullable().optional(), }) @@ -79,7 +78,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: try { const body = await req.json() - const { name, description, color, workspaceId, folderId } = DuplicateRequestSchema.parse(body) + const { name, description, workspaceId, folderId } = DuplicateRequestSchema.parse(body) logger.info( `[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${session.user.id}` @@ -122,10 +121,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const newWorkflowId = crypto.randomUUID() const now = new Date() - const resolvedColor = - typeof color === 'string' && color.trim().length > 0 - ? color.trim() - : getStableVibrantColor(newWorkflowId) + const resolvedColor = getStableVibrantColor(newWorkflowId) const duplicatedWorkflowState = regenerateWorkflowStateIds(sourceArtifacts.workflowState) const duplicatedVariables = remapVariableIds(sourceArtifacts.variables, newWorkflowId) diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index 3fa71eb2d..bb5f160f4 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -775,6 +775,28 @@ describe('Workflow By ID API Route', () => { const data = await response.json() expect(data.error).toBe('Invalid request data') }) + + it('should reject generated workflow color updates', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), + })) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'PUT', + body: JSON.stringify({ color: '#3972F6' }), + }) + 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(400) + const data = await response.json() + expect(data.error).toBe('Invalid request data') + expect(JSON.stringify(data.details)).toContain('color') + }) }) describe('Error handling', () => { diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index e7823b3e8..cc0088b93 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -16,12 +16,13 @@ import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' const logger = createLogger('WorkflowByIdAPI') -const UpdateWorkflowSchema = z.object({ - name: z.string().min(1, 'Name is required').optional(), - description: z.string().optional(), - color: z.string().optional(), - folderId: z.string().nullable().optional(), -}) +const UpdateWorkflowSchema = z + .object({ + name: z.string().min(1, 'Name is required').optional(), + description: z.string().optional(), + folderId: z.string().nullable().optional(), + }) + .strict() /** * GET /api/workflows/[id] @@ -310,7 +311,7 @@ export async function DELETE( /** * PUT /api/workflows/[id] - * Update workflow metadata (name, description, color, folderId) + * Update workflow metadata (name, description, folderId) */ export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() @@ -371,7 +372,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ const updateData: any = { updatedAt: new Date() } if (updates.name !== undefined) updateData.name = updates.name if (updates.description !== undefined) updateData.description = updates.description - if (updates.color !== undefined) updateData.color = updates.color if (updates.folderId !== undefined) updateData.folderId = updates.folderId // Update the workflow diff --git a/apps/tradinggoose/app/api/workflows/route.ts b/apps/tradinggoose/app/api/workflows/route.ts index 7e1c84601..ca3b2ca30 100644 --- a/apps/tradinggoose/app/api/workflows/route.ts +++ b/apps/tradinggoose/app/api/workflows/route.ts @@ -19,7 +19,6 @@ const logger = createLogger('WorkflowAPI') const CreateWorkflowSchema = z.object({ name: z.string().min(1, 'Name is required'), description: z.string().optional().default(''), - color: z.string().optional(), workspaceId: z.string().min(1, 'Workspace ID is required'), folderId: z.string().nullable().optional(), initialWorkflowState: z.any().optional(), @@ -129,7 +128,7 @@ export async function POST(req: NextRequest) { try { const body = await req.json() - const { name, description, color, workspaceId, folderId, initialWorkflowState } = + const { name, description, workspaceId, folderId, initialWorkflowState } = CreateWorkflowSchema.parse(body) const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id) @@ -161,10 +160,7 @@ export async function POST(req: NextRequest) { normalizeVariables(initialState?.variables), workflowId ) - const resolvedColor = - typeof color === 'string' && color.trim().length > 0 - ? color.trim() - : getStableVibrantColor(workflowId) + const resolvedColor = getStableVibrantColor(workflowId) logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${session.user.id}`) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/templates/[id]/template.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/templates/[id]/template.tsx index 9f1f36b1c..32ad89859 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/templates/[id]/template.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/templates/[id]/template.tsx @@ -216,7 +216,6 @@ export default function TemplateDetails({ template, workspaceId }: TemplateDetai body: JSON.stringify({ name: `${template.name} (Copy)`, description: `Created from template: ${template.name}`, - color: template.color, workspaceId, folderId: null, }), diff --git a/apps/tradinggoose/hooks/queries/indicators.ts b/apps/tradinggoose/hooks/queries/indicators.ts index 366bddedf..6120786c1 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: Omit + indicator: Pick } export function useCreateIndicator() { @@ -152,19 +152,11 @@ export function useCreateIndicator() { mutationFn: async ({ workspaceId, indicator }: CreateIndicatorParams) => { logger.info(`Creating indicator: ${indicator.name} in workspace ${workspaceId}`) - const resolvedIndicator = { - ...indicator, - color: - typeof indicator.color === 'string' && indicator.color.trim().length > 0 - ? indicator.color.trim() - : undefined, - } - const response = await fetch(API_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - indicators: [resolvedIndicator], + indicators: [indicator], workspaceId, }), }) @@ -192,7 +184,7 @@ interface UpdateIndicatorParams { workspaceId: string indicatorId: string updates: Partial< - Omit + Omit > } @@ -229,7 +221,6 @@ export function useUpdateIndicator() { { id: indicatorId, name: updates.name ?? currentIndicator.name, - color: updates.color ?? currentIndicator.color, pineCode: updates.pineCode ?? currentIndicator.pineCode, inputMeta: resolvedInputMeta, }, diff --git a/apps/tradinggoose/hooks/queries/workflows.ts b/apps/tradinggoose/hooks/queries/workflows.ts index f84959e2a..3783d61c5 100644 --- a/apps/tradinggoose/hooks/queries/workflows.ts +++ b/apps/tradinggoose/hooks/queries/workflows.ts @@ -16,7 +16,6 @@ interface CreateWorkflowVariables { workspaceId: string name?: string description?: string - color?: string folderId?: string | null } @@ -25,7 +24,7 @@ export function useCreateWorkflow() { return useMutation({ mutationFn: async (variables: CreateWorkflowVariables) => { - const { workspaceId, name, description, color, folderId } = variables + const { workspaceId, name, description, folderId } = variables logger.info(`Creating new workflow in workspace: ${workspaceId}`) const requestBody: Record = { @@ -34,9 +33,6 @@ export function useCreateWorkflow() { workspaceId, folderId: folderId || null, } - if (typeof color === 'string' && color.trim().length > 0) { - requestBody.color = color.trim() - } const createResponse = await fetch('/api/workflows', { method: 'POST', diff --git a/apps/tradinggoose/lib/copilot/access-policy.ts b/apps/tradinggoose/lib/copilot/access-policy.ts index 6eb379a84..441c60fd2 100644 --- a/apps/tradinggoose/lib/copilot/access-policy.ts +++ b/apps/tradinggoose/lib/copilot/access-policy.ts @@ -1,6 +1,6 @@ export type CopilotAccessLevel = 'limited' | 'full' -export function shouldAutoExecuteTool(accessLevel: CopilotAccessLevel): boolean { +function shouldAutoExecuteTool(accessLevel: CopilotAccessLevel): boolean { return accessLevel === 'full' } diff --git a/apps/tradinggoose/lib/copilot/agent/utils.test.ts b/apps/tradinggoose/lib/copilot/agent/utils.test.ts index 3e883dcfa..d0f7d9078 100644 --- a/apps/tradinggoose/lib/copilot/agent/utils.test.ts +++ b/apps/tradinggoose/lib/copilot/agent/utils.test.ts @@ -48,6 +48,7 @@ describe('requestCopilotTitle', () => { const title = await requestCopilotTitle({ message: 'Build a momentum screener with RSI filters', + userId: 'user-1', model: 'gpt-5.4', provider: 'openai', }) @@ -60,6 +61,7 @@ describe('requestCopilotTitle', () => { expect(init.headers).toEqual({ 'Content-Type': 'application/json', 'x-api-key': 'test-copilot-key', + 'x-copilot-user-id': 'user-1', }) const payload = JSON.parse(init.body) @@ -98,6 +100,7 @@ describe('requestCopilotTitle', () => { const title = await requestCopilotTitle({ message: 'Review the current skill implementation', + userId: 'user-1', model: 'claude-opus-4.6', }) diff --git a/apps/tradinggoose/lib/copilot/agent/utils.ts b/apps/tradinggoose/lib/copilot/agent/utils.ts index 2d674e9c5..a6db1bd48 100644 --- a/apps/tradinggoose/lib/copilot/agent/utils.ts +++ b/apps/tradinggoose/lib/copilot/agent/utils.ts @@ -14,10 +14,12 @@ const logger = createLogger('CopilotTitle') */ export async function requestCopilotTitle({ message, + userId, model, provider, }: { message: string + userId: string model?: string provider?: ProviderId }): Promise { @@ -43,6 +45,9 @@ export async function requestCopilotTitle({ }, ], }, + headers: { + 'x-copilot-user-id': userId, + }, }) if (!response.ok) { const errorText = await response.text().catch(() => '') diff --git a/apps/tradinggoose/lib/copilot/completion-usage-billing.ts b/apps/tradinggoose/lib/copilot/completion-usage-billing.ts new file mode 100644 index 000000000..9dee047e5 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/completion-usage-billing.ts @@ -0,0 +1,363 @@ +import { sql } from 'drizzle-orm' +import { z } from 'zod' +import { getPersonalEffectiveSubscription } from '@/lib/billing/core/subscription' +import { isBillingEnabledForRuntime } from '@/lib/billing/settings' +import { getTierCopilotCostMultiplier } from '@/lib/billing/tiers' +import { accrueUserUsageCost } from '@/lib/billing/usage-accrual' +import { resolveWorkflowBillingContext } from '@/lib/billing/workspace-billing' +import { commitCopilotUsageReservation } from '@/lib/copilot/usage-reservations' +import { isHosted } from '@/lib/environment' +import { createLogger } from '@/lib/logs/console/logger' +import { hasProcessedMessage, markMessageAsProcessed } from '@/lib/redis' +import { calculateCost } from '@/providers/ai/utils' + +const BILLING_EVENT_TTL_SECONDS = 60 * 60 * 24 * 30 +const DEFAULT_ESTIMATED_RESERVATION_USD = 1 +const logger = createLogger('CopilotUsageAPI') + +const CompletionUsageReportSchema = z.object({ + kind: z.literal('completion'), + model: z.string().min(1, 'model is required'), + usage: z.unknown(), + remoteModel: z.string().nullable().optional(), + completionId: z.string().min(1, 'completionId is required'), + workflowId: z.string().nullable().optional(), +}) + +interface TokenMetrics { + promptTokens: number + completionTokens: number + totalTokens: number +} + +export type UsageBillingResult = + | { + billed: true + duplicate: false + cost: number + tokens: number + model: string + } + | { + billed: false + duplicate: true + } + | { + billed: false + duplicate?: false + reason: 'billing_disabled' | 'no_token_metrics' | 'zero_cost' | 'ledger_not_found' + } + +function readNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + if (typeof value === 'string') { + const parsed = Number.parseFloat(value) + return Number.isFinite(parsed) ? parsed : undefined + } + return undefined +} + +function pickNumber(source: any, keys: string[]): number | undefined { + if (!source || typeof source !== 'object') return undefined + for (const key of keys) { + const candidate = readNumber(source[key]) + if (candidate !== undefined) { + return candidate + } + } + return undefined +} + +function extractTokenMetrics(usage: any): TokenMetrics | null { + const sources = [usage, usage?.tokenUsage, usage?.tokens, usage?.usageDetails] + + let promptTokens: number | undefined + let completionTokens: number | undefined + let totalTokens: number | undefined + + for (const src of sources) { + if (promptTokens === undefined) { + promptTokens = pickNumber(src, [ + 'prompt_tokens', + 'promptTokens', + 'input_tokens', + 'inputTokens', + 'prompt', + ]) + } + if (completionTokens === undefined) { + completionTokens = pickNumber(src, [ + 'completion_tokens', + 'completionTokens', + 'output_tokens', + 'outputTokens', + 'completion', + ]) + } + if (totalTokens === undefined) { + totalTokens = pickNumber(src, [ + 'total_tokens', + 'totalTokens', + 'tokens', + 'token_count', + 'total', + ]) + } + } + + if (totalTokens === undefined) { + totalTokens = readNumber(usage?.tokensUsed) ?? readNumber(usage?.usage) + } + + if (completionTokens === undefined) { + completionTokens = 0 + } + + if (totalTokens !== undefined && promptTokens === undefined) { + promptTokens = totalTokens - completionTokens + } + + if (promptTokens === undefined || totalTokens === undefined) { + return null + } + + const normalizedPrompt = Math.max(0, Math.round(promptTokens)) + const normalizedCompletion = Math.max(0, Math.round(completionTokens ?? 0)) + const normalizedTotal = Math.max( + 0, + Math.round(totalTokens ?? normalizedPrompt + normalizedCompletion) + ) + + if (normalizedTotal <= 0 || (normalizedPrompt === 0 && normalizedCompletion === 0)) { + return null + } + + return { + promptTokens: normalizedPrompt, + completionTokens: normalizedCompletion, + totalTokens: normalizedTotal, + } +} + +async function resolveEffectiveCopilotTier(params: { + userId: string + workflowId?: string +}): Promise<{ + effectiveTier: any + billingContext: Awaited> | null +}> { + const billingContext = params.workflowId + ? await resolveWorkflowBillingContext({ + workflowId: params.workflowId, + actorUserId: params.userId, + }) + : null + const effectiveTier = params.workflowId + ? (billingContext?.subscription?.tier ?? null) + : ((await getPersonalEffectiveSubscription(params.userId))?.tier ?? null) + + if (!effectiveTier) { + throw new Error( + params.workflowId + ? `No active workflow subscription tier found for billed copilot usage on workflow ${params.workflowId}` + : `No active personal subscription tier found for billed copilot usage for user ${params.userId}` + ) + } + + return { + effectiveTier, + billingContext, + } +} + +async function calculateCopilotCostUsd(params: { + userId: string + workflowId?: string + billingModel: string + promptTokens: number + completionTokens: number + fallbackUsd?: number +}): Promise<{ + costUsd: number + normalizedModel: string + billingContext: Awaited> | null +}> { + const normalizedModel = params.billingModel.trim().toLowerCase() + const costResult = calculateCost( + normalizedModel, + params.promptTokens, + params.completionTokens, + false + ) + const { effectiveTier, billingContext } = await resolveEffectiveCopilotTier({ + userId: params.userId, + workflowId: params.workflowId, + }) + const rawCostUsd = Number(costResult.total || 0) * getTierCopilotCostMultiplier(effectiveTier) + + return { + costUsd: rawCostUsd > 0 ? rawCostUsd : (params.fallbackUsd ?? 0), + normalizedModel, + billingContext, + } +} + +export async function calculateCopilotReservationUsdFromEstimate(params: { + userId: string + workflowId?: string + model: string + estimatedPromptTokens: number + reservedCompletionTokens: number +}): Promise { + const { costUsd } = await calculateCopilotCostUsd({ + userId: params.userId, + workflowId: params.workflowId, + billingModel: params.model, + promptTokens: params.estimatedPromptTokens, + completionTokens: params.reservedCompletionTokens, + fallbackUsd: DEFAULT_ESTIMATED_RESERVATION_USD, + }) + + return costUsd +} + +export async function recordCopilotCompletionUsage(params: { + userId: string + workflowId?: string + usage: any + billingModel: string + billingKeyId?: string | null +}): Promise { + const metrics = extractTokenMetrics(params.usage) + if (!metrics) { + logger.info('Skipping copilot billing - no token metrics available', { + billingKeyPrefix: 'copilot-completion-billing', + billingKeyId: params.billingKeyId, + reason: 'copilot_completion_usage', + }) + return { billed: false, reason: 'no_token_metrics' } + } + + const billingKey = params.billingKeyId + ? `copilot-completion-billing:${params.billingKeyId}` + : null + if (billingKey && (await hasProcessedMessage(billingKey))) { + logger.info('Copilot billing already processed', { + billingKey, + reason: 'copilot_completion_usage', + }) + return { billed: false, duplicate: true } + } + + const { + costUsd: costToAdd, + normalizedModel, + billingContext, + } = await calculateCopilotCostUsd({ + userId: params.userId, + workflowId: params.workflowId, + billingModel: params.billingModel, + promptTokens: metrics.promptTokens, + completionTokens: metrics.completionTokens, + }) + if (costToAdd <= 0) { + logger.info('Skipping copilot billing - calculated cost is zero', { + userId: params.userId, + workflowId: params.workflowId, + billingKeyId: params.billingKeyId, + model: normalizedModel, + reason: 'copilot_completion_usage', + }) + return { billed: false, reason: 'zero_cost' } + } + + const extraUpdates: Record = { + totalCopilotCost: sql`total_copilot_cost + ${costToAdd}`, + currentPeriodCopilotCost: sql`current_period_copilot_cost + ${costToAdd}`, + totalCopilotCalls: sql`total_copilot_calls + 1`, + } + + if (metrics.totalTokens > 0) { + extraUpdates.totalCopilotTokens = sql`total_copilot_tokens + ${metrics.totalTokens}` + } + + const didAccrue = await accrueUserUsageCost({ + userId: params.userId, + workflowId: params.workflowId, + cost: costToAdd, + extraUpdates, + reason: 'copilot_completion_usage', + }) + + if (!didAccrue) { + logger.warn('Copilot billing skipped - ledger record not found', { + userId: params.userId, + workflowId: params.workflowId, + billingKeyId: params.billingKeyId, + reason: 'copilot_completion_usage', + }) + return { billed: false, reason: 'ledger_not_found' } + } + + if (billingKey) { + await markMessageAsProcessed(billingKey, BILLING_EVENT_TTL_SECONDS) + } + + logger.info('Copilot billing recorded', { + userId: params.userId, + billingUserId: billingContext?.billingUserId ?? params.userId, + workflowId: params.workflowId, + billingKeyId: params.billingKeyId, + cost: costToAdd, + tokens: metrics.totalTokens, + model: normalizedModel, + reason: 'copilot_completion_usage', + }) + + return { + billed: true, + duplicate: false, + cost: costToAdd, + tokens: metrics.totalTokens, + model: normalizedModel, + } +} + +export async function mirrorLocalCopilotCompletionUsageReports(params: { + userId: string + reports: unknown[] +}): Promise { + if (isHosted || params.reports.length === 0) { + return + } + + if (!(await isBillingEnabledForRuntime())) { + return + } + + for (const report of params.reports) { + try { + const payload = CompletionUsageReportSchema.parse(report) + const billing = await commitCopilotUsageReservation({ + userId: params.userId, + workflowId: payload.workflowId ?? undefined, + operation: () => + recordCopilotCompletionUsage({ + userId: params.userId, + workflowId: payload.workflowId ?? undefined, + usage: payload.usage, + billingModel: payload.model, + billingKeyId: payload.completionId, + }), + }) + + if (!billing.billed && !billing.duplicate && billing.reason !== 'zero_cost') { + logger.warn('Local Copilot completion usage mirror skipped', { reason: billing.reason }) + } + } catch (error) { + logger.warn('Failed to mirror local Copilot completion usage report', { error }) + } + } +} diff --git a/apps/tradinggoose/lib/copilot/entity-documents.ts b/apps/tradinggoose/lib/copilot/entity-documents.ts index 055e2f920..a4042fcc3 100644 --- a/apps/tradinggoose/lib/copilot/entity-documents.ts +++ b/apps/tradinggoose/lib/copilot/entity-documents.ts @@ -35,7 +35,6 @@ const CustomToolDocumentSchema = z.object({ const IndicatorDocumentSchema = z.object({ name: z.string(), - color: z.string(), pineCode: z.string(), inputMeta: z.record(z.unknown()).nullable(), }) @@ -87,7 +86,6 @@ function normalizeEntityFields( case 'indicator': return { name: typeof source.name === 'string' ? source.name : '', - color: typeof source.color === 'string' ? source.color : '', pineCode: typeof source.pineCode === 'string' ? source.pineCode : '', inputMeta: source.inputMeta && diff --git a/apps/tradinggoose/lib/copilot/inline-tool-call.test.tsx b/apps/tradinggoose/lib/copilot/inline-tool-call.test.tsx index 725ac0aab..a6ccee09c 100644 --- a/apps/tradinggoose/lib/copilot/inline-tool-call.test.tsx +++ b/apps/tradinggoose/lib/copilot/inline-tool-call.test.tsx @@ -253,7 +253,7 @@ describe('InlineToolCall', () => { ) }) - it('shows review controls for staged workflow edits in full access without generic Allow', async () => { + it('shows review controls for already-staged workflow edits in full access', async () => { const toolCallId = 'tool-workflow-review' mockUseCopilotStoreState.accessLevel = 'full' mockGetToolInterruptDisplays.mockReturnValue({ @@ -299,7 +299,7 @@ describe('InlineToolCall', () => { expect(container.textContent).not.toContain('Allow') }) - it('renders entity review diffs and controls from staged tool results in full access', async () => { + it('renders entity review diffs with controls for already-staged reviews in full access', async () => { mockUseCopilotStoreState.accessLevel = 'full' mockGetToolInterruptDisplays.mockReturnValue({ accept: { text: 'Accept changes' }, diff --git a/apps/tradinggoose/lib/copilot/registry.ts b/apps/tradinggoose/lib/copilot/registry.ts index a6e61df58..f992da6c4 100644 --- a/apps/tradinggoose/lib/copilot/registry.ts +++ b/apps/tradinggoose/lib/copilot/registry.ts @@ -6,7 +6,10 @@ import { SKILL_DOCUMENT_FORMAT, } from '@/lib/copilot/entity-documents' import { MONITOR_DOCUMENT_FORMAT } from '@/lib/copilot/monitor/monitor-documents' -import { TG_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' +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, @@ -148,7 +151,6 @@ const CreateWorkflowArgs = z .object({ name: z.string().trim().min(1).optional(), description: z.string().optional(), - color: z.string().optional(), folderId: z.string().nullable().optional(), workspaceId: RequiredId.optional(), }) @@ -167,14 +169,19 @@ const EditWorkflowArgs = z .string() .min(1) .describe( - 'Complete raw `tg-mermaid-v1` Mermaid document for the entire workflow, not a partial patch. Preserve the canonical `%% TG_WORKFLOW`, `%% TG_BLOCK`, and `%% TG_EDGE` metadata returned by `read_workflow`; Studio validates that structure. Use this only for graph or topology changes such as adding, removing, reconnecting, or replacing blocks, loops, parallels, or condition branches.' + 'Minimal Mermaid flowchart for the entire workflow graph, not a partial patch. Include flowchart direction, existing block ids as node/subgraph ids, new block `id:` and `type:` labels, subgraph nesting, and edge arrows. Do not include `%% TG_*` metadata, subBlocks, outputs, enabled, positions, or full block metadata. Existing block ids are stable identities: their type and details are preserved by id, and supplied labels must match current block names. This tool cannot replace an existing block or change its type; new ids create new blocks with generated positions. Use edit_workflow_block for block internals.' + ), + removedBlockIds: z + .array(z.string().trim().min(1)) + .optional() + .describe( + 'Existing block root ids intentionally removed from the workflow graph. Removing a loop or parallel root removes its descendants.' ), - documentFormat: z.literal(TG_MERMAID_DOCUMENT_FORMAT).optional(), entityId: RequiredId, }) .strict() .describe( - "Full workflow document replacement tool. Do not use this to rename one existing block or patch one block's `enabled` or `subBlocks`; use `edit_workflow_block` instead." + "Full workflow topology rewrite tool using minimal Mermaid. Do not use this to replace an existing block, rename one existing block, or patch one block's `enabled` or `subBlocks`; use `edit_workflow_block` instead." ) const EditWorkflowBlockArgs = z @@ -591,6 +598,11 @@ const WorkflowDocumentEnvelope = WorkflowTargetEnvelope.extend({ entityDocument: z.string(), }) +const WorkflowGraphDocumentEnvelope = WorkflowTargetEnvelope.extend({ + documentFormat: z.literal(WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT), + entityDocument: z.string(), +}) + const WorkflowSummaryResult = z.object({ blocks: z.array( z.object({ @@ -640,7 +652,6 @@ const GenericEntityListEntry = z.object({ entityDescription: z.string().optional(), entityTitle: z.string().optional(), entityFunctionName: z.string().optional(), - entityColor: z.string().optional(), entityTransport: z.string().optional(), entityUrl: z.string().optional(), entityEnabled: z.boolean().optional(), @@ -656,7 +667,6 @@ const GenericEntityListResult = z.object({ const IndicatorListEntry = z.object({ name: z.string(), source: z.enum(['default', 'custom']), - color: z.string().optional(), editable: z.boolean(), callableInFunctionBlock: z.boolean(), inputTitles: z.array(z.string()).optional(), @@ -768,7 +778,7 @@ const WorkflowPreviewEdge = z.object({ targetHandle: z.string().optional(), }) -const BuildOrEditWorkflowResult = WorkflowDocumentEnvelope.extend({ +const WorkflowMutationResultShape = { workflowState: z.unknown().optional(), preview: z .object({ @@ -790,7 +800,10 @@ const BuildOrEditWorkflowResult = WorkflowDocumentEnvelope.extend({ edgesCount: z.number(), }) .optional(), -}) +} + +const EditWorkflowResult = WorkflowGraphDocumentEnvelope.extend(WorkflowMutationResultShape) +const EditWorkflowBlockResult = WorkflowDocumentEnvelope.extend(WorkflowMutationResultShape) const ExecutionEntry = z.object({ id: z.string(), @@ -841,8 +854,8 @@ export const ToolResultSchemas = { message: z.string().optional(), }), - edit_workflow: BuildOrEditWorkflowResult, - edit_workflow_block: BuildOrEditWorkflowResult, + edit_workflow: EditWorkflowResult, + edit_workflow_block: EditWorkflowBlockResult, rename_workflow: WorkflowMutationResult, run_workflow: z.object({ executionId: z.string().optional(), diff --git a/apps/tradinggoose/lib/copilot/runtime-tool-manifest-enrichment.ts b/apps/tradinggoose/lib/copilot/runtime-tool-manifest-enrichment.ts index 47b0522c6..dfe993cf2 100644 --- a/apps/tradinggoose/lib/copilot/runtime-tool-manifest-enrichment.ts +++ b/apps/tradinggoose/lib/copilot/runtime-tool-manifest-enrichment.ts @@ -12,7 +12,6 @@ import { MonitorDocumentSchema, } from '@/lib/copilot/monitor/monitor-documents' import type { RuntimeToolManifestSemanticValidator } from '@/lib/copilot/workflow-subblock-semantic-contracts' -import { TG_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' export type { RuntimeToolManifestSemanticValidator } from '@/lib/copilot/workflow-subblock-semantic-contracts' @@ -74,58 +73,6 @@ const JSON_DOCUMENT_SPECS: JsonDocumentSemanticSpec[] = [ }, ] -const TG_WORKFLOW_LINE_PREFIX = '%% TG_WORKFLOW ' -const TG_BLOCK_LINE_PREFIX = '%% TG_BLOCK ' -const TG_EDGE_LINE_PREFIX = '%% TG_EDGE ' - -const TG_WORKFLOW_METADATA_SCHEMA: Record = { - type: 'object', - required: ['version', 'direction'], - additionalProperties: true, - properties: { - version: { const: TG_MERMAID_DOCUMENT_FORMAT }, - direction: { enum: ['TD', 'LR'] }, - }, -} - -const TG_POSITION_SCHEMA: Record = { - type: 'object', - required: ['x', 'y'], - additionalProperties: true, - properties: { - x: { type: 'number' }, - y: { type: 'number' }, - }, -} - -const TG_BLOCK_SCHEMA: Record = { - type: 'object', - required: ['id', 'type', 'name', 'position', 'subBlocks', 'outputs', 'enabled'], - additionalProperties: true, - properties: { - id: { type: 'string' }, - type: { type: 'string' }, - name: { type: 'string' }, - position: TG_POSITION_SCHEMA, - subBlocks: { type: 'object' }, - outputs: { type: 'object' }, - enabled: { type: 'boolean' }, - }, -} - -const TG_EDGE_SCHEMA: Record = { - type: 'object', - required: ['source', 'target'], - additionalProperties: true, - properties: { - id: { type: 'string' }, - source: { type: 'string' }, - target: { type: 'string' }, - sourceHandle: { type: 'string' }, - targetHandle: { type: 'string' }, - }, -} - function getObjectPropertySchema( parameters: Record, propertyName: string @@ -151,87 +98,6 @@ function getConstStringValue(propertySchema: Record | null): st return null } -function buildWorkflowDocumentSemanticValidators( - documentField: string -): RuntimeToolManifestSemanticValidator[] { - return [ - { - path: documentField, - kind: 'string_requires_real_newlines', - description: - 'Use raw Mermaid text with real newlines; Studio validates workflow graph semantics.', - message: - 'Expected raw Mermaid text with real newline characters, not JSON-escaped `\\n` sequences.', - }, - { - path: documentField, - kind: 'string_starts_with', - args: { prefix: 'flowchart ' }, - description: - 'Start with a Mermaid `flowchart` declaration; Studio validates canonical workflow structure.', - message: 'Expected raw Mermaid text that starts with a `flowchart` declaration.', - }, - { - path: documentField, - kind: 'string_requires_line_prefix', - args: { prefix: TG_WORKFLOW_LINE_PREFIX, minMatches: 1 }, - description: 'Include a standalone canonical `%% TG_WORKFLOW {...}` metadata line.', - message: 'Workflow documents must include a standalone `%% TG_WORKFLOW {...}` metadata line.', - }, - { - path: documentField, - kind: 'string_requires_line_prefix', - args: { prefix: TG_BLOCK_LINE_PREFIX, minMatches: 1 }, - description: 'Include standalone canonical `%% TG_BLOCK {...}` metadata lines.', - message: 'Workflow documents must include standalone `%% TG_BLOCK {...}` metadata lines.', - }, - { - path: documentField, - kind: 'string_line_prefix_json_schema', - args: { prefix: TG_WORKFLOW_LINE_PREFIX, schema: TG_WORKFLOW_METADATA_SCHEMA }, - description: 'Validate each `TG_WORKFLOW` metadata JSON payload.', - message: - '`TG_WORKFLOW` metadata must be canonical JSON with `version: "tg-mermaid-v1"` and `direction` of `TD` or `LR`.', - }, - { - path: documentField, - kind: 'string_line_prefix_json_schema', - args: { prefix: TG_BLOCK_LINE_PREFIX, schema: TG_BLOCK_SCHEMA }, - description: 'Validate each `TG_BLOCK` metadata JSON payload.', - message: - '`TG_BLOCK` metadata must be canonical block state with `id`, `type`, `name`, `position`, `subBlocks`, `outputs`, and `enabled`.', - }, - { - path: documentField, - kind: 'string_line_prefix_json_schema', - args: { prefix: TG_EDGE_LINE_PREFIX, schema: TG_EDGE_SCHEMA }, - description: 'Validate each `TG_EDGE` metadata JSON payload when edge lines are present.', - message: '`TG_EDGE` metadata must be canonical edge state with string `source` and `target`.', - }, - { - path: documentField, - kind: 'string_forbids_substring', - args: { substring: '"blockType"' }, - description: 'Use canonical `TG_BLOCK.type`, not simplified block metadata aliases.', - message: 'Use `type` in `TG_BLOCK` metadata, not `blockType`.', - }, - { - path: documentField, - kind: 'string_forbids_substring', - args: { substring: '"blockName"' }, - description: 'Use canonical `TG_BLOCK.name`, not simplified block metadata aliases.', - message: 'Use `name` in `TG_BLOCK` metadata, not `blockName`.', - }, - { - path: documentField, - kind: 'string_forbids_substring', - args: { substring: '"blockDescription"' }, - description: 'Use canonical `TG_BLOCK` state, not simplified block metadata aliases.', - message: '`TG_BLOCK` metadata must not include `blockDescription`.', - }, - ] -} - function buildJsonDocumentSemanticValidators( documentField: string, spec: JsonDocumentSemanticSpec @@ -255,11 +121,6 @@ function buildJsonDocumentSemanticValidators( } const DOCUMENT_SEMANTIC_SPECS = [ - { - documentFormat: TG_MERMAID_DOCUMENT_FORMAT, - preferredDocumentField: 'entityDocument', - buildSemanticValidators: buildWorkflowDocumentSemanticValidators, - }, ...JSON_DOCUMENT_SPECS.map((spec) => ({ documentFormat: spec.documentFormat, preferredDocumentField: 'entityDocument', diff --git a/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts b/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts index 62bfed4d9..2cb512612 100644 --- a/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts +++ b/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts @@ -142,30 +142,19 @@ describe('copilot runtime tool manifest', () => { }), expect.objectContaining({ name: 'edit_workflow', - description: expect.stringContaining( - 'Do not use this for a single existing block `name`, `enabled`, or `subBlocks` change' - ), + description: expect.stringContaining('minimal Mermaid `entityDocument`'), kind: 'edit', entityKind: 'workflow', - semanticValidators: expect.arrayContaining([ - expect.objectContaining({ - path: 'entityDocument', - kind: 'string_requires_real_newlines', - description: expect.stringContaining('Studio validates workflow graph semantics'), - }), - expect.objectContaining({ - path: 'entityDocument', - kind: 'string_starts_with', - args: { prefix: 'flowchart ' }, - }), - ]), parameters: expect.objectContaining({ type: 'object', required: expect.arrayContaining(['entityId', 'entityDocument']), properties: expect.objectContaining({ entityId: expect.any(Object), entityDocument: expect.objectContaining({ - description: expect.stringContaining('%% TG_WORKFLOW'), + description: expect.stringContaining('Minimal Mermaid flowchart'), + }), + removedBlockIds: expect.objectContaining({ + description: expect.stringContaining('intentionally removed'), }), }), }), @@ -283,39 +272,30 @@ describe('copilot runtime tool manifest', () => { ) const editWorkflowValidators = manifest.tools.find((tool) => tool.name === 'edit_workflow')?.semanticValidators ?? [] - const workflowValidatorKinds = editWorkflowValidators.map((validator) => validator.kind) - expect(workflowValidatorKinds).toEqual( - expect.arrayContaining([ - 'string_requires_real_newlines', - 'string_starts_with', - 'string_requires_line_prefix', - 'string_line_prefix_json_schema', - 'string_forbids_substring', - ]) - ) - expect(workflowValidatorKinds).not.toContain('string_document_contract') - expect(editWorkflowValidators).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: 'string_requires_line_prefix', - args: { prefix: '%% TG_WORKFLOW ', minMatches: 1 }, - }), - expect.objectContaining({ - kind: 'string_requires_line_prefix', - args: { prefix: '%% TG_BLOCK ', minMatches: 1 }, - }), - expect.objectContaining({ - kind: 'string_line_prefix_json_schema', - args: expect.objectContaining({ prefix: '%% TG_EDGE ', schema: expect.any(Object) }), - }), - ]) - ) + expect(editWorkflowValidators.map((validator) => validator.kind)).toEqual([ + 'string_requires_real_newlines', + 'string_starts_with', + 'string_forbids_substring', + ]) const editWorkflowProperties = (manifest.tools.find((tool) => tool.name === 'edit_workflow')?.parameters?.properties as | Record | undefined) ?? {} + const createWorkflowProperties = + (manifest.tools.find((tool) => tool.name === 'create_workflow')?.parameters?.properties as + | Record + | undefined) ?? {} + const createIndicatorSchema = manifest.tools + .find((tool) => tool.name === 'create_indicator') + ?.semanticValidators?.find((validator) => validator.kind === 'string_json_schema')?.args + ?.schema as { properties?: Record; required?: string[] } | undefined + expect(createWorkflowProperties).not.toHaveProperty('color') + expect(createIndicatorSchema?.properties ?? {}).not.toHaveProperty('color') + expect(createIndicatorSchema?.required ?? []).not.toContain('color') expect(editWorkflowProperties).toHaveProperty('entityId') expect(editWorkflowProperties).toHaveProperty('entityDocument') + expect(editWorkflowProperties).toHaveProperty('removedBlockIds') + expect(editWorkflowProperties).not.toHaveProperty('documentFormat') expect(editWorkflowProperties).not.toHaveProperty('workflowId') expect(editWorkflowProperties).not.toHaveProperty('workflowDocument') expect( diff --git a/apps/tradinggoose/lib/copilot/runtime-tool-manifest.ts b/apps/tradinggoose/lib/copilot/runtime-tool-manifest.ts index 7aecaa1b2..e9d2428ec 100644 --- a/apps/tradinggoose/lib/copilot/runtime-tool-manifest.ts +++ b/apps/tradinggoose/lib/copilot/runtime-tool-manifest.ts @@ -42,11 +42,34 @@ const buildToolParameterSchema = (toolId: ToolId): Record => { } const TOOL_NAMES = ToolIds.options +const WORKFLOW_GRAPH_VALIDATORS: RuntimeToolManifestSemanticValidator[] = [ + { + path: 'entityDocument', + kind: 'string_requires_real_newlines', + message: 'Workflow graph Mermaid must be raw multi-line Mermaid text with real newlines.', + }, + { + path: 'entityDocument', + kind: 'string_starts_with', + args: { prefix: 'flowchart ' }, + message: 'Workflow graph Mermaid must start with `flowchart TD` or `flowchart LR`.', + }, + { + path: 'entityDocument', + kind: 'string_forbids_substring', + args: { substring: '%% TG_' }, + message: 'Workflow graph Mermaid must not include TG_* metadata comments.', + }, +] function getSemanticValidators( + toolName: ToolId, parameters: Record ): RuntimeToolManifestSemanticValidator[] | undefined { - const semanticValidators = buildAutomaticSemanticValidators(parameters) + const semanticValidators = + toolName === 'edit_workflow' + ? WORKFLOW_GRAPH_VALIDATORS + : buildAutomaticSemanticValidators(parameters) if (semanticValidators.length === 0) { return undefined @@ -60,7 +83,7 @@ export async function getCopilotRuntimeToolManifest(): Promise { const parameters = buildToolParameterSchema(toolName) - const semanticValidators = getSemanticValidators(parameters) + const semanticValidators = getSemanticValidators(toolName, parameters) return { name: toolName, diff --git a/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts b/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts index 7ef23c7ae..f0928a5b6 100644 --- a/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts +++ b/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts @@ -6,88 +6,92 @@ import { } from '@/lib/copilot/server-tool-errors' describe('copilot server tool errors', () => { - it('maps malformed workflow document errors to repairable 422 responses', () => { + it('returns container repair guidance for invalid canonical container edge handles', () => { const response = buildCopilotServerToolErrorResponse( 'edit_workflow', - new Error('Workflow document did not contain any TG_BLOCK entries') + new Error( + 'Invalid container edge: parallel1 container input requires targetHandle "target" for incoming outer edges.' + ) ) expect(response).toEqual({ status: 422, body: expect.objectContaining({ - code: 'invalid_workflow_document_missing_blocks', + code: 'invalid_workflow_document_container_edge', retryable: true, + issues: [ + { + path: 'entityDocument.edges', + message: + 'Invalid container edge: parallel1 container input requires targetHandle "target" for incoming outer edges.', + }, + ], }), }) - expect(response.body.error).toContain('standalone `%% TG_BLOCK') - expect(response.body.hint).toContain('Do not embed `TG_BLOCK` JSON inside node labels') + expect(response.body.hint).toContain('connect outer edges') }) - it('returns container and condition repair guidance for workflow edge mismatches', () => { + it('preserves embedded workflow sub-block paths in structured edit errors', () => { const response = buildCopilotServerToolErrorResponse( 'edit_workflow', new Error( - 'Workflow document edge metadata is inconsistent. Visible Mermaid connections and TG_EDGE payloads must resolve to the same logical workflow edges.' + 'Invalid edited workflow: Document contract is inconsistent: invalid block sub-block values for functionBlock.subBlocks.code.value (Expected valid raw TypeScript function-body code.).' ) ) expect(response).toEqual({ status: 422, body: expect.objectContaining({ - code: 'invalid_workflow_document_edge_mismatch', + code: 'invalid_workflow_state', retryable: true, + issues: [ + { + path: 'entityDocument.functionBlock.subBlocks.code.value', + message: 'Expected valid raw TypeScript function-body code.', + }, + ], }), }) - expect(response.body.hint).toContain('container subgraphs') - expect(response.body.hint).toContain('condition blocks') }) - it('returns container repair guidance for invalid canonical container edge handles', () => { + it('returns explicit removal guidance for omitted workflow blocks', () => { const response = buildCopilotServerToolErrorResponse( 'edit_workflow', new Error( - 'Invalid container edge: parallel1 container input requires targetHandle "target" for incoming outer edges.' + 'Invalid edited workflow: Existing block ids omitted from edit_workflow entityDocument without removedBlockIds: fn1.' ) ) expect(response).toEqual({ status: 422, body: expect.objectContaining({ - code: 'invalid_workflow_document_container_edge', + code: 'invalid_workflow_state', retryable: true, - issues: [ - { - path: 'entityDocument.edges', - message: - 'Invalid container edge: parallel1 container input requires targetHandle "target" for incoming outer edges.', - }, - ], }), }) - expect(response.body.hint).toContain('targetHandle "target"') + expect(response.body.hint).toContain('removedBlockIds') }) - it('preserves embedded workflow sub-block paths in structured edit errors', () => { + it('returns retryable graph-document guidance for malformed edit workflow Mermaid', () => { const response = buildCopilotServerToolErrorResponse( 'edit_workflow', - new Error( - 'Invalid edited workflow: Document contract is inconsistent: invalid block sub-block values for functionBlock.subBlocks.code.value (Expected valid raw TypeScript function-body code.).' - ) + new Error('Workflow graph Mermaid must start with `flowchart TD` or `flowchart LR`.') ) expect(response).toEqual({ status: 422, body: expect.objectContaining({ - code: 'invalid_workflow_state', + code: 'invalid_workflow_graph_document', retryable: true, issues: [ { - path: 'entityDocument.functionBlock.subBlocks.code.value', - message: 'Expected valid raw TypeScript function-body code.', + path: 'entityDocument', + message: 'Workflow graph Mermaid must start with `flowchart TD` or `flowchart LR`.', }, ], }), }) + expect(response.body.hint).toContain('minimal Mermaid graph') }) it('falls back to a generic 500 payload for unknown tool failures', () => { diff --git a/apps/tradinggoose/lib/copilot/server-tool-errors.ts b/apps/tradinggoose/lib/copilot/server-tool-errors.ts index 155a8d7c8..2fdf1ee90 100644 --- a/apps/tradinggoose/lib/copilot/server-tool-errors.ts +++ b/apps/tradinggoose/lib/copilot/server-tool-errors.ts @@ -76,78 +76,21 @@ function buildInvalidToolPayloadError( } function buildEditWorkflowError(message: string): CopilotServerToolErrorResponse | null { - if (message === 'Missing TG_WORKFLOW metadata') { - return { - status: 422, - body: { - code: 'invalid_workflow_document_missing_metadata', - error: 'Workflow document is missing a standalone `%% TG_WORKFLOW {...}` metadata line.', - hint: 'Send raw `tg-mermaid-v1` Mermaid text with real newlines, and keep `%% TG_WORKFLOW {...}` on its own line near the top of the document.', - retryable: true, - }, - } - } - - if (message === 'Workflow document did not contain any TG_BLOCK entries') { - return { - status: 422, - body: { - code: 'invalid_workflow_document_missing_blocks', - error: - 'Workflow document did not contain any standalone `%% TG_BLOCK {...}` block entries.', - hint: 'Emit canonical `%% TG_BLOCK {...}` comment lines for each block. Do not embed `TG_BLOCK` JSON inside node labels or send simplified block metadata.', - retryable: true, - }, - } - } - - if (message.startsWith('Invalid TG_BLOCK payload:')) { - return { - status: 422, - body: { - code: 'invalid_workflow_document_block_payload', - error: message, - hint: 'Each `TG_BLOCK` payload must be canonical workflow state with `id`, `type`, `name`, `position`, `subBlocks`, `outputs`, and `enabled`.', - retryable: true, - }, - } - } - - if (message.startsWith('Invalid TG_EDGE payload')) { - return { - status: 422, - body: { - code: 'invalid_workflow_document_edge_payload', - error: message, - hint: 'Each `TG_EDGE` payload must be a standalone JSON object with string `source` and `target` fields that matches the visible Mermaid connection.', - retryable: true, - }, - } - } - - if ( - message === - 'Workflow document contains Mermaid connection lines but no TG_EDGE entries. Every visible workflow connection must have a matching TG_EDGE payload.' - ) { - return { - status: 422, - body: { - code: 'invalid_workflow_document_missing_edge_metadata', - error: message, - hint: 'When the diagram shows visible Mermaid connections, include matching standalone `%% TG_EDGE {...}` lines for each connection.', - retryable: true, - }, - } - } + const isGraphDocumentError = + message.startsWith('Workflow graph Mermaid ') || + /^New workflow block ".+" is missing a type label\.$/.test(message) || + /^Unknown workflow block type ".+" for new block ".+"\.$/.test(message) || + message === 'entityDocument is required' - if (message.startsWith('Workflow document edge metadata is inconsistent.')) { + if (isGraphDocumentError) { return { status: 422, body: { - code: 'invalid_workflow_document_edge_mismatch', + code: 'invalid_workflow_graph_document', error: message, - hint: 'Keep the visible Mermaid connection lines and the canonical `%% TG_EDGE {...}` payloads in logical sync. Loop and parallel child blocks must stay inside their container subgraphs and cross container boundaries through the container handles, while condition blocks keep their diamond-and-branch structure.', + hint: 'Send a complete minimal Mermaid graph starting with `flowchart TD` or `flowchart LR`. Do not include TG_* metadata or block internals. Every new block needs `id:` and canonical `type:` labels from `get_available_blocks` or `get_blocks_metadata`.', retryable: true, + issues: [{ path: 'entityDocument', message }], }, } } @@ -158,7 +101,7 @@ function buildEditWorkflowError(message: string): CopilotServerToolErrorResponse body: { code: 'invalid_workflow_document_container_edge', error: message, - hint: 'For loop and parallel containers, incoming outer workflow edges must target the container block alias itself with targetHandle "target". Use Start nodes only as sources to child blocks, and End nodes only for child-to-container completion before leaving the container.', + hint: 'For loop and parallel containers, connect outer edges to the container node and internal edges to the generated start/end nodes.', retryable: true, issues: [{ path: 'entityDocument.edges', message }], }, @@ -184,11 +127,15 @@ function buildEditWorkflowError(message: string): CopilotServerToolErrorResponse const hint = details.includes('non-canonical sub-block') ? '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`. Keep `TG_BLOCK.type` unchanged unless you are intentionally replacing the block with another valid type.' + ? 'Use block types exactly as returned by `get_available_blocks` or `get_blocks_metadata`.' : details.includes('Edge references non-existent') - ? 'Every `TG_EDGE` source and target must match an existing `TG_BLOCK`, `TG_LOOP`, or `TG_PARALLEL` id in the same document.' - : 'Return a complete canonical workflow document that validates as workflow state. Preserve required block fields, canonical ids, and valid edge references.' + ? '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, diff --git a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts index 9435e9f68..d519c5de4 100644 --- a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts +++ b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts @@ -28,7 +28,7 @@ export const TOOL_PROMPT_METADATA: Record = { }, [CopilotTool.read_workflow]: { description: - 'Read a workflow by exact `entityId` and return Mermaid in `entityDocument`, plus `workflowSummary.blocks[].connections` counts and exact raw `workflowSummary.edges` with external/internal scope. 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`, 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.', kind: 'read', entityKind: 'workflow', }, @@ -40,7 +40,7 @@ export const TOOL_PROMPT_METADATA: Record = { }, edit_workflow: { description: - 'Replace the full workflow document using exact argument keys `entityId`, full `entityDocument`, and `documentFormat: tg-mermaid-v1`, then return the resulting workflow state. Use this only for graph or topology edits such as adding, removing, reconnecting, or replacing blocks, or changing loop, parallel, or condition structure. Do not use this for a single existing block `name`, `enabled`, or `subBlocks` change; use `edit_workflow_block` instead. If a full-document edit fails and the request only changes one existing block config, stop retrying `edit_workflow` and switch tools.', + 'Rewrite the full workflow graph topology using exact argument keys `entityId` and minimal Mermaid `entityDocument`, then return the resulting workflow state and graph-only Mermaid document. Use this only for graph or topology edits such as adding, deleting, reconnecting blocks, or changing loop/parallel nesting. Do not send `documentFormat`, `TG_BLOCK`, `TG_EDGE`, `subBlocks`, condition branch labels, `outputs`, `enabled`, positions, or full block metadata. Existing block ids are stable identities used directly as node/subgraph ids: their type and details are preserved by exact id, and supplied labels must match current block names. This tool cannot replace an existing block or change its type; new ids create new blocks with generated positions. New blocks need `id:` and canonical `type:` labels. Existing condition edges must use exact `condition--` source handles; use `edit_workflow_block` to define branches. If an existing block subtree is intentionally deleted, include the removed root id in `removedBlockIds`; otherwise every existing block id must remain in the Mermaid graph. Use `edit_workflow_block` for one existing block `name`, `enabled`, `subBlocks`, or condition branch definition change.', kind: 'edit', entityKind: 'workflow', }, 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 index 44126a3da..e3f20b34c 100644 --- 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 @@ -18,7 +18,6 @@ type EntityListEntry = { entityDescription?: string entityTitle?: string entityFunctionName?: string - entityColor?: string entityTransport?: string entityUrl?: string entityEnabled?: boolean @@ -28,7 +27,6 @@ type EntityListEntry = { export type CopilotIndicatorListEntry = { name: string source: 'default' | 'custom' - color?: string editable: boolean callableInFunctionBlock: boolean inputTitles?: string[] @@ -110,7 +108,6 @@ const ENTITY_API_CONFIG: Record = { extractList: (data) => (Array.isArray(data?.data) ? data.data : []), toFields: (item) => ({ name: item?.name ?? '', - color: item?.color ?? '', pineCode: item?.pineCode ?? '', inputMeta: item?.inputMeta && typeof item.inputMeta === 'object' && !Array.isArray(item.inputMeta) @@ -120,7 +117,6 @@ const ENTITY_API_CONFIG: Record = { toListEntry: (item) => ({ entityId: String(item?.id ?? ''), entityName: String(item?.name ?? ''), - entityColor: typeof item?.color === 'string' ? item.color : '', }), }, mcp_server: { @@ -196,9 +192,6 @@ function buildEntityCreateRequest( indicators: [ { name: fields.name, - ...(typeof fields.color === 'string' && fields.color.trim() - ? { color: fields.color.trim() } - : {}), pineCode: fields.pineCode, inputMeta: fields.inputMeta ?? undefined, }, @@ -385,7 +378,6 @@ export async function listCopilotIndicators( source, editable: item?.editable === true, callableInFunctionBlock: item?.callableInFunctionBlock === true, - ...(typeof item?.color === 'string' && item.color ? { color: item.color } : {}), ...(Array.isArray(item?.inputTitles) ? { inputTitles: item.inputTitles.filter( @@ -430,7 +422,6 @@ export async function readEntityFieldsFromContext( entityName: indicator.name, fields: { name: indicator.name, - color: '#3972F6', pineCode: indicator.pineCode, inputMeta: indicator.inputMeta ?? null, }, @@ -473,7 +464,6 @@ export function applyEntityFieldsToSession( break case 'indicator': setEntityField(session.doc, 'name', fields.name ?? '') - setEntityField(session.doc, 'color', fields.color ?? '') replaceEntityTextField(session.doc, 'pineCode', String(fields.pineCode ?? '')) setEntityField(session.doc, 'inputMeta', fields.inputMeta ?? null) break 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 index 9044dcdd7..a17bf5217 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/entities/entity-tools.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/entities/entity-tools.test.ts @@ -334,7 +334,6 @@ describe('entity document tools', () => { id: 'RSI', name: 'Relative Strength Index', source: 'default', - color: '#3972F6', editable: false, callableInFunctionBlock: true, inputTitles: ['Length'], @@ -344,7 +343,6 @@ describe('entity document tools', () => { id: 'indicator-1', name: 'My Custom Indicator', source: 'custom', - color: '#ff0000', editable: true, callableInFunctionBlock: false, inputTitles: ['Fast Length'], @@ -394,7 +392,6 @@ describe('entity document tools', () => { { name: 'Relative Strength Index', source: 'default', - color: '#3972F6', editable: false, callableInFunctionBlock: true, inputTitles: ['Length'], @@ -403,7 +400,6 @@ describe('entity document tools', () => { { name: 'My Custom Indicator', source: 'custom', - color: '#ff0000', editable: true, callableInFunctionBlock: false, inputTitles: ['Fast Length'], diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/create-workflow.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/create-workflow.ts index b876fa2eb..c6a4e1eab 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/create-workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/workflow/create-workflow.ts @@ -5,13 +5,12 @@ import { ClientToolCallState, } from '@/lib/copilot/tools/client/base-tool' import { createLogger } from '@/lib/logs/console/logger' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { getCopilotStoreForToolCall } from '@/stores/copilot/store-access' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' type CreateWorkflowArgs = { name?: string description?: string - color?: string folderId?: string | null workspaceId?: string } @@ -75,7 +74,6 @@ export class CreateWorkflowClientTool extends BaseClientTool { ...(typeof resolvedArgs?.description === 'string' ? { description: resolvedArgs.description } : {}), - ...(typeof resolvedArgs?.color === 'string' ? { color: resolvedArgs.color } : {}), ...(resolvedArgs?.folderId !== undefined ? { folderId: resolvedArgs.folderId } : {}), }) 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 index 545f65333..246555447 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.test.ts @@ -16,6 +16,11 @@ const workflowDocument = [ '%% 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 = {} @@ -26,16 +31,18 @@ vi.mock('@/lib/copilot/tools/client/workflow/workflow-review-tool-utils', () => workflowId, entityName, entityDocument, + documentFormat, }: { workflowId: string entityName?: string entityDocument: string + documentFormat?: string }) => ({ entityKind: 'workflow', entityId: workflowId, ...(entityName ? { entityName } : {}), entityDocument, - documentFormat: 'tg-mermaid-v1', + documentFormat: documentFormat ?? 'tg-mermaid-v1', }), })) @@ -104,10 +111,17 @@ describe('EditWorkflowClientTool approval gating', () => { }) it('stages workflow edits for review through the unified user-action handler', async () => { - const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => { + 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, @@ -130,7 +144,8 @@ describe('EditWorkflowClientTool approval gating', () => { loops: {}, parallels: {}, }, - entityDocument: workflowDocument, + entityDocument: editWorkflowDocument, + documentFormat: workflowGraphDocumentFormat, }, }), } @@ -160,7 +175,8 @@ describe('EditWorkflowClientTool approval gating', () => { await tool.handleUserAction({ entityId: 'wf-1', - entityDocument: workflowDocument, + entityDocument: editWorkflowDocument, + removedBlockIds: ['removed-1'], }) expect(tool.getState()).toBe(ClientToolCallState.review) @@ -230,7 +246,8 @@ describe('EditWorkflowClientTool approval gating', () => { loops: {}, parallels: {}, }, - entityDocument: workflowDocument, + entityDocument: editWorkflowDocument, + documentFormat: workflowGraphDocumentFormat, }, }), } @@ -252,7 +269,7 @@ describe('EditWorkflowClientTool approval gating', () => { await tool.handleUserAction({ entityId: 'wf-1', - entityDocument: workflowDocument, + entityDocument: editWorkflowDocument, }) expect(tool.getState()).toBe(ClientToolCallState.review) @@ -289,7 +306,8 @@ describe('EditWorkflowClientTool approval gating', () => { success: true, result: { workflowState: nextWorkflowState, - entityDocument: workflowDocument, + entityDocument: editWorkflowDocument, + documentFormat: workflowGraphDocumentFormat, }, }), } @@ -319,7 +337,7 @@ describe('EditWorkflowClientTool approval gating', () => { await tool.execute({ entityId: 'wf-1', - entityDocument: workflowDocument, + entityDocument: editWorkflowDocument, }) await tool.handleAccept() @@ -342,8 +360,8 @@ describe('EditWorkflowClientTool approval gating', () => { expect(markCompleteBody.data).toMatchObject({ entityKind: 'workflow', entityId: 'wf-1', - entityDocument: workflowDocument, - documentFormat: 'tg-mermaid-v1', + entityDocument: editWorkflowDocument, + documentFormat: workflowGraphDocumentFormat, }) }) @@ -374,7 +392,7 @@ describe('EditWorkflowClientTool approval gating', () => { }) await tool.execute({ - entityDocument: workflowDocument, + entityDocument: editWorkflowDocument, }) expect(tool.getState()).toBe(ClientToolCallState.error) @@ -415,7 +433,7 @@ describe('EditWorkflowClientTool approval gating', () => { state: ClientToolCallState.review, params: { entityId: 'wf-target', - entityDocument: workflowDocument, + entityDocument: editWorkflowDocument, }, result: { entityId: 'wf-target', diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.ts index 10b12ea2e..de6b9c0b3 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.ts @@ -22,7 +22,7 @@ import { getCopilotStoreForToolCall } from '@/stores/copilot/store-access' interface EditWorkflowArgs { entityDocument: string - documentFormat?: string + removedBlockIds?: string[] entityId?: string } @@ -147,7 +147,7 @@ export class EditWorkflowClientTool extends StagedReviewClientTool ({ entityKind: 'workflow', entityId: 'workflow-123', - entityDocument: 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', - documentFormat: TG_MERMAID_DOCUMENT_FORMAT, + entityDocument: 'flowchart TD\n n1["Input
id: input1
type: input_trigger"]', + documentFormat: WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT, workflowState: { blocks: {} }, })) const readWorkflowLogsExecute = vi.fn(async () => ({ entries: [] })) @@ -329,8 +332,7 @@ describe('routeExecution', () => { it('preserves workflow edit entity fields when routing workflow tools', async () => { const payload = { - entityDocument: 'flowchart TD\n%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', - documentFormat: TG_MERMAID_DOCUMENT_FORMAT, + entityDocument: 'flowchart TD\n n1["Input
id: input1
type: input_trigger"]', entityId: 'workflow-123', currentWorkflowState: '{"blocks":{}}', } @@ -339,7 +341,7 @@ describe('routeExecution', () => { entityKind: 'workflow', entityId: 'workflow-123', entityDocument: expect.any(String), - documentFormat: TG_MERMAID_DOCUMENT_FORMAT, + documentFormat: WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT, }) expect(editWorkflowExecute).toHaveBeenCalledWith(payload, undefined) diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.test.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.test.ts index b2c25b049..32b928fc0 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' vi.mock('@/lib/workflows/validation', () => ({ validateWorkflowState: (state: any) => ({ @@ -9,7 +10,8 @@ vi.mock('@/lib/workflows/validation', () => ({ }), })) -const INPUT_TRIGGER_CURRENT_WORKFLOW_STATE = JSON.stringify({ +const BASE_WORKFLOW_STATE = { + direction: 'TD', blocks: { input1: { id: 'input1', @@ -26,77 +28,104 @@ const INPUT_TRIGGER_CURRENT_WORKFLOW_STATE = JSON.stringify({ }, outputs: {}, }, + fn1: { + id: 'fn1', + type: 'function', + name: 'Compute Indicators', + position: { x: 0, y: 240 }, + enabled: true, + subBlocks: { + code: { + id: 'code', + type: 'code', + value: 'return { ok: true }', + }, + }, + outputs: {}, + }, }, edges: [], loops: {}, parallels: {}, -}) +} -function buildInputTriggerWorkflowDocument(subBlocks: Record): string { - return [ - 'flowchart TD', - '%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', - [ - '%% TG_BLOCK ', - JSON.stringify({ - id: 'input1', - type: 'input_trigger', - name: 'Input Form', - position: { x: 0, y: 0 }, - enabled: true, - subBlocks, - outputs: {}, - }), - ].join(''), - ].join('\n') +function graph(lines: string[]): string { + return lines.join('\n') } describe('editWorkflowServerTool', () => { - it( - 'does not persist canonical side effects while preparing a workflow edit proposal', - { timeout: 10_000 }, - async () => { - const { editWorkflowServerTool } = await import( - '@/lib/copilot/tools/server/workflow/edit-workflow' - ) + it('connects existing blocks without rewriting block internals', async () => { + const { editWorkflowServerTool } = await import( + '@/lib/copilot/tools/server/workflow/edit-workflow' + ) - const result = await editWorkflowServerTool.execute( + const result = await editWorkflowServerTool.execute( + { + entityId: 'wf-1', + entityDocument: graph([ + 'flowchart TD', + ' n1["Input Form
id: input1
type: input_trigger"]', + ' n2["Compute Indicators
id: fn1
type: function"]', + ' n1 --> n2', + ]), + currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), + }, + { userId: 'user-1' } + ) + + expect(result.workflowState.blocks.fn1.name).toBe('Compute Indicators') + expect(result.workflowState.blocks.fn1.subBlocks.code.value).toBe('return { ok: true }') + expect(result.workflowState.edges).toEqual([ + expect.objectContaining({ + id: 'input1-source-fn1-target', + source: 'input1', + target: 'fn1', + }), + ]) + expect(result.documentFormat).toBe(WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT) + expect(result.entityDocument).not.toContain('%% TG_') + expect(result.entityDocument).toContain('Compute Indicators') + }) + + it('rejects existing block label renames instead of ignoring them', async () => { + const { editWorkflowServerTool } = await import( + '@/lib/copilot/tools/server/workflow/edit-workflow' + ) + + await expect( + editWorkflowServerTool.execute( { entityId: 'wf-1', - entityDocument: [ + entityDocument: graph([ 'flowchart TD', - '%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', - '%% TG_BLOCK {"id":"block-1","type":"input_trigger","name":"Edited Trigger","position":{"x":0,"y":0},"subBlocks":{},"outputs":{},"enabled":true}', - ].join('\n'), - currentWorkflowState: JSON.stringify({ - blocks: { - 'block-1': { - id: 'block-1', - type: 'input_trigger', - name: 'Trigger', - position: { x: 0, y: 0 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - }), + ' n1["Input Form
id: input1
type: input_trigger"]', + ' n2["Compute
id: fn1
type: function"]', + ' n1 --> n2', + ]), + currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, { userId: 'user-1' } ) + ).rejects.toThrow('Use edit_workflow_block to rename existing blocks.') - expect(result.entityKind).toBe('workflow') - expect(result.entityId).toBe('wf-1') - expect(result.workflowState.blocks['block-1'].name).toBe('Edited Trigger') - expect(result.documentFormat).toBe('tg-mermaid-v1') - expect(result.entityDocument).toContain('TG_BLOCK') - } - ) + await expect( + editWorkflowServerTool.execute( + { + entityId: 'wf-1', + entityDocument: graph([ + 'flowchart TD', + ' input1["Input Form"]', + ' fn1["Compute"]', + ' input1 --> fn1', + ]), + currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), + }, + { userId: 'user-1' } + ) + ).rejects.toThrow('Use edit_workflow_block to rename existing blocks.') + }) - it('rejects non-canonical TG_BLOCK metadata aliases', async () => { + it('rejects existing block type changes instead of treating them as replacements', async () => { const { editWorkflowServerTool } = await import( '@/lib/copilot/tools/server/workflow/edit-workflow' ) @@ -105,36 +134,148 @@ describe('editWorkflowServerTool', () => { editWorkflowServerTool.execute( { entityId: 'wf-1', - entityDocument: [ + entityDocument: graph([ 'flowchart TD', - '%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', - '%% TG_BLOCK {"id":"block-1","blockType":"input_trigger","blockName":"Edited Trigger","blockDescription":"ignored","position":{"x":0,"y":0},"subBlocks":{},"outputs":{},"enabled":true}', - ].join('\n'), - currentWorkflowState: JSON.stringify({ - blocks: { - 'block-1': { - id: 'block-1', - type: 'input_trigger', - name: 'Trigger', - position: { x: 0, y: 0 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, + ' n1["Input Form
id: input1
type: input_trigger"]', + ' n2["Compute
id: fn1
type: agent"]', + ' n1 --> n2', + ]), + currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), + }, + { userId: 'user-1' } + ) + ).rejects.toThrow( + 'Existing block ids are immutable identities in edit_workflow; this tool cannot replace an existing block or change its type.' + ) + }) + + it('adds new blocks with canonical block defaults from metadata-only labels', async () => { + const { editWorkflowServerTool } = await import( + '@/lib/copilot/tools/server/workflow/edit-workflow' + ) + + const result = await editWorkflowServerTool.execute( + { + entityId: 'wf-1', + entityDocument: graph([ + 'flowchart TD', + ' n1["Input Form
id: input1
type: input_trigger"]', + ' n2["id: fn2
type: function"]', + ' n1 --> n2', + ]), + currentWorkflowState: JSON.stringify({ + ...BASE_WORKFLOW_STATE, + blocks: { input1: BASE_WORKFLOW_STATE.blocks.input1 }, + }), + }, + { userId: 'user-1' } + ) + + expect(result.workflowState.blocks.fn2).toMatchObject({ + id: 'fn2', + type: 'function', + name: 'Mock Function', + enabled: true, + }) + expect(result.workflowState.blocks.fn2.subBlocks.code).toMatchObject({ + id: 'code', + type: 'code', + value: '', + }) + expect(result.documentFormat).toBe(WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT) + expect(result.entityDocument).toContain('Mock Function') + expect(result.entityDocument).not.toContain('["id: fn2') + expect(result.preview.blockDiff.added).toEqual(['fn2']) + }) + + it('places new blocks after existing siblings regardless of Mermaid order', async () => { + const { editWorkflowServerTool } = await import( + '@/lib/copilot/tools/server/workflow/edit-workflow' + ) + + const result = await editWorkflowServerTool.execute( + { + entityId: 'wf-1', + entityDocument: graph([ + 'flowchart TD', + ' n2["id: fn2
type: function"]', + ' n1["Input Form
id: input1
type: input_trigger"]', + ' n3["Compute Indicators
id: fn1
type: function"]', + ]), + currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), + }, + { userId: 'user-1' } + ) + + expect(result.workflowState.blocks.fn2.position).toEqual({ x: 0, y: 360 }) + }) + + it('preserves existing block absolute position when moving into a container', async () => { + const { editWorkflowServerTool } = await import( + '@/lib/copilot/tools/server/workflow/edit-workflow' + ) + + const result = await editWorkflowServerTool.execute( + { + entityId: 'wf-1', + entityDocument: graph([ + 'flowchart LR', + ' subgraph sg_loop1["Loop
id: loop1
type: loop"]', + ' 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: {}, }, - edges: [], - loops: {}, - parallels: {}, - }), + }, + }), + }, + { userId: 'user-1' } + ) + + expect(result.workflowState.blocks.fn1.data).toMatchObject({ + parentId: 'loop1', + extent: 'parent', + }) + expect(result.workflowState.blocks.fn1.position).toEqual({ x: 320, y: 160 }) + }) + + it('rejects block-internal fields in graph-only workflow edits', async () => { + const { editWorkflowServerTool } = await import( + '@/lib/copilot/tools/server/workflow/edit-workflow' + ) + + await expect( + editWorkflowServerTool.execute( + { + entityId: 'wf-1', + entityDocument: graph([ + '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' } ) ).rejects.toThrow( - 'Invalid TG_BLOCK payload: expected object with string id and string type. Workflow documents use `type`, not `blockType`.' + 'Workflow graph Mermaid block "input1" includes block-internal fields (enabled, outputs, data.foo, subBlocks.code).' ) }) - it('rejects external TG_EDGE metadata that targets a parallel end handle', async () => { + it('rejects omitted existing blocks without explicit removedBlockIds', async () => { const { editWorkflowServerTool } = await import( '@/lib/copilot/tools/server/workflow/edit-workflow' ) @@ -143,62 +284,20 @@ describe('editWorkflowServerTool', () => { editWorkflowServerTool.execute( { entityId: 'wf-1', - entityDocument: [ + entityDocument: graph([ 'flowchart TD', - '%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"TD"}', - 'inputTrigger["Input Form
id: inputTrigger
type: input_trigger
enabled: true"]', - 'subgraph sg_parallel1["Parallel Research
id: parallel1
type: parallel
enabled: true"]', - ' parallel1__parallel_start["Parallel Start"]', - ' parallel1__parallel_end["Parallel End"]', - 'end', - 'inputTrigger --> parallel1', - '%% TG_BLOCK {"id":"inputTrigger","type":"input_trigger","name":"Input Form","position":{"x":0,"y":0},"subBlocks":{},"outputs":{},"enabled":true}', - '%% TG_BLOCK {"id":"parallel1","type":"parallel","name":"Parallel Research","position":{"x":240,"y":0},"subBlocks":{},"outputs":{},"enabled":true}', - '%% TG_EDGE {"source":"inputTrigger","target":"parallel1","targetHandle":"parallel-end-target"}', - '%% TG_PARALLEL {"id":"parallel1","nodes":[],"count":2,"parallelType":"count"}', - ].join('\n'), - currentWorkflowState: JSON.stringify({ - direction: 'TD', - blocks: { - inputTrigger: { - id: 'inputTrigger', - type: 'input_trigger', - name: 'Input Form', - position: { x: 0, y: 0 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - parallel1: { - id: 'parallel1', - type: 'parallel', - name: 'Parallel Research', - position: { x: 240, y: 0 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: { - parallel1: { - id: 'parallel1', - nodes: [], - count: 2, - parallelType: 'count', - }, - }, - }), + ' n1["Input Form
id: input1
type: input_trigger"]', + ]), + currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, { userId: 'user-1' } ) ).rejects.toThrow( - 'Invalid container edge: parallel1 container input requires targetHandle "target" for incoming outer edges.' + 'Existing block ids omitted from edit_workflow entityDocument without removedBlockIds: fn1' ) }) - it('re-lays out staged workflow state to match LR Mermaid direction before review', async () => { + it('removes omitted blocks only when removedBlockIds declares intent', async () => { const { editWorkflowServerTool } = await import( '@/lib/copilot/tools/server/workflow/edit-workflow' ) @@ -206,63 +305,38 @@ describe('editWorkflowServerTool', () => { const result = await editWorkflowServerTool.execute( { entityId: 'wf-1', - entityDocument: [ - 'flowchart LR', - '%% TG_WORKFLOW {"version":"tg-mermaid-v1","direction":"LR"}', - 'inputTrigger(["Input Trigger"])', - '%% TG_BLOCK {"id":"inputTrigger","type":"input_trigger","name":"Input Trigger","position":{"x":0,"y":0},"subBlocks":{},"outputs":{},"enabled":true}', - 'agentBlock(["Agent"])', - '%% TG_BLOCK {"id":"agentBlock","type":"agent","name":"Agent","position":{"x":0,"y":280},"subBlocks":{},"outputs":{},"enabled":true}', - 'inputTrigger --> agentBlock', - '%% TG_EDGE {"source":"inputTrigger","target":"agentBlock"}', - ].join('\n'), + entityDocument: graph(['flowchart TD', 'input1["Input Form"]']), + removedBlockIds: ['loop1'], currentWorkflowState: JSON.stringify({ - direction: 'TD', + ...BASE_WORKFLOW_STATE, blocks: { - inputTrigger: { - id: 'inputTrigger', - type: 'input_trigger', - name: 'Input Trigger', - position: { x: 0, y: 0 }, - subBlocks: {}, - outputs: {}, + input1: BASE_WORKFLOW_STATE.blocks.input1, + loop1: { + id: 'loop1', + type: 'loop', + name: 'Loop', + position: { x: 100, y: 100 }, enabled: true, - }, - agentBlock: { - id: 'agentBlock', - type: 'agent', - name: 'Agent', - position: { x: 0, y: 280 }, subBlocks: {}, outputs: {}, - enabled: true, }, - }, - edges: [ - { - id: 'inputTrigger-source-agentBlock-target', - source: 'inputTrigger', - target: 'agentBlock', + fn1: { + ...BASE_WORKFLOW_STATE.blocks.fn1, + data: { parentId: 'loop1', extent: 'parent' }, }, - ], - loops: {}, - parallels: {}, + }, }), }, { userId: 'user-1' } ) - expect(result.workflowState.direction).toBe('LR') - expect(result.workflowState.blocks.agentBlock.position.x).toBeGreaterThan( - result.workflowState.blocks.inputTrigger.position.x - ) - expect(result.entityDocument).toContain('flowchart LR') - expect(result.preview.warnings).toContain( - 'Re-laid out workflow blocks to match Mermaid direction LR.' - ) + expect(result.workflowState.blocks).toHaveProperty('input1') + expect(result.workflowState.blocks).not.toHaveProperty('loop1') + expect(result.workflowState.blocks).not.toHaveProperty('fn1') + expect(result.workflowState.edges).toEqual([]) }) - it('rejects input-trigger edits that invent inputSchema instead of inputFormat', async () => { + it('rejects removedBlockIds that still appear in the graph', async () => { const { editWorkflowServerTool } = await import( '@/lib/copilot/tools/server/workflow/edit-workflow' ) @@ -271,39 +345,20 @@ describe('editWorkflowServerTool', () => { editWorkflowServerTool.execute( { entityId: 'wf-1', - entityDocument: buildInputTriggerWorkflowDocument({ - inputSchema: { - id: 'inputSchema', - type: 'short_text', - value: JSON.stringify({ - type: 'object', - properties: { - ticker: { type: 'string' }, - trade_date: { type: 'string' }, - }, - }), - }, - ticker: { - id: 'ticker', - type: 'short_text', - value: 'AAPL', - }, - trade_date: { - id: 'trade_date', - type: 'short_text', - value: '2026-04-17', - }, - }), - currentWorkflowState: INPUT_TRIGGER_CURRENT_WORKFLOW_STATE, + entityDocument: graph([ + 'flowchart TD', + ' n1["Input Form
id: input1
type: input_trigger"]', + ' n2["Compute
id: fn1
type: function"]', + ]), + removedBlockIds: ['fn1'], + currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, { userId: 'user-1' } ) - ).rejects.toThrow( - 'Block Input Form: non-canonical sub-block "inputSchema" is not part of the input_trigger block config.' - ) + ).rejects.toThrow('removedBlockIds still appear in edit_workflow entityDocument: fn1') }) - it('rejects newly introduced non-canonical sub-block ids for known block configs', async () => { + it('rejects old TG metadata comments in mutation input', async () => { const { editWorkflowServerTool } = await import( '@/lib/copilot/tools/server/workflow/edit-workflow' ) @@ -312,19 +367,15 @@ describe('editWorkflowServerTool', () => { editWorkflowServerTool.execute( { entityId: 'wf-1', - entityDocument: buildInputTriggerWorkflowDocument({ - ticker: { - id: 'ticker', - type: 'short_text', - value: 'AAPL', - }, - }), - currentWorkflowState: INPUT_TRIGGER_CURRENT_WORKFLOW_STATE, + entityDocument: graph([ + 'flowchart TD', + '%% 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' } ) - ).rejects.toThrow( - 'Block Input Form: non-canonical sub-block "ticker" is not part of the input_trigger block config.' - ) + ).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 43bc3b805..ef63d8d94 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.ts @@ -1,25 +1,246 @@ import { requireCopilotEntityId } from '@/lib/copilot/tools/entity-target' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { createLogger } from '@/lib/logs/console/logger' -import { - parseTgMermaidToWorkflow, - TG_MERMAID_DOCUMENT_FORMAT, -} from '@/lib/workflows/studio-workflow-mermaid' -import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { resolveBlockRuntimeState } from '@/lib/workflows/block-outputs' +import { WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' +import { parseGraphOnlyWorkflowMermaid } from '@/lib/workflows/studio-workflow-mermaid' +import { buildInitialSubBlockStates } from '@/lib/workflows/subblock-values' +import { getAbsoluteBlockPosition } from '@/lib/workflows/workflow-direction' +import { createWorkflowSnapshot, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' +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' interface EditWorkflowParams { entityId: string entityDocument: string - documentFormat?: string + removedBlockIds?: string[] currentWorkflowState: string } +function buildStableEdgeId(edge: { + source: string + target: string + sourceHandle?: string | null + targetHandle?: string | null +}): string { + const sourceHandle = + !edge.sourceHandle || edge.sourceHandle === 'source' || edge.sourceHandle === 'output' + ? 'source' + : edge.sourceHandle + const targetHandle = + !edge.targetHandle || edge.targetHandle === 'target' || edge.targetHandle === 'input' + ? 'target' + : edge.targetHandle + + return `${edge.source}-${sourceHandle}-${edge.target}-${targetHandle}` +} + +function createInitialPositionAllocator( + graphBlocks: Array<{ blockId: string; parentId?: string }>, + baseBlocks: Record +): (parentId?: string) => Position { + const siblingCounts = new Map() + for (const graphBlock of graphBlocks) { + if (!baseBlocks[graphBlock.blockId]) continue + siblingCounts.set(graphBlock.parentId, (siblingCounts.get(graphBlock.parentId) ?? 0) + 1) + } + + return (parentId?: string) => { + const siblingCount = siblingCounts.get(parentId) ?? 0 + siblingCounts.set(parentId, siblingCount + 1) + return parentId ? { x: 120, y: siblingCount * 180 } : { x: 0, y: siblingCount * 180 } + } +} + +function buildDefaultBlock( + blockId: string, + blockType: string, + getInitialPosition: (parentId?: string) => Position, + parentId?: string, + name?: string +): BlockState { + const blockConfig = getBlock(blockType) + const data = parentId ? { parentId, extent: 'parent' as const } : undefined + + if (!blockConfig && blockType !== 'loop' && blockType !== 'parallel') { + throw new Error(`Unknown workflow block type "${blockType}" for new block "${blockId}".`) + } + + if (!blockConfig) { + return { + id: blockId, + type: blockType, + name: name?.trim() || (blockType === 'loop' ? 'Loop' : 'Parallel'), + position: getInitialPosition(parentId), + subBlocks: {}, + outputs: {}, + enabled: true, + ...(data ? { data } : {}), + } + } + + const initialSubBlocks = buildInitialSubBlockStates( + blockConfig.subBlocks + ) as BlockState['subBlocks'] + const runtimeState = resolveBlockRuntimeState({ + blockType, + blockConfig, + subBlocks: initialSubBlocks, + triggerMode: false, + }) + + return { + id: blockId, + type: blockType, + name: name?.trim() || blockConfig.name, + position: getInitialPosition(parentId), + subBlocks: runtimeState.subBlocks as BlockState['subBlocks'], + outputs: runtimeState.outputs, + enabled: true, + ...(data ? { data } : {}), + } +} + +function setParent( + block: BlockState, + parentId: string | undefined, + blocks: Record, + baseBlocks: Record +): BlockState { + const nextPosition = + block.data?.parentId === parentId + ? block.position + : (() => { + const absolutePosition = getAbsoluteBlockPosition(block.id, baseBlocks) + if (!parentId) return absolutePosition + const parentPosition = getAbsoluteBlockPosition(parentId, blocks) + return { + x: absolutePosition.x - parentPosition.x, + y: absolutePosition.y - parentPosition.y, + } + })() + + const nextData = parentId + ? { ...(block.data ?? {}), parentId, extent: 'parent' as const } + : (() => { + const { parentId: _parentId, extent: _extent, ...data } = block.data ?? {} + return data + })() + + if (Object.keys(nextData).length === 0) { + const { data: _data, ...blockWithoutData } = block + return { ...blockWithoutData, position: nextPosition } + } + return { ...block, position: nextPosition, data: nextData } +} + +function applyGraphMermaidToWorkflow( + baseWorkflowState: WorkflowSnapshot, + entityDocument: string, + removedBlockIds: string[] = [] +): WorkflowSnapshot & { direction: 'TD' | 'LR' } { + const graph = parseGraphOnlyWorkflowMermaid(entityDocument, baseWorkflowState.blocks ?? {}) + const blocks: Record = {} + const explicitRemovedBlockIds = new Set(removedBlockIds) + for (let expanded = true; expanded; ) { + expanded = false + for (const [blockId, block] of Object.entries(baseWorkflowState.blocks ?? {})) { + const parentId = block.data?.parentId + if ( + !explicitRemovedBlockIds.has(blockId) && + parentId && + explicitRemovedBlockIds.has(parentId) + ) { + explicitRemovedBlockIds.add(blockId) + expanded = true + } + } + } + const graphBlockIds = new Set(graph.blocks.map((block) => block.blockId)) + const omittedExistingBlockIds = Object.keys(baseWorkflowState.blocks ?? {}).filter( + (blockId) => !graphBlockIds.has(blockId) + ) + const missingRemovalIntents = omittedExistingBlockIds.filter( + (blockId) => !explicitRemovedBlockIds.has(blockId) + ) + + if (missingRemovalIntents.length > 0) { + throw new Error( + `Invalid edited workflow: Existing block ids omitted from edit_workflow entityDocument without removedBlockIds: ${missingRemovalIntents.join(', ')}.` + ) + } + + const stillPresentRemovedBlockIds = [...explicitRemovedBlockIds].filter((blockId) => + graphBlockIds.has(blockId) + ) + if (stillPresentRemovedBlockIds.length > 0) { + throw new Error( + `Invalid edited workflow: removedBlockIds still appear in edit_workflow entityDocument: ${stillPresentRemovedBlockIds.join(', ')}.` + ) + } + + const getInitialPosition = createInitialPositionAllocator( + graph.blocks, + baseWorkflowState.blocks ?? {} + ) + + for (const graphBlock of graph.blocks) { + const existingBlock = baseWorkflowState.blocks?.[graphBlock.blockId] + if (existingBlock) { + if (graphBlock.blockType && graphBlock.blockType !== existingBlock.type) { + throw new Error( + `Invalid edited workflow: Existing block "${graphBlock.blockId}" has type "${existingBlock.type}" but entityDocument declares type "${graphBlock.blockType}". Existing block ids are immutable identities in edit_workflow; this tool cannot replace an existing block or change its type.` + ) + } + if (graphBlock.name && graphBlock.name.trim() !== existingBlock.name) { + throw new Error( + `Invalid edited workflow: Existing block "${graphBlock.blockId}" has name "${existingBlock.name}" but entityDocument declares name "${graphBlock.name}". Use edit_workflow_block to rename existing blocks.` + ) + } + blocks[graphBlock.blockId] = setParent( + existingBlock, + graphBlock.parentId, + blocks, + baseWorkflowState.blocks ?? {} + ) + continue + } + if (!graphBlock.blockType) { + throw new Error(`New workflow block "${graphBlock.blockId}" is missing a type label.`) + } + blocks[graphBlock.blockId] = buildDefaultBlock( + graphBlock.blockId, + graphBlock.blockType, + getInitialPosition, + graphBlock.parentId, + graphBlock.name + ) + } + + const edges = graph.edges.map((edge) => ({ + ...edge, + id: buildStableEdgeId(edge), + type: 'default', + data: {}, + })) + + return createWorkflowSnapshot({ + ...baseWorkflowState, + direction: graph.direction, + blocks, + edges, + loops: generateLoopBlocks(blocks), + parallels: generateParallelBlocks(blocks), + }) as WorkflowSnapshot & { direction: 'TD' | 'LR' } +} + export const editWorkflowServerTool: BaseServerTool = { name: 'edit_workflow', async execute(params: EditWorkflowParams): Promise { const logger = createLogger('EditWorkflowServerTool') - const { entityDocument, documentFormat, currentWorkflowState } = params + const { entityDocument, removedBlockIds, currentWorkflowState } = params const workflowId = requireCopilotEntityId(params, { toolName: 'edit_workflow' }) if (!entityDocument || entityDocument.trim().length === 0) { @@ -28,19 +249,24 @@ export const editWorkflowServerTool: BaseServerTool = { logger.info('Executing edit_workflow', { workflowId, - documentFormat: documentFormat || TG_MERMAID_DOCUMENT_FORMAT, + documentLength: entityDocument.length, }) const baseWorkflowState = await loadBaseWorkflowState(workflowId, currentWorkflowState) - const parsedWorkflowDocument = parseTgMermaidToWorkflow(entityDocument) + const nextWorkflowState = applyGraphMermaidToWorkflow( + baseWorkflowState, + entityDocument, + removedBlockIds + ) const result = buildWorkflowMutationResult({ workflowId, baseWorkflowState, - nextWorkflowState: createWorkflowSnapshot(parsedWorkflowDocument), - requestedDirection: parsedWorkflowDocument.direction, + nextWorkflowState, + requestedDirection: nextWorkflowState.direction, + documentFormat: WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT, }) - logger.info('edit_workflow successfully parsed workflow document', { + logger.info('edit_workflow successfully applied workflow graph', { workflowId, blocksCount: Object.keys(result.workflowState.blocks).length, edgesCount: result.workflowState.edges.length, 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 f5cf9789d..4274a65ac 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,8 @@ 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' @@ -32,8 +34,11 @@ export function buildWorkflowMutationResult(params: { baseWorkflowState: WorkflowSnapshot nextWorkflowState: WorkflowSnapshot requestedDirection?: WorkflowDirection + entityDocument?: string + documentFormat?: string }) { const { workflowId, baseWorkflowState, nextWorkflowState, requestedDirection } = params + const documentFormat = params.documentFormat ?? TG_MERMAID_DOCUMENT_FORMAT const nonCanonicalSubBlockErrors = findIntroducedNonCanonicalSubBlocks( nextWorkflowState, baseWorkflowState @@ -63,14 +68,18 @@ 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 entityDocument = serializeWorkflowToTgMermaid(finalWorkflowState, { direction }) + const entityDocument = + params.entityDocument ?? + (documentFormat === WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT + ? serializeWorkflowToGraphMermaid(finalWorkflowState, { direction }) + : serializeWorkflowToTgMermaid(finalWorkflowState, { direction })) return { success: true, entityKind: 'workflow' as const, entityId: workflowId, entityDocument, - documentFormat: TG_MERMAID_DOCUMENT_FORMAT, + documentFormat, workflowState: finalWorkflowState, preview: { ...preview, diff --git a/apps/tradinggoose/lib/copilot/usage-reservations.ts b/apps/tradinggoose/lib/copilot/usage-reservations.ts index b44152bc4..2027b4d46 100644 --- a/apps/tradinggoose/lib/copilot/usage-reservations.ts +++ b/apps/tradinggoose/lib/copilot/usage-reservations.ts @@ -62,6 +62,8 @@ export type CopilotUsageReleaseResult = { const RESERVATION_KEY_PREFIX = 'copilot:usage-reservation' const DEFAULT_RESERVATION_TTL_SECONDS = 15 * 60 const DEFAULT_LOCK_TTL_SECONDS = 10 +const LOCK_ACQUIRE_ATTEMPTS = 10 +const LOCK_ACQUIRE_RETRY_DELAY_MS = 50 function parsePositiveInt(value: string | undefined, fallback: number): number { if (!value) return fallback @@ -215,10 +217,22 @@ function sumReservedUsd(reservations: CopilotUsageReservation[]): number { return reservations.reduce((total, reservation) => total + reservation.reservedUsd, 0) } +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + async function withScopeLock(scope: ReservationScope, action: () => Promise): Promise { const lockKey = getScopeLockKey(scope) const token = crypto.randomUUID() - const acquired = await acquireLock(lockKey, token, LOCK_TTL_SECONDS) + let acquired = false + + for (let attempt = 0; attempt < LOCK_ACQUIRE_ATTEMPTS; attempt++) { + acquired = await acquireLock(lockKey, token, LOCK_TTL_SECONDS) + if (acquired) break + if (attempt < LOCK_ACQUIRE_ATTEMPTS - 1) { + await delay(LOCK_ACQUIRE_RETRY_DELAY_MS) + } + } if (!acquired) { throw new Error(`Could not acquire copilot usage reservation lock for ${scope.scopeType}:${scope.scopeId}`) @@ -259,6 +273,57 @@ async function resolveReservationScope(params: { } } +async function resolveMutationScope(params: { + userId: string + workflowId?: string | null + reservationId?: string | null +}): Promise { + if (params.reservationId) { + const lookup = await readReservationLookup(params.reservationId) + if (lookup) return lookup + } + return resolveReservationScope(params) +} + +async function releaseReservationInScope( + scope: ReservationScope, + reservationId: string +): Promise { + const reservations = pruneExpiredReservations(await readScopeReservations(scope)) + const reservation = reservations.find((entry) => entry.id === reservationId) ?? null + const remainingReservations = reservations.filter((entry) => entry.id !== reservationId) + + await writeScopeReservations(scope, remainingReservations) + await deleteCachedValue(getReservationLookupKey(reservationId)) + + return { + released: reservation !== null, + reservationId, + reservedUsd: reservation?.reservedUsd, + scopeType: scope.scopeType, + scopeId: scope.scopeId, + } +} + +export async function commitCopilotUsageReservation(params: { + userId: string + workflowId?: string | null + reservationId?: string | null + operation: () => Promise +}): Promise { + const scope = await resolveMutationScope(params) + + return withScopeLock(scope, async () => { + try { + return await params.operation() + } finally { + if (params.reservationId) { + await releaseReservationInScope(scope, params.reservationId) + } + } + }) +} + export async function reserveCopilotUsage(params: { userId: string workflowId?: string | null @@ -327,126 +392,16 @@ export async function reserveCopilotUsage(params: { }) } -export async function adjustCopilotUsageReservation(params: { - reservationId: string - userId: string - workflowId?: string | null - requestedUsd: number - reason?: string -}): Promise { - const lookup = await readReservationLookup(params.reservationId) - if (!lookup) { - return { - allowed: false, - status: 404, - currentUsage: 0, - limit: 0, - remaining: 0, - activeReservedUsd: 0, - scopeType: 'user', - scopeId: params.userId, - message: 'Reservation not found', - } - } - - return withScopeLock(lookup, async () => { - const reservations = pruneExpiredReservations(await readScopeReservations(lookup)) - const reservation = reservations.find((entry) => entry.id === params.reservationId) ?? null - - if (!reservation) { - await deleteCachedValue(getReservationLookupKey(params.reservationId)) - return { - allowed: false, - status: 404, - currentUsage: 0, - limit: 0, - remaining: 0, - activeReservedUsd: 0, - scopeType: lookup.scopeType, - scopeId: lookup.scopeId, - message: 'Reservation not found', - } - } - - const usage = await checkServerSideUsageLimits({ - userId: params.userId, - workflowId: params.workflowId ?? reservation.workflowId, - }) - - const otherReservations = reservations.filter((entry) => entry.id !== params.reservationId) - const otherReservedUsd = sumReservedUsd(otherReservations) - const remainingBeforeAdjust = Math.max(0, usage.limit - usage.currentUsage - otherReservedUsd) - - if (usage.isExceeded || remainingBeforeAdjust < params.requestedUsd) { - return { - allowed: false, - status: 402, - reservationId: params.reservationId, - reservedUsd: reservation.reservedUsd, - currentUsage: usage.currentUsage, - limit: usage.limit, - remaining: remainingBeforeAdjust, - activeReservedUsd: otherReservedUsd + reservation.reservedUsd, - scopeType: lookup.scopeType, - scopeId: lookup.scopeId, - message: usage.message, - } - } - - const refreshedReservation: CopilotUsageReservation = { - ...reservation, - userId: params.userId, - workflowId: params.workflowId ?? reservation.workflowId, - reservedUsd: params.requestedUsd, - reason: params.reason ?? reservation.reason, - expiresAt: new Date(Date.now() + RESERVATION_TTL_SECONDS * 1000).toISOString(), - } - - await writeScopeReservations(lookup, [...otherReservations, refreshedReservation]) - await writeReservationLookup(params.reservationId, lookup) - - return { - allowed: true, - status: 200, - reservationId: params.reservationId, - reservedUsd: refreshedReservation.reservedUsd, - currentUsage: usage.currentUsage, - limit: usage.limit, - remaining: Math.max(0, remainingBeforeAdjust - refreshedReservation.reservedUsd), - activeReservedUsd: otherReservedUsd + refreshedReservation.reservedUsd, - scopeType: lookup.scopeType, - scopeId: lookup.scopeId, - expiresAt: refreshedReservation.expiresAt, - message: usage.message, - } - }) -} - export async function releaseCopilotUsageReservation(params: { reservationId: string }): Promise { - const lookup = await readReservationLookup(params.reservationId) - if (!lookup) { + const scope = await readReservationLookup(params.reservationId) + if (!scope) { return { released: false, reservationId: params.reservationId, } } - return withScopeLock(lookup, async () => { - const reservations = pruneExpiredReservations(await readScopeReservations(lookup)) - const reservation = reservations.find((entry) => entry.id === params.reservationId) ?? null - const remainingReservations = reservations.filter((entry) => entry.id !== params.reservationId) - - await writeScopeReservations(lookup, remainingReservations) - await deleteCachedValue(getReservationLookupKey(params.reservationId)) - - return { - released: reservation !== null, - reservationId: params.reservationId, - reservedUsd: reservation?.reservedUsd, - scopeType: lookup.scopeType, - scopeId: lookup.scopeId, - } - }) + return withScopeLock(scope, () => releaseReservationInScope(scope, params.reservationId)) } diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index 70e6c8260..a0e811c07 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -20,7 +20,6 @@ interface UpsertIndicatorsParams { indicators: Array<{ id?: string name: string - color?: string pineCode: string inputMeta?: Record }> @@ -36,20 +35,6 @@ interface ImportIndicatorsParams { requestId?: string } -const resolveIndicatorColor = ( - input: string | null | undefined, - indicatorId: string, - fallback?: string | null -): string => { - if (typeof input === 'string' && input.trim().length > 0) { - return input.trim() - } - if (typeof fallback === 'string' && fallback.trim().length > 0) { - return fallback.trim() - } - return getStableVibrantColor(indicatorId) -} - export async function upsertIndicators({ indicators, workspaceId, @@ -72,13 +57,12 @@ export async function upsertIndicators({ if (existing.length > 0) { const existingColor = existing[0]?.color - const nextColor = resolveIndicatorColor(indicator.color, indicator.id, existingColor) await tx .update(pineIndicators) .set({ name: indicator.name, - color: nextColor, + color: existingColor ?? getStableVibrantColor(indicator.id), pineCode: indicator.pineCode, inputMeta: indicator.inputMeta ?? null, updatedAt: nowTime, @@ -92,14 +76,12 @@ export async function upsertIndicators({ } const indicatorId = indicator.id ?? crypto.randomUUID() - const nextColor = resolveIndicatorColor(indicator.color, indicatorId) - await tx.insert(pineIndicators).values({ id: indicatorId, workspaceId, userId, name: indicator.name, - color: nextColor, + color: getStableVibrantColor(indicatorId), pineCode: indicator.pineCode, inputMeta: indicator.inputMeta ?? null, createdAt: nowTime, @@ -159,7 +141,7 @@ export async function importIndicators({ workspaceId, userId, name: nextName, - color: resolveIndicatorColor(indicator.color, indicatorId), + color: getStableVibrantColor(indicatorId), pineCode: indicator.pineCode, inputMeta: indicator.inputMeta ?? null, createdAt: nowTime, diff --git a/apps/tradinggoose/lib/indicators/generated/copilot-indicator-reference.ts b/apps/tradinggoose/lib/indicators/generated/copilot-indicator-reference.ts index b9c9d7fad..fd2b502bc 100644 --- a/apps/tradinggoose/lib/indicators/generated/copilot-indicator-reference.ts +++ b/apps/tradinggoose/lib/indicators/generated/copilot-indicator-reference.ts @@ -127,13 +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.color', - 'document.pineCode', - 'document.inputMeta', - ], + relatedIds: ['document.format', 'document.name', 'document.pineCode', 'document.inputMeta'], sourceReferences: [ { label: 'Indicator document schema', @@ -141,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.color 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 document.inputmeta', }, { id: 'section:runtime', @@ -305,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, color, pineCode, inputMeta`.', + 'TradingGoose indicator editing tools expect `tg-indicator-document-v1` JSON with the live field set `name, pineCode, inputMeta`.', support: 'curated', - signature: 'tg-indicator-document-v1 = { name, color, pineCode, inputMeta }', - relatedIds: ['document.name', 'document.color', 'document.pineCode', 'document.inputMeta'], + signature: 'tg-indicator-document-v1 = { name, pineCode, inputMeta }', + relatedIds: ['document.name', 'document.pineCode', 'document.inputMeta'], sourceReferences: [ { label: 'Indicator document schema', @@ -316,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, color, pinecode, inputmeta`. tg-indicator-document-v1 = { name, color, pinecode, inputmeta } document.name document.color 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 the live field set `name, pinecode, inputmeta`. tg-indicator-document-v1 = { name, pinecode, inputmeta } document.name document.pinecode document.inputmeta', }, { id: 'document.name', @@ -336,24 +330,6 @@ export const INDICATOR_REFERENCE_ITEM_RECORDS = [ queryText: 'document.name section:document document field: name human-readable indicator name in the canonical document. the `name` field is part of the live indicator document schema and is what tradinggoose renames when copilot updates an indicator title.', }, - { - id: 'document.color', - sectionId: 'section:document', - type: 'document_field', - title: 'Document Field: color', - summary: 'Default display color in the canonical document.', - detail: - 'The `color` field is part of the live indicator document schema and stores the default indicator display color.', - support: 'curated', - sourceReferences: [ - { - label: 'Indicator document schema', - path: 'apps/tradinggoose/lib/copilot/entity-documents.ts', - }, - ], - queryText: - 'document.color section:document document field: color default display color in the canonical document. the `color` field is part of the live indicator document schema and stores the default indicator display color.', - }, { id: 'document.pineCode', sectionId: 'section:document', diff --git a/apps/tradinggoose/lib/indicators/import-export.test.ts b/apps/tradinggoose/lib/indicators/import-export.test.ts index 9856cf723..3b8225f21 100644 --- a/apps/tradinggoose/lib/indicators/import-export.test.ts +++ b/apps/tradinggoose/lib/indicators/import-export.test.ts @@ -13,7 +13,6 @@ describe('indicator import/export helpers', () => { indicators: [ { name: 'RSI Export Example', - color: '#3972F6', pineCode: "indicator('RSI Export Example')", inputMeta: { Length: { @@ -40,7 +39,6 @@ describe('indicator import/export helpers', () => { indicators: [ { name: 'RSI Export Example', - color: '#3972F6', pineCode: "indicator('RSI Export Example')", inputMeta: { Length: { @@ -61,7 +59,6 @@ describe('indicator import/export helpers', () => { indicators: [ { name: 'RSI Export Example', - color: '#3972F6', pineCode: "indicator('RSI Export Example')", inputMeta: undefined, }, @@ -80,7 +77,6 @@ describe('indicator import/export helpers', () => { indicators: [ { name: 'RSI Export Example', - color: '#3972F6', pineCode: "indicator('RSI Export Example')", }, ], @@ -101,7 +97,6 @@ describe('indicator import/export helpers', () => { indicators: [ { name: ' RSI Export Example ', - color: ' #3972F6 ', pineCode: "indicator('RSI Export Example')", inputMeta: {}, }, @@ -111,7 +106,6 @@ describe('indicator import/export helpers', () => { expect(parsed.indicators).toEqual([ { name: 'RSI Export Example', - color: '#3972F6', pineCode: "indicator('RSI Export Example')", inputMeta: {}, }, @@ -172,7 +166,7 @@ describe('indicator import/export helpers', () => { ).toThrow() }) - it('rejects import entries with extra keys', () => { + it('ignores generated indicator storage fields in transfer records', () => { expect(() => parseImportedIndicatorsFile({ version: '1', @@ -183,12 +177,13 @@ describe('indicator import/export helpers', () => { indicators: [ { id: 'indicator-1', + color: '#3972F6', name: 'RSI Export Example', pineCode: "indicator('RSI Export Example')", }, ], }) - ).toThrow() + ).not.toThrow() }) it('renames duplicate imported indicators with the imported marker', () => { diff --git a/apps/tradinggoose/lib/indicators/import-export.ts b/apps/tradinggoose/lib/indicators/import-export.ts index 0ec0d17d7..464788ee6 100644 --- a/apps/tradinggoose/lib/indicators/import-export.ts +++ b/apps/tradinggoose/lib/indicators/import-export.ts @@ -8,11 +8,6 @@ import type { IndicatorDefinition } from '@/stores/indicators/types' const IMPORTED_INDICATOR_MARKER = '(imported)' const normalizeInlineWhitespace = (value: string) => value.trim().replace(/\s+/g, ' ') -const normalizeOptionalString = (value: string | null | undefined) => { - if (typeof value !== 'string') return undefined - const normalized = value.trim() - return normalized.length > 0 ? normalized : undefined -} export const IndicatorTransferSchema = z .object({ @@ -20,11 +15,9 @@ export const IndicatorTransferSchema = z .string() .transform(normalizeInlineWhitespace) .pipe(z.string().min(1, 'Indicator name is required')), - color: z.string().transform(normalizeInlineWhitespace).optional(), pineCode: z.string(), inputMeta: z.record(z.any()).optional(), }) - .strict() export const IndicatorsTransferListSchema = z .array(IndicatorTransferSchema) @@ -46,11 +39,10 @@ export type IndicatorTransferRecord = z.infer export type IndicatorsImportFile = z.infer function normalizeIndicatorForTransfer( - indicator: Pick + indicator: Pick ): IndicatorTransferRecord { return { name: normalizeInlineWhitespace(indicator.name), - color: normalizeOptionalString(indicator.color), pineCode: indicator.pineCode ?? '', inputMeta: indicator.inputMeta && typeof indicator.inputMeta === 'object' @@ -67,7 +59,7 @@ export function createIndicatorsExportFile({ indicators, exportedFrom, }: { - indicators: Array> + indicators: Array> exportedFrom: string }): IndicatorsImportFile { return createTradingGooseExportFile({ @@ -83,7 +75,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/watchlists/types.ts b/apps/tradinggoose/lib/watchlists/types.ts index 8af94a28c..50e1891ad 100644 --- a/apps/tradinggoose/lib/watchlists/types.ts +++ b/apps/tradinggoose/lib/watchlists/types.ts @@ -1,3 +1,4 @@ +import type { TradingGooseExportEnvelope } from '@/lib/import-export/trading-goose' import type { ListingIdentity } from '@/lib/listing/identity' export type WatchlistSettings = { @@ -38,17 +39,8 @@ export type WatchlistTransferRecord = { items: WatchlistImportFileItem[] } -export type WatchlistImportFile = { - version: '1' - fileType: 'tradingGooseExport' - exportedAt: string - exportedFrom: string - resourceTypes: string[] +export type WatchlistImportFile = TradingGooseExportEnvelope & { watchlists: [WatchlistTransferRecord] - skills?: unknown[] - workflows?: unknown[] - customTools?: unknown[] - indicators?: unknown[] } export type WatchlistRecord = { diff --git a/apps/tradinggoose/lib/workflows/document-format.ts b/apps/tradinggoose/lib/workflows/document-format.ts index 1fef5d54b..3d51364d5 100644 --- a/apps/tradinggoose/lib/workflows/document-format.ts +++ b/apps/tradinggoose/lib/workflows/document-format.ts @@ -1 +1,2 @@ export const TG_MERMAID_DOCUMENT_FORMAT = 'tg-mermaid-v1' as const +export const WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT = 'tg-workflow-graph-mermaid-v1' as const diff --git a/apps/tradinggoose/lib/workflows/import-export.test.ts b/apps/tradinggoose/lib/workflows/import-export.test.ts index 832281559..27014726c 100644 --- a/apps/tradinggoose/lib/workflows/import-export.test.ts +++ b/apps/tradinggoose/lib/workflows/import-export.test.ts @@ -73,7 +73,6 @@ describe('workflow import/export helpers', () => { workflow: { name: ' Primary Workflow ', description: ' Workflow used for trading ', - color: ' #3972F6 ', state: createWorkflowState(), }, }) @@ -89,7 +88,6 @@ describe('workflow import/export helpers', () => { { name: 'Primary Workflow', description: 'Workflow used for trading', - color: '#3972F6', state: { blocks: { block_1: { @@ -115,7 +113,6 @@ describe('workflow import/export helpers', () => { workflow: { name: 'Primary Workflow', description: 'Workflow used for trading', - color: '#3972F6', state: createWorkflowStateWithSkills(), }, skills: [ @@ -185,7 +182,6 @@ describe('workflow import/export helpers', () => { { name: ' Primary Workflow ', description: ' Workflow used for trading ', - color: ' #3972F6 ', state: createWorkflowState(), }, ], @@ -198,7 +194,6 @@ describe('workflow import/export helpers', () => { expect(parsed.data).toMatchObject({ name: 'Primary Workflow', description: 'Workflow used for trading', - color: '#3972F6', skills: [ { name: 'Ignore me', @@ -220,23 +215,21 @@ describe('workflow import/export helpers', () => { }) }) - it('rejects invalid workflow envelopes', () => { - const parsed = parseImportedWorkflowFile({ + it('ignores generated workflow presentation color in transfer records', () => { + expect(parseImportedWorkflowFile({ version: '1', - fileType: 'wrongFileType', + fileType: 'tradingGooseExport', exportedAt: '2026-04-08T15:30:00.000Z', exportedFrom: 'workflowEditor', - resourceTypes: ['skills'], + resourceTypes: ['workflows'], workflows: [ { name: 'Primary Workflow', + color: '#3972F6', state: createWorkflowState(), }, ], - }) - - expect(parsed.data).toBeNull() - expect(parsed.errors[0]).toContain('Unsupported JSON format') + }).errors).toEqual([]) }) it('renames duplicate imported workflows with the imported marker', () => { diff --git a/apps/tradinggoose/lib/workflows/import-export.ts b/apps/tradinggoose/lib/workflows/import-export.ts index a6eb3b7af..5fd04f285 100644 --- a/apps/tradinggoose/lib/workflows/import-export.ts +++ b/apps/tradinggoose/lib/workflows/import-export.ts @@ -27,7 +27,6 @@ const formatZodIssue = (issue: z.ZodIssue) => { export interface WorkflowTransferRecord { name: string description: string - color: string state: ExportWorkflowState['state'] skills: SkillTransferRecord[] } @@ -37,7 +36,6 @@ type WorkflowSkillSource = Pick { { name: 'Primary Workflow', description: 'Workflow imported from the unified schema', - color: '#3972F6', state: { blocks: { block_1: { @@ -45,13 +44,11 @@ describe('workflow import orchestration', () => { name: string description: string workspaceId: string - color?: string }) => { callOrder.push('createWorkflow') expect(params).toMatchObject({ name: 'Primary Workflow (imported) 1', description: 'Workflow imported from the unified schema', - color: '#3972F6', workspaceId: 'workspace-1', }) return 'workflow-1' @@ -115,7 +112,6 @@ describe('workflow import orchestration', () => { { name: 'Primary Workflow', description: 'Workflow imported from the unified schema', - color: '#3972F6', state: { blocks: { block_1: { @@ -176,12 +172,10 @@ describe('workflow import orchestration', () => { name: string description: string workspaceId: string - color?: string }) => { expect(params).toMatchObject({ name: 'Primary Workflow (imported) 1', description: 'Workflow imported from the unified schema', - color: '#3972F6', workspaceId: 'workspace-1', }) return 'workflow-1' diff --git a/apps/tradinggoose/lib/workflows/import.ts b/apps/tradinggoose/lib/workflows/import.ts index 9d133bc74..ec8eae3ef 100644 --- a/apps/tradinggoose/lib/workflows/import.ts +++ b/apps/tradinggoose/lib/workflows/import.ts @@ -15,7 +15,6 @@ type CreateWorkflowParams = { name: string description: string workspaceId: string - color?: string } type ImportWorkflowFromJsonContentParams = { @@ -121,7 +120,6 @@ export async function importWorkflowFromJsonContent({ const workflowId = await createWorkflow({ name: resolvedName, description: workflowData.description, - color: workflowData.color.length > 0 ? workflowData.color : undefined, workspaceId, }) diff --git a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts index 65abb7676..f1db52c16 100644 --- a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts +++ b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import { applyAutoLayout } from '@/lib/workflows/autolayout' import { buildWorkflowDocumentPreviewDiff, + parseGraphOnlyWorkflowMermaid, parseTgMermaidToWorkflow, + serializeWorkflowToGraphMermaid, serializeWorkflowToTgMermaid, TG_MERMAID_DOCUMENT_FORMAT, } from '@/lib/workflows/studio-workflow-mermaid' @@ -395,6 +397,77 @@ n3 --> n4 ]) }) + it('parses ordinary graph-only Mermaid aliases without flattening containers', () => { + const parsed = parseGraphOnlyWorkflowMermaid( + [ + 'flowchart TD', + 'sink["Send Alert"]', + 'subgraph loop_parent["For Each Symbol"]', + ' loop_child["Generate Signal"]', + 'end', + 'sink --> loop_parent', + ].join('\n'), + workflowState.blocks + ) + + expect(parsed.blocks.find((block) => block.blockId === 'loop_child')?.parentId).toBe( + 'loop_parent' + ) + expect(parsed.edges).toContainEqual({ + source: 'sink', + target: 'loop_parent', + targetHandle: 'target', + }) + }) + + it('serializes empty graph-only containers with boundary nodes', () => { + const document = serializeWorkflowToGraphMermaid({ + direction: 'TD', + blocks: { + loop1: { + id: 'loop1', + type: 'loop', + name: 'Loop', + position: { x: 0, y: 0 }, + enabled: true, + subBlocks: {}, + outputs: {}, + }, + sink: { + id: 'sink', + type: 'telegram', + name: 'Sink', + position: { x: 320, y: 0 }, + enabled: true, + subBlocks: {}, + outputs: {}, + }, + }, + edges: [{ id: 'e1', source: 'loop1', target: 'sink', sourceHandle: 'loop-end-source' }], + loops: {}, + parallels: {}, + }) + + expect(document).toContain('n1__loop_start["Loop Start"]') + expect(document).toContain('n1__loop_end["Loop End"]') + expect(document).toContain('n1__loop_end --> n2') + expect(() => parseGraphOnlyWorkflowMermaid(document, {})).not.toThrow() + }) + + it('rejects shorthand graph-only condition edge handles', () => { + expect(() => + parseGraphOnlyWorkflowMermaid( + [ + 'flowchart TD', + 'gate["Market Hours?
id: gate
type: condition"]', + 'sink["Send Alert
id: sink
type: telegram"]', + 'gate -- "if -> target" --> sink', + ].join('\n'), + workflowState.blocks + ) + ).toThrow('must use canonical sourceHandle "condition-gate-"') + }) + it('rejects visible external edges into container internal endpoint nodes', () => { for (const [endpoint, message] of [ ['n2__parallel_end', 'end node only accepts edges from blocks inside that container'], diff --git a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts index 51b5512dd..81d2f7a29 100644 --- a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts +++ b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts @@ -27,7 +27,7 @@ type ConditionEntry = { type MermaidLabelOverlay = { id: string - name: string + name?: string type?: string enabled?: boolean advancedMode?: boolean @@ -35,6 +35,7 @@ type MermaidLabelOverlay = { outputs?: Record dataEntries: Record subBlockEntries: Record + internalFields: string[] } type ConditionBranchOverlay = { @@ -61,6 +62,12 @@ type ParsedVisibleWorkflowEdges = { inferredParentIds: Map } +export type GraphOnlyWorkflowMermaid = { + direction: WorkflowDirection + blocks: Array<{ blockId: string; blockType?: string; name?: string; parentId?: string }> + edges: Array> +} + const COMMENT_PREFIX = '%% ' export const TG_WORKFLOW_PREFIX = `${COMMENT_PREFIX}TG_WORKFLOW ` export const TG_BLOCK_PREFIX = `${COMMENT_PREFIX}TG_BLOCK ` @@ -68,6 +75,18 @@ export const TG_EDGE_PREFIX = `${COMMENT_PREFIX}TG_EDGE ` const TG_LOOP_PREFIX = `${COMMENT_PREFIX}TG_LOOP ` const TG_PARALLEL_PREFIX = `${COMMENT_PREFIX}TG_PARALLEL ` const CONDITION_INPUT_KEY = 'conditions' +const HIDDEN_VISIBLE_EDGE_HANDLES = new Set([ + 'source', + 'target', + 'input', + 'output', + 'loop-start-source', + 'loop-end-source', + 'parallel-start-source', + 'parallel-end-source', + 'loop-end-target', + 'parallel-end-target', +]) function toDocumentJson(value: unknown): string { return stableStringifyJsonValue(value) @@ -101,7 +120,7 @@ function resolveBlockIdFromVisibleNodeId( } function parseRectNodeLine(line: string): { nodeId: string; label: string } | null { - const rectMatch = line.match(/^([A-Za-z0-9_]+)(?:\(\["(.*)"\]\)|\["(.*)"\])$/) + const rectMatch = line.match(/^([A-Za-z0-9_-]+)(?:\(\["(.*)"\]\)|\["(.*)"\])$/) const label = rectMatch?.[2] ?? rectMatch?.[3] if (!rectMatch?.[1] || !label) { @@ -325,6 +344,10 @@ function buildBlockLabelLines(blockId: string, block: BlockState): string[] { return lines } +function buildGraphOnlyBlockLabelLines(blockId: string, block: BlockState): string[] { + return [block.name || block.type, `id: ${blockId}`, `type: ${block.type}`] +} + function renderRectNode(nodeId: string, labelLines: string[], indent: string): string { return `${indent}${nodeId}["${escapeMermaidLabel(labelLines.join('\n'))}"]` } @@ -378,9 +401,20 @@ function emitBlockGraphLines(params: { aliases: Map childrenByParent: Map lines: string[] + labelLinesForBlock?: (blockId: string, block: BlockState) => string[] + includeConditionBranches?: boolean indent?: string }): void { - const { blockId, blocks, aliases, childrenByParent, lines, indent = ' ' } = params + const { + blockId, + blocks, + aliases, + childrenByParent, + lines, + labelLinesForBlock = buildBlockLabelLines, + includeConditionBranches = true, + indent = ' ', + } = params const block = blocks[blockId] const alias = aliases.get(blockId) @@ -388,10 +422,10 @@ function emitBlockGraphLines(params: { return } - const labelLines = buildBlockLabelLines(blockId, block) + const labelLines = labelLinesForBlock(blockId, block) const children = childrenByParent.get(blockId) ?? [] - if (block.type === 'condition') { + if (block.type === 'condition' && includeConditionBranches) { const conditionEntries = parseConditionEntries(block.subBlocks?.[CONDITION_INPUT_KEY]?.value) lines.push(`${indent}subgraph sg_${alias}["${escapeMermaidLabel(labelLines.join('\n'))}"]`) @@ -409,7 +443,12 @@ function emitBlockGraphLines(params: { return } - if (children.length === 0 || (block.type !== 'loop' && block.type !== 'parallel')) { + if (block.type === 'condition') { + lines.push(renderDiamondNode(alias, labelLines, indent)) + return + } + + if (block.type !== 'loop' && block.type !== 'parallel') { lines.push(renderRectNode(alias, labelLines, indent)) return } @@ -429,6 +468,8 @@ function emitBlockGraphLines(params: { aliases, childrenByParent, lines, + labelLinesForBlock, + includeConditionBranches, indent: `${indent} `, }) } @@ -552,20 +593,10 @@ function resolveVisibleEdgeLabel(edge: Edge, blocks: Record) } } - const hiddenHandles = new Set([ - 'source', - 'target', - 'input', - 'output', - 'loop-start-source', - 'loop-end-source', - 'parallel-start-source', - 'parallel-end-source', - 'loop-end-target', - 'parallel-end-target', - ]) - - if (hiddenHandles.has(sourceHandle) && hiddenHandles.has(targetHandle)) { + if ( + HIDDEN_VISIBLE_EDGE_HANDLES.has(sourceHandle) && + HIDDEN_VISIBLE_EDGE_HANDLES.has(targetHandle) + ) { return null } @@ -593,6 +624,60 @@ function emitEdgeGraphLine( return ` ${sourceNodeId} -- "${escapeMermaidLabel(label)}" --> ${targetNodeId}` } +function resolveGraphOnlySourceNodeId( + edge: Edge, + blocks: Record, + aliases: Map +): string | null { + const sourceAlias = aliases.get(edge.source) + const sourceBlock = blocks[edge.source] + + if (!sourceAlias || !sourceBlock) return sourceAlias ?? null + if (sourceBlock.type === 'loop') { + if (edge.sourceHandle === 'loop-start-source') { + return createContainerNodeId(sourceAlias, 'loop', 'start') + } + if (edge.sourceHandle === 'loop-end-source') { + return createContainerNodeId(sourceAlias, 'loop', 'end') + } + } + if (sourceBlock.type === 'parallel') { + if (edge.sourceHandle === 'parallel-start-source') { + return createContainerNodeId(sourceAlias, 'parallel', 'start') + } + if (edge.sourceHandle === 'parallel-end-source') { + return createContainerNodeId(sourceAlias, 'parallel', 'end') + } + } + return sourceAlias +} + +function resolveGraphOnlyEdgeLabel(edge: Edge): string | null { + const sourceHandle = edge.sourceHandle || 'source' + const targetHandle = edge.targetHandle || 'target' + + return HIDDEN_VISIBLE_EDGE_HANDLES.has(sourceHandle) && + HIDDEN_VISIBLE_EDGE_HANDLES.has(targetHandle) + ? null + : `${sourceHandle} -> ${targetHandle}` +} + +function emitGraphOnlyEdgeGraphLine( + edge: Edge, + blocks: Record, + aliases: Map +): string | null { + const sourceNodeId = resolveGraphOnlySourceNodeId(edge, blocks, aliases) + const targetNodeId = resolveVisibleTargetNodeId(edge, blocks, aliases, aliases) + + if (!sourceNodeId || !targetNodeId) return null + + const label = resolveGraphOnlyEdgeLabel(edge) + return label + ? ` ${sourceNodeId} -- "${escapeMermaidLabel(label)}" --> ${targetNodeId}` + : ` ${sourceNodeId} --> ${targetNodeId}` +} + function parseCommentPayload(line: string, prefix: string): T | null { if (!line.startsWith(prefix)) { return null @@ -628,16 +713,17 @@ function parseOverlayFromLabel(label: string): MermaidLabelOverlay | null { const overlay: MermaidLabelOverlay = { id: '', - name: lines[0], dataEntries: {}, subBlockEntries: {}, + internalFields: [], } const conditionEntries: ConditionEntry[] = [] - for (const line of lines.slice(1)) { + for (const line of lines) { const separatorIndex = line.indexOf(':') if (separatorIndex === -1) { + overlay.name ??= line continue } @@ -653,18 +739,22 @@ function parseOverlayFromLabel(label: string): MermaidLabelOverlay | null { continue } if (rawKey === 'enabled') { + overlay.internalFields.push(rawKey) overlay.enabled = Boolean(parseLabelValue(rawValue)) continue } if (rawKey === 'advancedMode') { + overlay.internalFields.push(rawKey) overlay.advancedMode = Boolean(parseLabelValue(rawValue)) continue } if (rawKey === 'triggerMode') { + overlay.internalFields.push(rawKey) overlay.triggerMode = Boolean(parseLabelValue(rawValue)) continue } if (rawKey === 'outputs') { + overlay.internalFields.push(rawKey) const parsed = parseLabelValue(rawValue) if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { overlay.outputs = parsed as Record @@ -672,10 +762,12 @@ function parseOverlayFromLabel(label: string): MermaidLabelOverlay | null { continue } if (rawKey.startsWith('data.')) { + overlay.internalFields.push(rawKey) overlay.dataEntries[rawKey.slice('data.'.length)] = parseLabelValue(rawValue) continue } if (rawKey.startsWith('subBlocks.')) { + overlay.internalFields.push(rawKey) overlay.subBlockEntries[rawKey.slice('subBlocks.'.length)] = parseLabelValue(rawValue) continue } @@ -685,8 +777,12 @@ function parseOverlayFromLabel(label: string): MermaidLabelOverlay | null { rawKey === 'else-if' || rawKey.startsWith('else-if-') ) { + overlay.internalFields.push(`subBlocks.${CONDITION_INPUT_KEY}`) conditionEntries.push({ key: rawKey, value: rawValue }) + continue } + + overlay.name ??= line } if (overlay.id.length === 0) { @@ -798,7 +894,7 @@ function parseMermaidLabelOverlays( continue } - const diamondMatch = trimmed.match(/^[A-Za-z0-9_]+\{"(.*)"\}$/) + const diamondMatch = trimmed.match(/^[A-Za-z0-9_-]+\{"(.*)"\}$/) if (diamondMatch?.[1]) { const overlay = parseOverlayFromLabel(diamondMatch[1]) if (overlay) { @@ -810,6 +906,50 @@ function parseMermaidLabelOverlays( return { blocks, conditionBranches } } +function readGraphOnlyInternalFields(overlay: MermaidLabelOverlay): string[] { + return [...new Set(overlay.internalFields)] +} + +function readGraphOnlyDirectExistingBlockNames( + document: string, + existingBlockIds: Set +): Map { + const names = new Map() + + for (const rawLine of document.split(/\r?\n/)) { + const trimmed = rawLine.trim() + const node = + parseRectNodeLine(trimmed) ?? + (() => { + const diamondMatch = trimmed.match(/^([A-Za-z0-9_-]+)\{"(.*)"\}$/) + return diamondMatch?.[1] && diamondMatch[2] + ? { nodeId: diamondMatch[1], label: diamondMatch[2] } + : null + })() ?? + (() => { + const subgraphMatch = trimmed.match(/^subgraph\s+([A-Za-z0-9_-]+)\["(.*)"\]$/) + return subgraphMatch?.[1] && subgraphMatch[2] + ? { nodeId: subgraphMatch[1], label: subgraphMatch[2] } + : null + })() + + if (!node || !existingBlockIds.has(node.nodeId) || parseOverlayFromLabel(node.label)) { + continue + } + + const name = unescapeMermaidLabel(node.label) + .split('\n') + .map((line) => line.trim()) + .find((line) => line.length > 0) + + if (name) { + names.set(node.nodeId, name) + } + } + + return names +} + function parseVisibleEdgeLabel( rawLabel: string ): { sourceHandle: string; targetHandle: string } | null { @@ -902,6 +1042,22 @@ function parseVisibleWorkflowEdges( return chain } + const registerBlockRef = ( + nodeId: string, + blockId: string, + blockType: string | undefined, + parentId: string | null + ) => { + nodeRefs.set(nodeId, { kind: 'block', blockId, blockType }) + visibleBlockIds.add(blockId) + if (parentId && parentId !== blockId) { + inferredParentIds.set(blockId, parentId) + } + if (!preferredBlockNodeIds.has(blockId)) { + preferredBlockNodeIds.set(blockId, nodeId) + } + } + for (const rawLine of document.split(/\r?\n/)) { const trimmed = rawLine.trim() @@ -910,27 +1066,34 @@ function parseVisibleWorkflowEdges( continue } - const subgraphMatch = trimmed.match(/^subgraph\s+(sg_[A-Za-z0-9_]+)\["(.*)"\]$/) + const subgraphMatch = trimmed.match(/^subgraph\s+([A-Za-z0-9_-]+)\["(.*)"\]$/) if (subgraphMatch?.[1] && subgraphMatch[2]) { const currentContainerId = getActiveContainerId() const overlay = parseOverlayFromLabel(subgraphMatch[2]) if (overlay) { - const nodeId = subgraphMatch[1].slice(3) - aliasToBlockId.set(nodeId, overlay.id) - nodeRefs.set(nodeId, { kind: 'block', blockId: overlay.id, blockType: overlay.type }) - visibleBlockIds.add(overlay.id) - if (currentContainerId && currentContainerId !== overlay.id) { - inferredParentIds.set(overlay.id, currentContainerId) - } - if (!preferredBlockNodeIds.has(overlay.id)) { - preferredBlockNodeIds.set(overlay.id, nodeId) + const nodeId = subgraphMatch[1] + const edgeNodeId = nodeId.startsWith('sg_') ? nodeId.slice('sg_'.length) : nodeId + for (const visibleNodeId of [edgeNodeId, nodeId]) { + aliasToBlockId.set(visibleNodeId, overlay.id) + registerBlockRef(visibleNodeId, overlay.id, overlay.type, currentContainerId) } subgraphStack.push({ blockId: overlay.id, isContainer: overlay.type === 'loop' || overlay.type === 'parallel', }) } else { - subgraphStack.push({ blockId: null, isContainer: false }) + const directBlockId = resolveBlockIdFromVisibleNodeId( + subgraphMatch[1], + knownBlockIdSet, + aliasToBlockId + ) + const directBlockType = directBlockId ? blocks[directBlockId]?.type : undefined + if (directBlockId && isContainerBlockType(directBlockType)) { + registerBlockRef(subgraphMatch[1], directBlockId, directBlockType, currentContainerId) + subgraphStack.push({ blockId: directBlockId, isContainer: true }) + } else { + subgraphStack.push({ blockId: null, isContainer: false }) + } } continue } @@ -956,36 +1119,18 @@ function parseVisibleWorkflowEdges( const directBlockId = resolveBlockIdFromVisibleNodeId(nodeId, knownBlockIdSet, aliasToBlockId) if (directBlockId) { - nodeRefs.set(nodeId, { - kind: 'block', - blockId: directBlockId, - blockType: blocks[directBlockId]?.type, - }) - visibleBlockIds.add(directBlockId) - if (currentContainerId && currentContainerId !== directBlockId) { - inferredParentIds.set(directBlockId, currentContainerId) - } - if (!preferredBlockNodeIds.has(directBlockId)) { - preferredBlockNodeIds.set(directBlockId, nodeId) - } + registerBlockRef(nodeId, directBlockId, blocks[directBlockId]?.type, currentContainerId) continue } const overlay = parseOverlayFromLabel(rectNode.label) if (overlay) { aliasToBlockId.set(nodeId, overlay.id) - nodeRefs.set(nodeId, { kind: 'block', blockId: overlay.id, blockType: overlay.type }) - visibleBlockIds.add(overlay.id) - if (currentContainerId && currentContainerId !== overlay.id) { - inferredParentIds.set(overlay.id, currentContainerId) - } - if (!preferredBlockNodeIds.has(overlay.id)) { - preferredBlockNodeIds.set(overlay.id, nodeId) - } + registerBlockRef(nodeId, overlay.id, overlay.type, currentContainerId) continue } - const containerMatch = nodeId.match(/^([A-Za-z0-9_]+)__(loop|parallel)_(start|end)$/) + const containerMatch = nodeId.match(/^([A-Za-z0-9_-]+)__(loop|parallel)_(start|end)$/) if (containerMatch?.[1] && containerMatch[2] && containerMatch[3]) { const blockId = resolveBlockIdFromVisibleNodeId( containerMatch[1], @@ -1004,24 +1149,13 @@ function parseVisibleWorkflowEdges( continue } - const diamondMatch = trimmed.match(/^([A-Za-z0-9_]+)\{"(.*)"\}$/) + const diamondMatch = trimmed.match(/^([A-Za-z0-9_-]+)\{"(.*)"\}$/) if (diamondMatch?.[1] && diamondMatch[2]) { const currentContainerId = getActiveContainerId() const overlay = parseOverlayFromLabel(diamondMatch[2]) if (overlay) { aliasToBlockId.set(diamondMatch[1], overlay.id) - nodeRefs.set(diamondMatch[1], { - kind: 'block', - blockId: overlay.id, - blockType: overlay.type, - }) - visibleBlockIds.add(overlay.id) - if (currentContainerId && currentContainerId !== overlay.id) { - inferredParentIds.set(overlay.id, currentContainerId) - } - if (!preferredBlockNodeIds.has(overlay.id)) { - preferredBlockNodeIds.set(overlay.id, diamondMatch[1]) - } + registerBlockRef(diamondMatch[1], overlay.id, overlay.type, currentContainerId) } } } @@ -1031,7 +1165,7 @@ function parseVisibleWorkflowEdges( for (const rawLine of document.split(/\r?\n/)) { const trimmed = rawLine.trim() const edgeMatch = trimmed.match( - /^([A-Za-z0-9_]+)\s*(?:--\s*"((?:\\"|[^"])*)"\s*)?-->\s*([A-Za-z0-9_]+)$/ + /^([A-Za-z0-9_-]+)\s*(?:--\s*"((?:\\"|[^"])*)"\s*)?-->\s*([A-Za-z0-9_-]+)$/ ) if (!edgeMatch?.[1] || !edgeMatch[3]) { continue @@ -1040,7 +1174,9 @@ function parseVisibleWorkflowEdges( const sourceRef = nodeRefs.get(edgeMatch[1]) const targetRef = nodeRefs.get(edgeMatch[3]) if (!sourceRef || !targetRef) { - continue + throw new Error( + `Workflow graph Mermaid edge "${edgeMatch[1]} --> ${edgeMatch[3]}" references unknown node id.` + ) } if ( @@ -1055,11 +1191,11 @@ function parseVisibleWorkflowEdges( const sourceAncestors = getVisibleAncestorChain(sourceRef.blockId) const visibleEndpointViolation = targetRef.kind === 'container-start' - ? `Invalid visible container edge: ${targetRef.blockId} start node is source-only. Use the ${targetRef.blockId} container block alias in the visible line and targetHandle "target" in TG_EDGE metadata for incoming edges.` + ? `Invalid container edge: ${targetRef.blockId} start node is source-only. Use the ${targetRef.blockId} container block alias in the visible line and targetHandle "target" in TG_EDGE metadata for incoming edges.` : targetRef.kind === 'container-end' && !sourceAncestors.includes(targetRef.blockId) - ? `Invalid visible container edge: ${targetRef.blockId} end node only accepts edges from blocks inside that container. Use the ${targetRef.blockId} container block alias in the visible line and targetHandle "target" in TG_EDGE metadata for incoming outer edges.` + ? `Invalid container edge: ${targetRef.blockId} end node only accepts edges from blocks inside that container. Use the ${targetRef.blockId} container block alias in the visible line and targetHandle "target" in TG_EDGE metadata for incoming outer edges.` : sourceRef.kind === 'container-start' && !targetAncestors.includes(sourceRef.blockId) - ? `Invalid visible container edge: ${sourceRef.blockId} start node only connects to blocks inside that container. Use the ${sourceRef.blockId} container block alias for outer workflow edges.` + ? `Invalid container edge: ${sourceRef.blockId} start node only connects to blocks inside that container. Use the ${sourceRef.blockId} container block alias for outer workflow edges.` : null if (visibleEndpointViolation) throw new Error(visibleEndpointViolation) @@ -1082,6 +1218,17 @@ function parseVisibleWorkflowEdges( ? `${targetRef.blockType}-end-target` : 'target') + const sourceBlock = blocks[sourceRef.blockId] + const conditionHandlePrefix = `condition-${sourceRef.blockId}-` + 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.` + ) + } + visibleEdges.push({ source: sourceRef.blockId, target: targetRef.blockId, @@ -1383,6 +1530,121 @@ function applyVisibleParenting( return nextBlocks } +export function parseGraphOnlyWorkflowMermaid( + document: string, + existingBlocks: Record +): GraphOnlyWorkflowMermaid { + const directionMatch = document.trimStart().match(/^flowchart\s+(TD|LR)\b/) + if (!directionMatch?.[1]) { + throw new Error('Workflow graph Mermaid must start with `flowchart TD` or `flowchart LR`.') + } + + for (const line of document.split(/\r?\n/)) { + const trimmed = line.trim() + if ( + trimmed.startsWith(TG_WORKFLOW_PREFIX) || + trimmed.startsWith(TG_BLOCK_PREFIX) || + trimmed.startsWith(TG_EDGE_PREFIX) || + trimmed.startsWith(TG_LOOP_PREFIX) || + trimmed.startsWith(TG_PARALLEL_PREFIX) + ) { + throw new Error( + 'Workflow graph Mermaid must not include TG_* metadata comments. Send only visible Mermaid nodes, subgraphs, and edges.' + ) + } + } + + const existingBlockIds = new Set(Object.keys(existingBlocks)) + const blockOverlays = parseMermaidLabelOverlays(document, Object.keys(existingBlocks)) + for (const [blockId, name] of readGraphOnlyDirectExistingBlockNames(document, existingBlockIds)) { + if (!blockOverlays.blocks.has(blockId)) { + blockOverlays.blocks.set(blockId, { + id: blockId, + name, + dataEntries: {}, + subBlockEntries: {}, + internalFields: [], + }) + } + } + const graphBlocks: Record = { ...existingBlocks } + + for (const [blockId, overlay] of blockOverlays.blocks) { + const internalFields = readGraphOnlyInternalFields(overlay) + if (internalFields.length > 0) { + throw new Error( + `Workflow graph Mermaid block "${blockId}" includes block-internal fields (${internalFields.join(', ')}). Use edit_workflow_block to change block configuration; edit_workflow only accepts visible graph labels: name, id, and type.` + ) + } + + if (!graphBlocks[blockId]) { + graphBlocks[blockId] = { + id: blockId, + type: overlay.type ?? 'unknown', + name: overlay.name ?? '', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + } + } + } + + if (parseMermaidLabelOverlays(document, Object.keys(graphBlocks)).conditionBranches.size > 0) { + throw new Error( + 'Workflow graph Mermaid must not include condition branch labels. Use edit_workflow_block to change condition branch definitions.' + ) + } + + const visibleGraph = parseVisibleWorkflowEdges(document, graphBlocks) + const blocksWithVisibleParenting = applyVisibleParenting( + graphBlocks, + visibleGraph.visibleBlockIds, + visibleGraph.inferredParentIds + ) + const edges = normalizeLogicalWorkflowEdges(visibleGraph.edges, blocksWithVisibleParenting).map( + ({ source, target, sourceHandle, targetHandle }) => ({ + source, + target, + ...(sourceHandle ? { sourceHandle } : {}), + ...(targetHandle ? { targetHandle } : {}), + }) + ) + for (const edge of edges) { + const conditionKey = extractConditionDisplayKey(edge.source, edge.sourceHandle) + if (!conditionKey) continue + + const sourceBlock = blocksWithVisibleParenting[edge.source] + const existingConditionKeys = new Set( + parseConditionEntries(sourceBlock?.subBlocks?.[CONDITION_INPUT_KEY]?.value).map( + (entry) => entry.key + ) + ) + if (sourceBlock?.type !== 'condition' || !existingConditionKeys.has(conditionKey)) { + throw new Error( + `Workflow graph Mermaid references unknown condition branch "${conditionKey}" on block "${edge.source}". Use edit_workflow_block to define condition branches before wiring them.` + ) + } + } + + return { + direction: directionMatch[1] as WorkflowDirection, + blocks: [...visibleGraph.visibleBlockIds].map((blockId) => { + const block = blocksWithVisibleParenting[blockId] + const overlay = blockOverlays.blocks.get(blockId) + return { + blockId, + ...((overlay?.type ?? block?.type) && (overlay?.type ?? block?.type) !== 'unknown' + ? { blockType: overlay?.type ?? block?.type } + : {}), + ...(overlay?.name ? { name: overlay.name } : {}), + ...(block?.data?.parentId ? { parentId: block.data.parentId } : {}), + } + }), + edges, + } +} + function syncContainerNodeMembership( blocks: Record, loops: Record, @@ -1705,6 +1967,44 @@ export function serializeWorkflowToTgMermaid( return lines.join('\n') } +export function serializeWorkflowToGraphMermaid( + workflowState: WorkflowSnapshot, + options: { direction?: WorkflowDirection } = {} +): string { + const direction = + options.direction ?? + workflowState.direction ?? + inferMermaidDirectionFromWorkflowState(workflowState) + const blocks = workflowState.blocks ?? {} + const blockIds = Object.keys(blocks).sort((left, right) => left.localeCompare(right)) + const aliases = buildAliasMap(blockIds) + const childrenByParent = getChildrenByParent(blocks) + const rootBlockIds = blockIds.filter((blockId) => { + const parentId = blocks[blockId]?.data?.parentId + return !parentId || !blocks[parentId] + }) + const lines = [`flowchart ${direction}`] + + for (const blockId of rootBlockIds) { + emitBlockGraphLines({ + blockId, + blocks, + aliases, + childrenByParent, + lines, + labelLinesForBlock: buildGraphOnlyBlockLabelLines, + includeConditionBranches: false, + }) + } + + for (const edge of workflowState.edges ?? []) { + const line = emitGraphOnlyEdgeGraphLine(edge, blocks, aliases) + if (line) lines.push(line) + } + + return lines.join('\n') +} + export function parseTgMermaidToWorkflow( document: string ): WorkflowSnapshot & { direction: WorkflowDirection } { diff --git a/apps/tradinggoose/lib/workflows/subblock-values.ts b/apps/tradinggoose/lib/workflows/subblock-values.ts index b3d1279dc..33dd7a8ac 100644 --- a/apps/tradinggoose/lib/workflows/subblock-values.ts +++ b/apps/tradinggoose/lib/workflows/subblock-values.ts @@ -89,6 +89,32 @@ export function resolveInitialSubBlockValue( return '' } +export function buildInitialSubBlockStates( + subBlockConfigs: SubBlockConfig[], + initialValues?: Record +): Record { + const subBlocks: Record = {} + const resolvedSubBlockParams: Record = {} + + for (const subBlock of subBlockConfigs) { + const resolvedInitialValue = resolveInitialSubBlockValue( + subBlock, + resolvedSubBlockParams, + initialValues?.[subBlock.id] + ) + + subBlocks[subBlock.id] = { + id: subBlock.id, + type: subBlock.type, + value: resolvedInitialValue, + } + + resolvedSubBlockParams[subBlock.id] = resolvedInitialValue + } + + return subBlocks +} + export function resolveDisplayedSubBlockValue( subBlock: Pick, value: unknown diff --git a/apps/tradinggoose/lib/workflows/workflow-direction.ts b/apps/tradinggoose/lib/workflows/workflow-direction.ts index 77b3858de..834d92072 100644 --- a/apps/tradinggoose/lib/workflows/workflow-direction.ts +++ b/apps/tradinggoose/lib/workflows/workflow-direction.ts @@ -4,7 +4,7 @@ import type { BlockState, WorkflowDirection } from '@/stores/workflows/workflow/ type WorkflowGraphState = Pick -function getAbsoluteBlockPosition( +export function getAbsoluteBlockPosition( blockId: string, blocks: Record, visiting = new Set() @@ -70,7 +70,9 @@ export function inferMermaidDirectionFromWorkflowState( return horizontalDistance > verticalDistance ? 'LR' : 'TD' } - const positions = Object.keys(blocks).map((blockId) => getPosition(blockId)).filter(Boolean) as Array<{ + const positions = Object.keys(blocks) + .map((blockId) => getPosition(blockId)) + .filter(Boolean) as Array<{ x: number y: number }> diff --git a/apps/tradinggoose/lib/yjs/use-workflow-doc.ts b/apps/tradinggoose/lib/yjs/use-workflow-doc.ts index 22f70573f..a81ca5f8c 100644 --- a/apps/tradinggoose/lib/yjs/use-workflow-doc.ts +++ b/apps/tradinggoose/lib/yjs/use-workflow-doc.ts @@ -15,7 +15,7 @@ import type { Edge } from '@xyflow/react' import type * as Y from 'yjs' import { escapeRegExp } from '@/lib/utils' import { readBlockOutputs, resolveBlockRuntimeState } from '@/lib/workflows/block-outputs' -import { resolveInitialSubBlockValue } from '@/lib/workflows/subblock-values' +import { buildInitialSubBlockStates } from '@/lib/workflows/subblock-values' import { YJS_ORIGINS, type YjsOrigin } from '@/lib/yjs/transaction-origins' import { useYjsSubscription } from '@/lib/yjs/use-yjs-subscription' import { rewriteWorkflowContentReferences } from '@/lib/yjs/workflow-reference-rewrite' @@ -839,25 +839,13 @@ export function useWorkflowMutations() { const blockConfig = getBlock(type) let subBlocks: Record = {} const outputs: Record = {} - const resolvedSubBlockParams: Record = {} if (blockConfig) { const initValues = blockProperties?.initialSubBlockValues - blockConfig.subBlocks.forEach((subBlock) => { - const resolvedInitialValue = resolveInitialSubBlockValue( - subBlock, - resolvedSubBlockParams, - initValues?.[subBlock.id] - ) - - subBlocks[subBlock.id] = { - id: subBlock.id, - type: subBlock.type, - value: resolvedInitialValue as any, - } - - resolvedSubBlockParams[subBlock.id] = resolvedInitialValue - }) + subBlocks = buildInitialSubBlockStates( + blockConfig.subBlocks, + initValues + ) as Record const runtimeState = resolveBlockRuntimeState({ blockType: type, diff --git a/apps/tradinggoose/stores/copilot/store.test.ts b/apps/tradinggoose/stores/copilot/store.test.ts index c3f2d42f2..fc859bec6 100644 --- a/apps/tradinggoose/stores/copilot/store.test.ts +++ b/apps/tradinggoose/stores/copilot/store.test.ts @@ -859,7 +859,7 @@ describe('copilot streaming regressions', () => { expect(store.getState().isAwaitingContinuation).toBe(false) }) - it('treats awaiting_tools as a pause and skips terminal billing fetch', async () => { + it('treats awaiting_tools as a pause and skips terminal context usage refresh', async () => { const channelId = 'copilot-awaiting-tools-pause' const store = getCopilotStore(channelId) const fetchMock = vi.fn(async (input: RequestInfo | URL) => { @@ -1345,8 +1345,7 @@ describe('copilot streaming regressions', () => { name: 'edit_workflow', arguments: { entityId: 'wf-limited-edit', - entityDocument: 'workflow: {}', - documentFormat: 'tg-mermaid-v1', + entityDocument: 'flowchart TD', }, }, }, @@ -2807,7 +2806,7 @@ describe('copilot context usage', () => { store.setState({ currentChat: { reviewSessionId: 'review-context-usage-generic', - workspaceId: null, + workspaceId: 'workspace-context-usage', entityKind: 'copilot', entityId: null, draftSessionId: null, @@ -2839,6 +2838,7 @@ describe('copilot context usage', () => { conversationId: 'conversation-context-usage-generic', model: 'claude-sonnet-4.6', provider: 'anthropic', + workspaceId: 'workspace-context-usage', }) expect(store.getState().contextUsage).toEqual({ usage: 1234, diff --git a/apps/tradinggoose/stores/copilot/store.ts b/apps/tradinggoose/stores/copilot/store.ts index d97835d98..ae8c0d4e0 100644 --- a/apps/tradinggoose/stores/copilot/store.ts +++ b/apps/tradinggoose/stores/copilot/store.ts @@ -4,7 +4,7 @@ import { createContext, createElement, type ReactNode, useContext, useMemo } fro import type { StoreApi } from 'zustand' import { devtools } from 'zustand/middleware' import { createWithEqualityFn as create, useStoreWithEqualityFn } from 'zustand/traditional' -import { shouldAutoExecuteTool } from '@/lib/copilot/access-policy' +import { shouldRequireToolApproval } from '@/lib/copilot/access-policy' import { type CopilotChat, sendStreamingMessage } from '@/lib/copilot/api' import { mergeCopilotContexts } from '@/lib/copilot/chat-contexts' import { DEFAULT_COPILOT_RUNTIME_MODEL } from '@/lib/copilot/runtime-models' @@ -70,6 +70,7 @@ import { ensureClientToolInstance, handleCopilotServerToolSuccess, isCopilotTool, + isGatedTool, isServerManagedCopilotTool, prepareCopilotToolArgs, resolveToolDisplay, @@ -275,10 +276,6 @@ function autoExecutePendingToolsForAccessLevel( accessLevel: CopilotStore['accessLevel'], get: () => CopilotStore ) { - if (!shouldAutoExecuteTool(accessLevel)) { - return - } - const { toolCallsById } = get() const copilotToolIds: string[] = [] @@ -287,7 +284,10 @@ function autoExecutePendingToolsForAccessLevel( continue } - if (isCopilotTool(toolCall.name)) { + if ( + isCopilotTool(toolCall.name) && + !shouldRequireToolApproval(accessLevel, isGatedTool(toolCall.name)) + ) { copilotToolIds.push(id) } } @@ -1172,13 +1172,7 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) // Fetch context usage after response completes if (!context.awaitingTools) { logger.info('[Context Usage] Stream completed, fetching usage') - const billingOptions = assistantMessageId - ? { - bill: true, - assistantMessageId, - } - : undefined - await get().fetchContextUsage(billingOptions) + await get().fetchContextUsage() } } finally { abortSignal?.removeEventListener('abort', cancelReader) @@ -1270,9 +1264,8 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) setAgentPrefetch: (prefetch) => set({ agentPrefetch: prefetch }), // Fetch context usage from copilot API - fetchContextUsage: async (options?: { bill?: boolean; assistantMessageId?: string }) => { + fetchContextUsage: async () => { try { - const { bill = false, assistantMessageId } = options ?? {} const { currentChat, selectedModel } = get() const selectedProvider = resolveCopilotRuntimeProvider(selectedModel) logger.info('[Context Usage] Starting fetch', { @@ -1280,8 +1273,6 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) conversationId: currentChat?.conversationId, model: selectedModel, provider: selectedProvider, - bill, - assistantMessageId, }) if (!currentChat) { @@ -1303,15 +1294,8 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) conversationId: currentChat.conversationId, model: selectedModel, provider: selectedProvider, + ...(currentChat.workspaceId ? { workspaceId: currentChat.workspaceId } : {}), } - // Generic Copilot context usage is conversation/user scoped. Workflow contexts are - // prompt context for the chat, not billing scope selectors for this widget. - if (bill && assistantMessageId) { - requestPayload.bill = true - requestPayload.assistantMessageId = assistantMessageId - requestPayload.billingModel = selectedModel - } - logger.info('[Context Usage] Calling API', requestPayload) // Call the backend API route which proxies to copilot @@ -1510,7 +1494,7 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) syncClientToolInstanceState(id, instance) if ( stateBeforeUserAction !== ClientToolCallState.review && - shouldAutoExecuteTool(get().accessLevel) && + !shouldRequireToolApproval(get().accessLevel, true) && get().toolCallsById[id]?.state === ClientToolCallState.review && typeof instance.handleUserAction === 'function' ) { diff --git a/apps/tradinggoose/stores/copilot/types.ts b/apps/tradinggoose/stores/copilot/types.ts index c0306d6ca..d7f07387f 100644 --- a/apps/tradinggoose/stores/copilot/types.ts +++ b/apps/tradinggoose/stores/copilot/types.ts @@ -162,7 +162,7 @@ export interface CopilotActions { setAccessLevel: (accessLevel: CopilotAccessLevel) => void setSelectedModel: (model: CopilotStore['selectedModel']) => Promise setAgentPrefetch: (prefetch: boolean) => void - fetchContextUsage: (options?: { bill?: boolean; assistantMessageId?: string }) => Promise + fetchContextUsage: () => Promise loadChats: (options?: { workspaceId?: string | null }) => Promise selectChat: (chat: CopilotChat) => Promise diff --git a/apps/tradinggoose/stores/workflows/json/importer.test.ts b/apps/tradinggoose/stores/workflows/json/importer.test.ts index e0e689f2f..d99e067fd 100644 --- a/apps/tradinggoose/stores/workflows/json/importer.test.ts +++ b/apps/tradinggoose/stores/workflows/json/importer.test.ts @@ -32,7 +32,6 @@ describe('workflow json importer', () => { { name: ' Primary Workflow ', description: ' Workflow used for trading ', - color: ' #3972F6 ', state: createWorkflowState(), }, ], @@ -48,7 +47,6 @@ describe('workflow json importer', () => { expect(data).toMatchObject({ name: 'Primary Workflow', description: 'Workflow used for trading', - color: '#3972F6', state: { blocks: { block_1: { @@ -77,7 +75,6 @@ describe('workflow json importer', () => { { name: ' Primary Workflow ', description: ' Workflow used for trading ', - color: ' #3972F6 ', state: createWorkflowState(), }, ], @@ -93,7 +90,6 @@ describe('workflow json importer', () => { expect(data).toMatchObject({ name: 'Primary Workflow', description: 'Workflow used for trading', - color: '#3972F6', skills: [ { name: 'Market Research', @@ -122,7 +118,6 @@ describe('workflow json importer', () => { { name: 'Primary Workflow', description: 'Workflow used for trading', - color: '#3972F6', state: createWorkflowState(), }, ], @@ -160,7 +155,6 @@ describe('workflow json importer', () => { { name: 'Primary Workflow', description: 'Workflow used for trading', - color: '#3972F6', state: createWorkflowState(), }, ], diff --git a/apps/tradinggoose/stores/workflows/json/importer.ts b/apps/tradinggoose/stores/workflows/json/importer.ts index 5486f8a8a..5f89c58c3 100644 --- a/apps/tradinggoose/stores/workflows/json/importer.ts +++ b/apps/tradinggoose/stores/workflows/json/importer.ts @@ -156,7 +156,6 @@ export function parseWorkflowJson( logger.info('Successfully parsed workflow JSON', { name: workflowData.name, description: workflowData.description, - color: workflowData.color, blocksCount: Object.keys(workflowData.state.blocks).length, edgesCount: workflowData.state.edges.length, loopsCount: Object.keys(workflowData.state.loops).length, diff --git a/apps/tradinggoose/stores/workflows/json/store.ts b/apps/tradinggoose/stores/workflows/json/store.ts index f75f85c43..5020fc270 100644 --- a/apps/tradinggoose/stores/workflows/json/store.ts +++ b/apps/tradinggoose/stores/workflows/json/store.ts @@ -86,7 +86,6 @@ export const useWorkflowJsonStore = create()( workflow: { name: currentWorkflow.name, description: currentWorkflow.description ?? '', - color: currentWorkflow.color ?? '', state: workflowSnapshot, }, skills: workspaceSkills, diff --git a/apps/tradinggoose/stores/workflows/registry/store.ts b/apps/tradinggoose/stores/workflows/registry/store.ts index 02611846d..0b8fa0804 100644 --- a/apps/tradinggoose/stores/workflows/registry/store.ts +++ b/apps/tradinggoose/stores/workflows/registry/store.ts @@ -887,12 +887,6 @@ export const useWorkflowRegistry = create()( workspaceId, folderId: options.folderId || null, } - if (typeof options.color === 'string') { - requestBody.color = options.color - } - if (options.marketplaceId) { - requestBody.color = '#808080' - } const response = await fetch('/api/workflows', { method: 'POST', @@ -1181,7 +1175,10 @@ export const useWorkflowRegistry = create()( }, // Update workflow metadata - updateWorkflow: async (id: string, metadata: Partial) => { + updateWorkflow: async ( + id: string, + metadata: Partial> + ) => { const { workflows } = get() const workflow = workflows[id] if (!workflow) { diff --git a/apps/tradinggoose/stores/workflows/registry/types.ts b/apps/tradinggoose/stores/workflows/registry/types.ts index 95670f699..d1214e0e8 100644 --- a/apps/tradinggoose/stores/workflows/registry/types.ts +++ b/apps/tradinggoose/stores/workflows/registry/types.ts @@ -63,14 +63,16 @@ export interface WorkflowRegistryActions { id: string, options?: { skipApi?: boolean; templateAction?: 'keep' | 'delete' } ) => Promise - updateWorkflow: (id: string, metadata: Partial) => Promise + updateWorkflow: ( + id: string, + metadata: Partial> + ) => Promise createWorkflow: (options?: { isInitial?: boolean marketplaceId?: string marketplaceState?: any name?: string description?: string - color?: string workspaceId?: string folderId?: string | null }) => Promise diff --git a/apps/tradinggoose/stores/workflows/workflow/store.ts b/apps/tradinggoose/stores/workflows/workflow/store.ts index d75319fc3..b7489586c 100644 --- a/apps/tradinggoose/stores/workflows/workflow/store.ts +++ b/apps/tradinggoose/stores/workflows/workflow/store.ts @@ -4,6 +4,7 @@ import { devtools } from 'zustand/middleware' import { createStore, type StoreApi } from 'zustand/vanilla' import { createLogger } from '@/lib/logs/console/logger' import { resolveBlockRuntimeState } from '@/lib/workflows/block-outputs' +import { buildInitialSubBlockStates } from '@/lib/workflows/subblock-values' import { getBlock } from '@/blocks' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -118,15 +119,10 @@ const createWorkflowStoreState = ...(parentId && { parentId, extent: extent || 'parent' }), } - let subBlocks: Record = {} - blockConfig.subBlocks.forEach((subBlock) => { - const subBlockId = subBlock.id - subBlocks[subBlockId] = { - id: subBlockId, - type: subBlock.type, - value: null, - } - }) + let subBlocks = buildInitialSubBlockStates(blockConfig.subBlocks) as Record< + string, + SubBlockState + > const triggerMode = blockProperties?.triggerMode ?? false const runtimeState = resolveBlockRuntimeState({ diff --git a/apps/tradinggoose/widgets/widgets/editor_indicator/index.test.tsx b/apps/tradinggoose/widgets/widgets/editor_indicator/index.test.tsx index 159d82246..66a6d9e56 100644 --- a/apps/tradinggoose/widgets/widgets/editor_indicator/index.test.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_indicator/index.test.tsx @@ -222,7 +222,6 @@ describe('Indicator Editor header controls', () => { indicators: [ { name: 'RSI Export Example', - color: '#3972F6', pineCode: "indicator('RSI Export Example')", inputMeta: { Length: { diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-node.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-node.tsx index 8689640f9..eb96f5e79 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-node.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-node.tsx @@ -120,7 +120,7 @@ function LocalizedPreviewNode({ id, data }: NodeProps) { ) const isEnabled = data.blockState?.enabled ?? true const isAdvancedMode = data.blockState?.advancedMode ?? false - const useHorizontalHandles = data.blockState?.horizontalHandles ?? false + const useHorizontalHandles = data.blockState?.horizontalHandles ?? true const isPureTriggerBlock = blockConfig?.category === 'triggers' const isTriggerMode = Boolean(data.blockState?.triggerMode) || isPureTriggerBlock const previewSubBlocks = useMemo( 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 78dd32717..3118e358e 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 @@ -159,7 +159,6 @@ export function IndicatorList({ workspaceId, indicator: { name: copiedName, - color: indicator.color ?? '', pineCode: indicator.pineCode ?? '', inputMeta: indicator.inputMeta && typeof indicator.inputMeta === 'object' diff --git a/apps/tradinggoose/widgets/widgets/list_indicator/index.test.tsx b/apps/tradinggoose/widgets/widgets/list_indicator/index.test.tsx index 3ef2263f3..45816566f 100644 --- a/apps/tradinggoose/widgets/widgets/list_indicator/index.test.tsx +++ b/apps/tradinggoose/widgets/widgets/list_indicator/index.test.tsx @@ -140,7 +140,6 @@ describe('Indicator List header controls', () => { indicators: [ { name: 'RSI Export Example', - color: '#3972F6', pineCode: "indicator('RSI Export Example')", inputMeta: {}, }, diff --git a/changelog/June-11-2026.md b/changelog/June-11-2026.md new file mode 100644 index 000000000..8e1a03e34 --- /dev/null +++ b/changelog/June-11-2026.md @@ -0,0 +1,79 @@ +# June-11-2026 + +## fix/copilot-billing @ 9153673a vs upstream/staging + +### Summary +- Moves Copilot billing to completion-usage reports and removes local context-usage billing and reservation-adjust flows. +- Adds a scoped reservation commit helper so completion billing and local report mirroring release reservations under the same lock. +- Switches `edit_workflow` from full `tg-mermaid-v1` replacement documents to minimal graph-only Mermaid with explicit removal intent. +- Removes user-supplied `color` from workflow and indicator create/update/import/export contracts while keeping stable colors as internal display state. + +### Branch Scope +- Compared `78a776d35928728bd0389e21070e88a001271ef2..9153673a5f641cd67fec93d8345ceefba8161125`, where `78a776d35928728bd0389e21070e88a001271ef2` is both the merge base and current `upstream/staging`. +- Ran `git fetch upstream staging` before comparing. This entry intentionally uses `upstream/staging`, not the template default `origin/staging`, because the user requested the upstream base. +- `git status --short --branch` was clean before editing this changelog, so no uncommitted feature work was included in the branch evidence. +- Main areas touched: Copilot usage billing routes and reservation helpers, Copilot chat/tool-completion proxying, Copilot tool approval and context usage store paths, workflow graph Mermaid parsing/editing, runtime tool manifest validation, server-tool error mapping, import/export envelopes, workflow/indicator color contracts, and focused tests. + +### Key Changes +- `apps/tradinggoose/app/api/copilot/usage/route.ts` turns context usage back into browser-session inspection only. `ContextUsageRequestSchema` no longer accepts `bill`, `assistantMessageId`, `billingModel`, or caller-provided `userId`, and `action: "commit"` now validates only `CompletionCommitRequestSchema` with `kind: "completion"`. +- `apps/tradinggoose/app/api/copilot/usage/route.ts` adds `CompletionUsageReportSchema` and exports `mirrorLocalCopilotCompletionUsageReports()`. Self-hosted Studio instances parse `billing.completion_usage` reports, bill them with the `copilot-completion-billing:` idempotency key, and skip hosted deployments or empty report sets. +- `apps/tradinggoose/lib/copilot/usage-reservations.ts` replaces `adjustCopilotUsageReservation()` with `commitCopilotUsageReservation()`. Commits resolve the mutation scope from a reservation lookup when present, run the billing operation under `withScopeLock()`, and release the reservation in `finally`. +- `apps/tradinggoose/app/api/copilot/chat/route.ts` and `apps/tradinggoose/app/api/copilot/tools/mark-complete/route.ts` consume completion billing reports from SSE `billing.completion_usage` events and non-streaming `completionUsageReports`, mirror them locally, and avoid forwarding billing events to the client stream. +- `apps/tradinggoose/lib/copilot/agent/utils.ts` requires `userId` for `requestCopilotTitle()` and forwards it as `x-copilot-user-id`; `apps/tradinggoose/app/api/copilot/chat/route.ts` passes the authenticated user id when title generation is requested. +- `apps/tradinggoose/stores/copilot/store.ts` and `apps/tradinggoose/stores/copilot/types.ts` simplify `fetchContextUsage()` to take no billing options, include `workspaceId` in the context usage request payload, and use `shouldRequireToolApproval(accessLevel, isGatedTool(name))` so full access can auto-run non-gated pending tools without bypassing staged review flows. +- `apps/tradinggoose/lib/workflows/document-format.ts` introduces `WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT = "tg-workflow-graph-mermaid-v1"`. `apps/tradinggoose/lib/copilot/registry.ts` makes `edit_workflow` return this graph format, removes the old `documentFormat` input, and adds `removedBlockIds` for intentional topology deletion. +- `apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.ts` now applies graph-only Mermaid over the current workflow state. It preserves existing block identity and details by id, rejects existing type/name changes, requires omitted existing blocks to be declared in `removedBlockIds`, cascades container descendant removal, initializes new block defaults from block metadata, and emits stable edge ids. +- `apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts` adds `parseGraphOnlyWorkflowMermaid()` and `serializeWorkflowToGraphMermaid()`. The parser accepts minimal flowchart nodes/subgraphs/edges, supports hyphenated ids, forbids `%% TG_*` metadata and block-internal fields, validates canonical condition handles, and errors on unknown edge node ids. +- `apps/tradinggoose/lib/copilot/runtime-tool-manifest.ts`, `apps/tradinggoose/lib/copilot/runtime-tool-manifest-enrichment.ts`, `apps/tradinggoose/lib/copilot/server-tool-errors.ts`, and `apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts` shift runtime guidance and repair responses from full `tg-mermaid-v1` documents to graph-only edit documents. +- `apps/tradinggoose/lib/copilot/entity-documents.ts`, `apps/tradinggoose/lib/copilot/tools/client/entities/entity-document-tool-utils.ts`, `apps/tradinggoose/app/api/indicators/custom/route.ts`, `apps/tradinggoose/hooks/queries/indicators.ts`, and `apps/tradinggoose/lib/indicators/import-export.ts` remove `color` from the indicator document/create/update/import/export surface. +- `apps/tradinggoose/app/api/workflows/route.ts`, `apps/tradinggoose/app/api/workflows/[id]/route.ts`, `apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts`, `apps/tradinggoose/hooks/queries/workflows.ts`, `apps/tradinggoose/stores/workflows/registry/store.ts`, and `apps/tradinggoose/lib/workflows/import-export.ts` remove user-supplied workflow color from create/update/duplicate/import/export paths. +- `apps/tradinggoose/lib/import-export/trading-goose.ts` remains the canonical `version: "1"` TradingGoose export envelope. `apps/tradinggoose/lib/watchlists/types.ts` now reuses `TradingGooseExportEnvelope` instead of copying the shared envelope fields. + +### Design Decisions +- Completion tokens are the only locally billed Copilot usage in this branch. Context usage remains useful for UI display and context-window telemetry, but it no longer creates local billing records or context commit actions. +- Reservation mutation is centralized around `commitCopilotUsageReservation()` so billing operations can own their commit lifecycle instead of requiring a separate adjust step before completion usage is known. +- Completion usage mirroring is intentionally kept inside the Studio proxy routes. `billing.completion_usage` is a server-side accounting event and should not become a client-visible Copilot message or UI event. +- `edit_workflow` now edits topology only. Full `tg-mermaid-v1` inspection documents are still produced for read paths, but mutation input is the graph-only format owned by `WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT`. +- Existing workflow block ids are immutable identities for graph edits. New blocks must supply canonical block `type` labels, while existing block internals, names, condition branch definitions, and sub-block values stay under `edit_workflow_block`. +- `removedBlockIds` is the explicit deletion contract for graph edits. Omitting an existing block without this list is treated as an invalid edit rather than an implicit delete. +- Workflow and indicator colors remain stored/displayable internally, but they are no longer part of user-authored documents, exports, Copilot entity fields, or create/update request contracts. + +### Shared Contracts and Helpers to Reuse +- Use `mirrorLocalCopilotCompletionUsageReports()` from `apps/tradinggoose/app/api/copilot/usage/route.ts` whenever a Studio proxy receives Copilot completion usage reports that need local self-hosted billing. +- Use `commitCopilotUsageReservation()` from `apps/tradinggoose/lib/copilot/usage-reservations.ts` for operations that must release a Copilot usage reservation after a successful or failed billing commit. Do not recreate reservation release wrappers in route handlers. +- Use `CompletionUsageReportSchema` shape at the route boundary: `{ kind: "completion", model, usage, completionId, workflowId? }`. Billing idempotency belongs to `copilot-completion-billing:`. +- Use `WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT` from `apps/tradinggoose/lib/workflows/document-format.ts` for `edit_workflow` mutation results. Keep `TG_MERMAID_DOCUMENT_FORMAT` for full read/inspection serialization. +- Use `parseGraphOnlyWorkflowMermaid()` and `serializeWorkflowToGraphMermaid()` from `apps/tradinggoose/lib/workflows/studio-workflow-mermaid.ts` for graph-only workflow mutation input/output instead of parsing or emitting ad hoc Mermaid. +- Use `buildWorkflowMutationResult()` from `apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.ts` with its `documentFormat` option when a workflow mutation should return graph-only Mermaid. +- Use `buildInitialSubBlockStates()` from `apps/tradinggoose/lib/workflows/subblock-values.ts` for new block initialization. It is now shared by graph-only workflow edits, `apps/tradinggoose/lib/yjs/use-workflow-doc.ts`, and `apps/tradinggoose/stores/workflows/workflow/store.ts`. +- Use `getAbsoluteBlockPosition()` from `apps/tradinggoose/lib/workflows/workflow-direction.ts` when moving existing blocks across container parents while preserving absolute canvas placement. +- Use `TradingGooseExportEnvelopeSchema`, `TradingGooseExportEnvelope`, and `createTradingGooseExportFile()` from `apps/tradinggoose/lib/import-export/trading-goose.ts` for import/export resources instead of copying envelope fields into resource-specific types. +- Use `shouldRequireToolApproval()` from `apps/tradinggoose/lib/copilot/access-policy.ts` with `isGatedTool()` from `apps/tradinggoose/stores/copilot/tool-registry.ts` when deciding whether a Copilot tool can auto-run. + +### Removed or Replaced Items +- Removed local context billing options and context commit handling from `apps/tradinggoose/app/api/copilot/usage/route.ts`. Do not reintroduce `bill`, `assistantMessageId`, `billingModel`, or `kind: "context"` commit payloads; use completion usage reports for billing. +- Removed `adjustCopilotUsageReservation()` from `apps/tradinggoose/lib/copilot/usage-reservations.ts` and the `/api/copilot/usage` `action: "adjust"` path. Use `reserve`, `commit` with `kind: "completion"`, and `release`. +- Removed client forwarding of `billing.completion_usage` SSE events in Copilot chat and mark-complete routes. Keep these events server-side and mirror through `mirrorLocalCopilotCompletionUsageReports()`. +- Removed `documentFormat` from `edit_workflow` input in `apps/tradinggoose/lib/copilot/registry.ts` and `apps/tradinggoose/lib/copilot/tools/client/workflow/edit-workflow.ts`. The replacement mutation contract is graph-only `entityDocument` plus optional `removedBlockIds`. +- Removed full `%% TG_WORKFLOW`, `%% TG_BLOCK`, `%% TG_EDGE`, `%% TG_LOOP`, and `%% TG_PARALLEL` mutation input expectations for `edit_workflow`. Use minimal graph Mermaid for topology edits and reserve `tg-mermaid-v1` metadata for read/inspection documents. +- Removed workflow runtime manifest validators that required `tg-mermaid-v1` metadata in `apps/tradinggoose/lib/copilot/runtime-tool-manifest-enrichment.ts`. The replacement edit-workflow validators live in `apps/tradinggoose/lib/copilot/runtime-tool-manifest.ts` and require real newlines, a `flowchart` prefix, and no `%% TG_` metadata. +- Removed `color` from indicator document schemas, Copilot entity fields, indicator create/update request payloads, and indicator import/export records. Do not re-add `document.color`; indicators should get stable display colors internally from `getStableVibrantColor()`. +- Removed `color` from workflow create/update/duplicate/import/export payloads and registry create/update option types. Do not pass user-authored workflow colors through APIs; workflow records still derive stable display colors internally. +- Removed copied TradingGoose export-envelope fields from `WatchlistImportFile` in `apps/tradinggoose/lib/watchlists/types.ts`. Use `TradingGooseExportEnvelope` from the shared import/export module. + +### Future Branch Guardrails +- Do not bill Copilot context usage locally. If a future branch needs billing, route it through completion usage reports and `commitCopilotUsageReservation()` rather than restoring context commit or adjust actions. +- Do not expose `billing.completion_usage` to the browser stream or persist it as chat content. It is an accounting signal handled by Studio proxy routes. +- Do not send full `read_workflow` `tg-mermaid-v1` documents back into `edit_workflow`. Read documents are inspection artifacts; edit documents are graph-only Mermaid with `WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT`. +- Do not use `edit_workflow` to rename existing blocks, change existing block types, change subBlocks, set condition branch definitions, or delete by omission. Use `edit_workflow_block` for internals and `removedBlockIds` for intentional removals. +- Do not recreate workflow or indicator `color` fields in Copilot documents, import/export records, request schemas, hooks, or registry option types. Keep colors as internal derived display metadata. +- Do not copy the TradingGoose export envelope into new resource-specific types. Extend `TradingGooseExportEnvelopeSchema` for validation and `TradingGooseExportEnvelope` for TypeScript shapes. +- When adding new workflow block creation paths, use `buildInitialSubBlockStates()` and `resolveBlockRuntimeState()` so new blocks get canonical sub-block values and outputs. + +### Validation Notes +- Used the requested `staging-changelog` workflow and followed `changelog/TEMPLATE.md`, with the explicit base override from `origin/staging` to `upstream/staging`. +- Reviewed `git status --short --branch`, `git remote -v`, `git fetch upstream staging`, `git rev-parse fix/copilot-billing`, `git rev-parse upstream/staging`, `git merge-base upstream/staging fix/copilot-billing`, `git log --oneline`, `git diff --stat`, `git diff --name-status --find-renames`, `git diff --summary`, and `git diff --dirstat` for `78a776d35928728bd0389e21070e88a001271ef2..fix/copilot-billing`. +- Inspected the repository instructions, `changelog/TEMPLATE.md`, recent changelog layout, Copilot usage routes, reservation helper, chat and mark-complete proxy paths, Copilot store/access-policy paths, workflow graph parser/serializer, workflow mutation server tool, runtime manifest and error mapping, import/export envelope helpers, indicator/workflow import-export paths, and related API/hooks/store files. +- Reviewed focused test changes in `apps/tradinggoose/app/api/copilot/usage/route.test.ts`, `apps/tradinggoose/stores/copilot/store.test.ts`, `apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts`, `apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.test.ts`, `apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts`, `apps/tradinggoose/lib/copilot/server-tool-errors.test.ts`, indicator import/export tests, workflow import/export tests, workflow importer tests, and workflow API route tests. +- Confirmed the branch delta contains no renamed or deleted files and no `*/migration/*` edits. +- No automated test suite was run for this changelog-only update. Validation focused on merge-base diff review, related source/test inspection, template conformance, and a final changelog diff check. diff --git a/scripts/indicators/generate-copilot-reference.ts b/scripts/indicators/generate-copilot-reference.ts index d146ee563..52e10f048 100644 --- a/scripts/indicators/generate-copilot-reference.ts +++ b/scripts/indicators/generate-copilot-reference.ts @@ -349,11 +349,6 @@ export const generateCopilotIndicatorReference = async () => { detail: 'The `name` field is part of the live indicator document schema and is what TradingGoose renames when Copilot updates an indicator title.', }, - color: { - summary: 'Default display color in the canonical document.', - detail: - 'The `color` field is part of the live indicator document schema and stores the default indicator display color.', - }, pineCode: { summary: 'PineTS authoring source in the canonical document.', detail: From 8fabe739dfa95de8df3556974aaa7199d3c43f9d Mon Sep 17 00:00:00 2001 From: Bruzzz BackUp <149516937+BWJ2310-backup@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:14:22 -0600 Subject: [PATCH 02/18] fix(workflows): unify trigger resolution for editor and queued runs (#142) * feat(workflow): resolve editor trigger input from session snapshot Co-authored-by: Codex * fix(workflows): preserve input trigger snapshot values Co-authored-by: Codex * fix(workflow): stabilize workflow test execution Keep test-input writes on a dedicated origin, prefer connected runnable triggers, and block run until the session is ready.\n\nCo-authored-by: Codex * fix(workflows): preserve queued trigger types Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workflows): require live start block for queued webhook and schedule runs Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workflows): remove test-input Yjs special case Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workflow): block chat-only workflows from editor run Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * feat(workflow): run selected trigger blocks Propagate the selected workflow node into manual execution and resolve editor test runs against the selected trigger block when available. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * refactor(workflow): derive run target from awareness Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workflows): select trigger branch for editor Run Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * feat(workflows): preserve trigger source in queued executions Propagate trigger metadata from editor test runs through the queue client and API so queued executions keep the correct trigger identity. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workflows): resolve queued trigger identity from workflow state Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * refactor(workflows): simplify trigger helpers Remove redundant trigger helper exports and inline the same logic in the existing selection helpers. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workflows): reject chat triggers in editor runs Prevent editor Run from selecting chat triggers and preserve the fallback selection flow for the remaining trigger branches. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * test(workflows): add coverage for editor trigger resolution Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workflows): relax manual queue trigger matching Allow manual queue runs to match non-chat start blocks while preserving exact matching for non-manual triggers. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * test(yjs): cover shared workflow session bootstrap loading Add coverage for the loading window before a shared workflow session publishes a readable document, and factor repeated bootstrap fixture setup into a helper. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workflows): use editor test trigger for client workflow execution Co-authored-by: Codex \nCo-authored-by: BWJ2310 \nCo-authored-by: BWJ2310-backup * feat(workflow): unify workflow trigger resolution Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workflow): preserve trigger identity and live run input Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * refactor(workflows): rename workflow start target to trigger target Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workflows): align trigger selection with trigger-mode blocks Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * feat(workflows): require explicit trigger blocks for workflow runs Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workflows): align run_workflow trigger handling Require exact trigger block ids, remove the legacy start alias, and resolve copilot runs to manual unless the trigger is chat. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * style(workflows): reorder accessible reference imports Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workflows): handle missing trigger blocks consistently Disable schedules and webhooks when their trigger block is missing, tighten schedule creation validation, and keep editor trigger resolution aligned with manual editor runs. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * refactor(workflow): centralize executable workflow data Consolidate executable block and edge filtering into a shared helper, then reuse it across workflow execution paths and tests. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workflows): preserve resolved trigger input for copilot runs Keep explicit copilot workflow input intact when resolving manual runs, and pass the resolved input into queued execution. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(schedules): delete orphaned schedule rows Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * test(resolver): clarify trigger block alias expectation Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --------- Co-authored-by: Codex Co-authored-by: BWJ2310 --- .../app/api/schedules/execute/route.test.ts | 33 +- .../app/api/schedules/execute/route.ts | 147 ++++---- apps/tradinggoose/app/api/schedules/route.ts | 64 +--- .../api/workflows/[id]/execute/route.test.ts | 14 +- .../app/api/workflows/[id]/execute/route.ts | 5 +- .../api/workflows/[id]/queue/route.test.ts | 19 +- .../app/api/workflows/[id]/queue/route.ts | 66 +++- .../indicator-monitor-execution.test.ts | 2 +- .../background/indicator-monitor-execution.ts | 2 +- .../background/portfolio-monitor-execution.ts | 2 +- .../background/schedule-execution.ts | 18 +- .../background/webhook-execution.ts | 9 +- .../background/workflow-execution.test.ts | 10 +- .../background/workflow-execution.ts | 18 +- .../executor/__test-utils__/test-executor.ts | 4 +- apps/tradinggoose/executor/index.test.ts | 34 +- apps/tradinggoose/executor/index.ts | 52 +-- .../executor/resolver/resolver.test.ts | 44 ++- .../executor/resolver/resolver.ts | 270 ++++++-------- .../tests/executor-layer-validation.test.ts | 8 +- .../tests/multi-input-routing.test.ts | 8 +- apps/tradinggoose/executor/types.ts | 1 + .../use-accessible-reference-prefixes.ts | 6 +- .../workflow/use-workflow-execution.test.tsx | 106 +++--- .../hooks/workflow/use-workflow-execution.ts | 158 +++----- .../tradinggoose/lib/block-path-calculator.ts | 2 +- apps/tradinggoose/lib/copilot/registry.ts | 5 + .../lib/copilot/tool-prompt-metadata.ts | 3 +- .../client/workflow/run-workflow.test.ts | 2 + .../tools/client/workflow/run-workflow.ts | 9 + .../workflow/workflow-execution-utils.ts | 48 +-- .../lib/copilot/tools/server/router.test.ts | 11 +- apps/tradinggoose/lib/webhooks/processor.ts | 8 +- .../lib/workflows/execution-runner.test.ts | 28 +- .../lib/workflows/execution-runner.ts | 68 ++-- .../lib/workflows/queued-execution-client.ts | 7 +- .../lib/workflows/triggers.test.ts | 105 ++++++ apps/tradinggoose/lib/workflows/triggers.ts | 341 ++++++++---------- .../lib/workflows/triggers/trigger-utils.ts | 9 + .../lib/yjs/workflow-session-host.tsx | 34 +- .../lib/yjs/workflow-shared-session.test.ts | 120 +++--- .../lib/yjs/workflow-shared-session.ts | 14 +- apps/tradinggoose/services/queue/index.ts | 2 +- apps/tradinggoose/services/queue/types.ts | 1 + .../stores/workflows/registry/store.ts | 8 +- .../stores/workflows/workflow/utils.test.ts | 39 +- .../stores/workflows/workflow/utils.ts | 18 + apps/tradinggoose/triggers/resolution.ts | 23 ++ .../components/control-bar/control-bar.tsx | 103 +++++- .../components/input-format/input-format.tsx | 84 ++--- .../components/chat/chat.test.tsx | 1 + .../workflow_chat/components/chat/chat.tsx | 35 +- 52 files changed, 1220 insertions(+), 1008 deletions(-) create mode 100644 apps/tradinggoose/lib/workflows/triggers.test.ts diff --git a/apps/tradinggoose/app/api/schedules/execute/route.test.ts b/apps/tradinggoose/app/api/schedules/execute/route.test.ts index ce360a70c..bb942537b 100644 --- a/apps/tradinggoose/app/api/schedules/execute/route.test.ts +++ b/apps/tradinggoose/app/api/schedules/execute/route.test.ts @@ -89,7 +89,7 @@ describe('Scheduled Workflow Execution API Route', () => { { id: 'schedule-1', workflowId: 'workflow-1', - blockId: null, + blockId: 'schedule-trigger-1', cronExpression: null, lastRanAt: null, failedCount: 0, @@ -151,7 +151,7 @@ describe('Scheduled Workflow Execution API Route', () => { expect(data.error).toContain('Trigger.dev is required for scheduled executions') }) - it('should queue schedules through pending execution when enabled', async () => { + it('should queue configured schedules and remove orphan schedule rows', async () => { vi.doMock('@/lib/auth/internal', () => ({ verifyCronAuth: vi.fn().mockReturnValue(null), })) @@ -189,18 +189,29 @@ describe('Scheduled Workflow Execution API Route', () => { isPendingExecutionLimitError: vi.fn(() => false), })) + let deletedScheduleWhere: Record | undefined vi.doMock('@tradinggoose/db', () => { const scheduleRows = [ { id: 'schedule-1', workflowId: 'workflow-1', - blockId: null, + blockId: 'schedule-trigger-1', cronExpression: null, lastRanAt: null, failedCount: 0, timezone: 'UTC', nextRunAt: new Date('2024-01-01T00:00:00.000Z'), }, + { + id: 'schedule-missing-trigger', + workflowId: 'workflow-2', + blockId: null, + cronExpression: null, + lastRanAt: null, + failedCount: 1, + timezone: 'UTC', + nextRunAt: new Date('2024-01-01T00:00:00.000Z'), + }, ] const workflowRows = [ @@ -231,6 +242,12 @@ describe('Scheduled Workflow Execution API Route', () => { }), } }), + delete: vi.fn().mockImplementation(() => ({ + where: vi.fn().mockImplementation((condition) => { + deletedScheduleWhere = condition + return Promise.resolve([]) + }), + })), } return { @@ -247,6 +264,12 @@ describe('Scheduled Workflow Execution API Route', () => { expect(response.status).toBe(200) const data = await response.json() expect(data).toHaveProperty('executedCount', 1) + expect(deletedScheduleWhere).toEqual( + expect.objectContaining({ + type: 'eq', + value: 'schedule-missing-trigger', + }) + ) expect(enqueuePendingExecutionMock).toHaveBeenCalledWith( expect.objectContaining({ executionType: 'schedule', @@ -349,7 +372,7 @@ describe('Scheduled Workflow Execution API Route', () => { { id: 'schedule-1', workflowId: 'workflow-1', - blockId: null, + blockId: 'schedule-trigger-1', cronExpression: null, lastRanAt: null, failedCount: 0, @@ -359,7 +382,7 @@ describe('Scheduled Workflow Execution API Route', () => { { id: 'schedule-2', workflowId: 'workflow-2', - blockId: null, + blockId: 'schedule-trigger-2', cronExpression: null, lastRanAt: null, failedCount: 0, diff --git a/apps/tradinggoose/app/api/schedules/execute/route.ts b/apps/tradinggoose/app/api/schedules/execute/route.ts index c99e5fd0b..de99b4c1e 100644 --- a/apps/tradinggoose/app/api/schedules/execute/route.ts +++ b/apps/tradinggoose/app/api/schedules/execute/route.ts @@ -1,8 +1,8 @@ import { db, workflow, workflowSchedule } from '@tradinggoose/db' import { and, eq, lte, not } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { verifyCronAuth } from '@/lib/auth/internal' import { getApiKeyOwnerUserId } from '@/lib/api-key/service' +import { verifyCronAuth } from '@/lib/auth/internal' import { enqueuePendingExecution, isPendingExecutionLimitError, @@ -40,94 +40,95 @@ export async function GET(request: NextRequest) { const queuedSchedules = await Promise.all( dueSchedules.map(async (schedule) => { try { + if (typeof schedule.blockId !== 'string' || schedule.blockId.length === 0) { + logger.warn( + `[${requestId}] Removing schedule ${schedule.id}: missing schedule trigger block.` + ) + await db.delete(workflowSchedule).where(eq(workflowSchedule.id, schedule.id)) + return null + } + const [workflowRecord] = await db .select({ - workspaceId: workflow.workspaceId, - pinnedApiKeyId: workflow.pinnedApiKeyId, + workspaceId: workflow.workspaceId, + pinnedApiKeyId: workflow.pinnedApiKeyId, + }) + .from(workflow) + .where(eq(workflow.id, schedule.workflowId)) + .limit(1) + + if (!workflowRecord) { + logger.warn( + `[${requestId}] Workflow ${schedule.workflowId} not found for schedule ${schedule.id}` + ) + return null + } + + const actorUserId = await getApiKeyOwnerUserId(workflowRecord.pinnedApiKeyId) + + if (!actorUserId) { + logger.warn( + `[${requestId}] Skipping schedule ${schedule.id}: pinned API key required to attribute usage.` + ) + return null + } + + const pendingExecutionId = `schedule_execution:${schedule.id}:${schedule.nextRunAt?.toISOString() ?? now.toISOString()}` + const payload = { + executionId: pendingExecutionId, + scheduleId: schedule.id, + workflowId: schedule.workflowId, + blockId: schedule.blockId, + cronExpression: schedule.cronExpression || undefined, + lastRanAt: schedule.lastRanAt?.toISOString(), + failedCount: schedule.failedCount || 0, + timezone: schedule.timezone, + now: now.toISOString(), + } + + const handle = await enqueuePendingExecution({ + executionType: 'schedule', + pendingExecutionId, + workflowId: schedule.workflowId, + workspaceId: workflowRecord.workspaceId, + userId: actorUserId, + source: 'schedule', + orderingKey: `schedule:${schedule.id}`, + requestId, + payload, }) - .from(workflow) - .where(eq(workflow.id, schedule.workflowId)) - .limit(1) - if (!workflowRecord) { - logger.warn( - `[${requestId}] Workflow ${schedule.workflowId} not found for schedule ${schedule.id}`, - ) - return null - } - - const actorUserId = await getApiKeyOwnerUserId( - workflowRecord.pinnedApiKeyId, - ) + if (!handle.inserted) return null - if (!actorUserId) { - logger.warn( - `[${requestId}] Skipping schedule ${schedule.id}: pinned API key required to attribute usage.`, + logger.info( + `[${requestId}] Queued schedule execution ${handle.pendingExecutionId} for workflow ${schedule.workflowId}` ) - return null - } - - const pendingExecutionId = `schedule_execution:${schedule.id}:${schedule.nextRunAt?.toISOString() ?? now.toISOString()}` - const payload = { - executionId: pendingExecutionId, - scheduleId: schedule.id, - workflowId: schedule.workflowId, - blockId: schedule.blockId || undefined, - cronExpression: schedule.cronExpression || undefined, - lastRanAt: schedule.lastRanAt?.toISOString(), - failedCount: schedule.failedCount || 0, - timezone: schedule.timezone, - now: now.toISOString(), - } - - const handle = await enqueuePendingExecution({ - executionType: 'schedule', - pendingExecutionId, - workflowId: schedule.workflowId, - workspaceId: workflowRecord.workspaceId, - userId: actorUserId, - source: 'schedule', - orderingKey: `schedule:${schedule.id}`, - requestId, - payload, - }) - - if (!handle.inserted) return null - - logger.info( - `[${requestId}] Queued schedule execution ${handle.pendingExecutionId} for workflow ${schedule.workflowId}`, - ) - return handle - } catch (error) { - if (isPendingExecutionLimitError(error)) { - logger.warn( - `[${requestId}] Pending backlog full for schedule ${schedule.id}`, - { + return handle + } catch (error) { + if (isPendingExecutionLimitError(error)) { + logger.warn(`[${requestId}] Pending backlog full for schedule ${schedule.id}`, { workflowId: schedule.workflowId, pendingCount: error.details.pendingCount, maxPendingCount: error.details.maxPendingCount, - }, + }) + return null + } + + if (error instanceof TriggerExecutionUnavailableError) { + throw error + } + + logger.error( + `[${requestId}] Failed to trigger schedule execution for workflow ${schedule.workflowId}`, + error ) return null } - - if (error instanceof TriggerExecutionUnavailableError) { - throw error - } - - logger.error( - `[${requestId}] Failed to trigger schedule execution for workflow ${schedule.workflowId}`, - error - ) - return null - } }) ) const queuedCount = queuedSchedules.filter((result) => result !== null).length - logger.info( - `[${requestId}] Queued ${queuedCount} schedule executions to pending execution`, - ) + logger.info(`[${requestId}] Queued ${queuedCount} schedule executions to pending execution`) return NextResponse.json({ message: 'Scheduled workflow executions processed', diff --git a/apps/tradinggoose/app/api/schedules/route.ts b/apps/tradinggoose/app/api/schedules/route.ts index f12fde77e..92da584ce 100644 --- a/apps/tradinggoose/app/api/schedules/route.ts +++ b/apps/tradinggoose/app/api/schedules/route.ts @@ -5,7 +5,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' -import { resolveTimezoneState } from '@/lib/timezone/timezone-resolver' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { type BlockState, @@ -15,13 +14,14 @@ import { getSubBlockValue, validateCronExpression, } from '@/lib/schedules/utils' +import { resolveTimezoneState } from '@/lib/timezone/timezone-resolver' import { generateRequestId } from '@/lib/utils' const logger = createLogger('ScheduledAPI') const ScheduleRequestSchema = z.object({ workflowId: z.string(), - blockId: z.string().optional(), + blockId: z.string().min(1), state: z.object({ blocks: z.record(z.any()), edges: z.array(z.any()), @@ -212,68 +212,37 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Not authorized to modify this workflow' }, { status: 403 }) } - // Find the target block - prioritize the specific blockId if provided - let targetBlock: BlockState | undefined - if (blockId) { - targetBlock = Object.values(state.blocks).find((block: any) => block.id === blockId) as - | BlockState - | undefined - } else { - targetBlock = Object.values(state.blocks).find( - (block: any) => block.type === 'schedule' - ) as BlockState | undefined - } + const targetBlock = Object.values(state.blocks).find((block: any) => block.id === blockId) as + | BlockState + | undefined if (!targetBlock) { - logger.warn(`[${requestId}] No schedule block found in workflow ${workflowId}`) - return NextResponse.json( - { error: 'No schedule block found in workflow' }, - { status: 400 } - ) + logger.warn(`[${requestId}] Schedule block ${blockId} not found in workflow ${workflowId}`) + return NextResponse.json({ error: 'Schedule block not found in workflow' }, { status: 400 }) } const scheduleType = getSubBlockValue(targetBlock, 'scheduleType') - const isScheduleBlock = targetBlock.type === 'schedule' + if (targetBlock.type !== 'schedule') { + return NextResponse.json({ error: 'Schedule block is required' }, { status: 400 }) + } const scheduleValues = getScheduleTimeValues(targetBlock) const hasValidConfig = hasValidScheduleConfig(scheduleType, scheduleValues, targetBlock) - // Debug logging to understand why validation fails - logger.info(`[${requestId}] Schedule validation debug:`, { - workflowId, - blockId, - blockType: targetBlock.type, - scheduleType, - hasValidConfig, - scheduleValues: { - minutesInterval: scheduleValues.minutesInterval, - dailyTime: scheduleValues.dailyTime, - cronExpression: scheduleValues.cronExpression, - }, - }) - if (!hasValidConfig) { logger.info( `[${requestId}] Removing schedule for workflow ${workflowId} - no valid configuration found` ) - // Build delete conditions - const deleteConditions = [eq(workflowSchedule.workflowId, workflowId)] - if (blockId) { - deleteConditions.push(eq(workflowSchedule.blockId, blockId)) - } - await db .delete(workflowSchedule) - .where(deleteConditions.length > 1 ? and(...deleteConditions) : deleteConditions[0]) + .where( + and(eq(workflowSchedule.workflowId, workflowId), eq(workflowSchedule.blockId, blockId)) + ) return NextResponse.json({ message: 'Schedule removed' }) } - if (isScheduleBlock) { - logger.info(`[${requestId}] Processing schedule trigger block for workflow ${workflowId}`) - } - logger.debug(`[${requestId}] Schedule type for workflow ${workflowId}: ${scheduleType}`) let cronExpression: string | null = null @@ -313,7 +282,12 @@ export async function POST(req: NextRequest) { } } - nextRunAt = calculateNextRunTime(defaultScheduleType, scheduleValues, undefined, utcOffsetMinutes) + nextRunAt = calculateNextRunTime( + defaultScheduleType, + scheduleValues, + undefined, + utcOffsetMinutes + ) logger.debug( `[${requestId}] Generated cron: ${cronExpression}, next run at: ${nextRunAt.toISOString()}` diff --git a/apps/tradinggoose/app/api/workflows/[id]/execute/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/execute/route.test.ts index 50513b392..d885774e6 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/execute/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/execute/route.test.ts @@ -470,7 +470,13 @@ describe('/api/workflows/[id]/execute', () => { expect(createHttpResponseFromBlockMock).toHaveBeenCalledWith(responseResult) }) - it('rejects non-API execution control fields on the deployed execute adapter', async () => { + it.each([ + 'workflowTriggerType', + 'triggerType', + 'executionTarget', + 'startBlockId', + 'triggerBlockId', + ])('rejects %s on the deployed execute adapter', async (field) => { const { POST } = await import('./route') const response = await POST( new NextRequest('https://example.com/api/workflows/workflow-1/execute', { @@ -479,16 +485,14 @@ describe('/api/workflows/[id]/execute', () => { 'Content-Type': 'application/json', 'X-API-Key': 'key-1', }, - body: JSON.stringify({ - workflowTriggerType: 'chat', - }), + body: JSON.stringify({ [field]: 'chat' }), }), { params: Promise.resolve({ id: 'workflow-1' }) } ) expect(response.status).toBe(400) await expect(response.json()).resolves.toMatchObject({ - error: 'Field "workflowTriggerType" is not supported by the deployed API execute endpoint', + error: `Field "${field}" is not supported by the deployed API execute endpoint`, }) expect(enqueuePendingExecutionMock).not.toHaveBeenCalled() }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/execute/route.ts b/apps/tradinggoose/app/api/workflows/[id]/execute/route.ts index d904867e0..e3930f4de 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/execute/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/execute/route.ts @@ -31,13 +31,16 @@ const API_EXECUTION_POLL_INTERVAL_MS = 1_000 const API_EXECUTION_WAIT_TIMEOUT_MS = 25_000 const UNSUPPORTED_API_EXECUTE_FIELDS = [ 'workflowTriggerType', + 'triggerType', + 'executionTarget', + 'startBlockId', + 'triggerBlockId', 'isSecureMode', 'useDraftState', 'isClientSession', 'workflowData', 'workflowStateOverride', 'workflowVariables', - 'startBlockId', 'executionId', ] as const diff --git a/apps/tradinggoose/app/api/workflows/[id]/queue/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/queue/route.test.ts index a6e147bce..69107e314 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/queue/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/queue/route.test.ts @@ -51,6 +51,8 @@ vi.mock('@/lib/utils', () => ({ SSE_HEADERS: { 'Content-Type': 'text/event-stream' }, })) +vi.unmock('@/blocks/registry') + import { POST } from './route' describe('POST /api/workflows/[id]/queue', () => { @@ -142,7 +144,7 @@ describe('POST /api/workflows/[id]/queue', () => { it.each([ { name: 'unsupported trigger type', - body: JSON.stringify({ triggerType: 'webhook' }), + body: JSON.stringify({ triggerType: 'api-endpoint' }), error: 'Unsupported queued workflow trigger type', }, { @@ -150,6 +152,11 @@ describe('POST /api/workflows/[id]/queue', () => { body: JSON.stringify({ executionTarget: 'draft' }), error: 'Unsupported queued workflow execution target', }, + { + name: 'webhook without live trigger block', + body: JSON.stringify({ executionTarget: 'live', triggerType: 'webhook' }), + error: 'Webhook and schedule queued workflow executions require a live trigger block', + }, { name: 'malformed JSON', body: '{', @@ -284,10 +291,10 @@ describe('POST /api/workflows/[id]/queue', () => { expect(enqueuePendingExecutionMock).not.toHaveBeenCalled() }) - it('queues editor live executions with the canonical workflow payload', async () => { + it('queues editor live executions as manual runs with trigger source metadata', async () => { const workflowData = { blocks: { - 'trigger-1': { id: 'trigger-1', type: 'manual_trigger' }, + 'trigger-1': { id: 'trigger-1', type: 'schedule' }, }, edges: [], loops: {}, @@ -304,7 +311,7 @@ describe('POST /api/workflows/[id]/queue', () => { triggerType: 'manual', workflowData, workflowVariables: { risk: { value: 1 } }, - startBlockId: 'trigger-1', + triggerBlockId: 'trigger-1', }), headers: { 'Content-Type': 'application/json', @@ -323,10 +330,12 @@ describe('POST /api/workflows/[id]/queue', () => { payload: expect.objectContaining({ executionId: 'execution-1', input: { symbol: 'AAPL' }, + triggerType: 'manual', executionTarget: 'live', workflowData, workflowVariables: { risk: { value: 1 } }, - startBlockId: 'trigger-1', + triggerBlockId: 'trigger-1', + triggerData: { source: 'schedule' }, }), }) ) diff --git a/apps/tradinggoose/app/api/workflows/[id]/queue/route.ts b/apps/tradinggoose/app/api/workflows/[id]/queue/route.ts index ba987a865..4a3a9237d 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/queue/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/queue/route.ts @@ -11,10 +11,11 @@ import { TriggerExecutionUnavailableError } from '@/lib/trigger/settings' import { generateRequestId, SSE_HEADERS } from '@/lib/utils' import type { WorkflowExecutionBlueprint } from '@/lib/workflows/execution-runner' import { readWorkflowAccessContext } from '@/lib/workflows/utils' +import type { QueuedWorkflowTriggerType } from '@/services/queue' +import { resolveTriggerExecutionIdentity } from '@/triggers/resolution' const logger = createLogger('WorkflowQueueAPI') -type QueuedWorkflowTriggerType = 'api' | 'manual' | 'chat' type QueuedWorkflowExecutionTarget = 'deployed' | 'live' type QueueRequestBody = { @@ -24,7 +25,7 @@ type QueueRequestBody = { triggerType?: unknown workflowData?: WorkflowExecutionBlueprint['workflowData'] workflowVariables?: Record - startBlockId?: string + triggerBlockId?: string selectedOutputs?: string[] stream?: boolean workflowDepth?: number @@ -32,7 +33,9 @@ type QueueRequestBody = { function readQueuedWorkflowTriggerType(value: unknown): QueuedWorkflowTriggerType | null { if (value === undefined) return 'manual' - if (value === 'api' || value === 'manual' || value === 'chat') return value + if (['api', 'manual', 'chat', 'webhook', 'schedule'].includes(value as string)) { + return value as QueuedWorkflowTriggerType + } return null } @@ -58,10 +61,36 @@ function hasLiveWorkflowState(body: QueueRequestBody) { return ( body.workflowData !== undefined || body.workflowVariables !== undefined || - (typeof body.startBlockId === 'string' && body.startBlockId.length > 0) + (typeof body.triggerBlockId === 'string' && body.triggerBlockId.length > 0) ) } +function resolveQueuedTriggerData( + body: QueueRequestBody, + executionTarget: QueuedWorkflowExecutionTarget, + triggerType: QueuedWorkflowTriggerType +) { + if (executionTarget !== 'live' || typeof body.triggerBlockId !== 'string') { + return undefined + } + + const block = body.workflowData?.blocks?.[body.triggerBlockId] + if (!block) { + throw new Error('Queued workflow trigger block was not found in live workflow state') + } + + const identity = resolveTriggerExecutionIdentity(block) + const isManualEditorRun = triggerType === 'manual' + const triggerTypeMatchesBlock = isManualEditorRun + ? identity.triggerType !== 'chat' + : identity.triggerType === triggerType + if (!triggerTypeMatchesBlock) { + throw new Error('Queued workflow trigger type does not match the trigger block') + } + + return { source: identity.triggerSource } +} + export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() const { id: workflowId } = await params @@ -118,7 +147,17 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ { status: 400 } ) } - + if ( + (triggerType === 'webhook' || triggerType === 'schedule') && + (executionTarget !== 'live' || + typeof body.triggerBlockId !== 'string' || + body.triggerBlockId.length === 0) + ) { + return NextResponse.json( + { error: 'Webhook and schedule queued workflow executions require a live trigger block' }, + { status: 400 } + ) + } if ( !accessContext.isOwner && !accessContext.isWorkspaceOwner && @@ -133,6 +172,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ typeof body.executionId === 'string' && body.executionId.length > 0 ? body.executionId : `workflow_execution_${randomUUID()}` + let triggerData: { source: string } | undefined + try { + triggerData = resolveQueuedTriggerData(body, executionTarget, triggerType) + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Queued workflow trigger block is not runnable' + return NextResponse.json({ error: errorMessage }, { status: 400 }) + } const handle = await enqueuePendingExecution({ executionType: 'workflow', pendingExecutionId, @@ -153,12 +200,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ workflowVariables: executionTarget === 'live' ? body.workflowVariables : undefined, selectedOutputs: body.selectedOutputs, stream: body.stream === true, - startBlockId: + triggerBlockId: executionTarget === 'live' && - typeof body.startBlockId === 'string' && - body.startBlockId.length > 0 - ? body.startBlockId + typeof body.triggerBlockId === 'string' && + body.triggerBlockId.length > 0 + ? body.triggerBlockId : undefined, + ...(triggerData ? { triggerData } : {}), workflowDepth: typeof body.workflowDepth === 'number' ? body.workflowDepth : 0, metadata: { source, diff --git a/apps/tradinggoose/background/indicator-monitor-execution.test.ts b/apps/tradinggoose/background/indicator-monitor-execution.test.ts index e5e47b3a4..6b4da16d9 100644 --- a/apps/tradinggoose/background/indicator-monitor-execution.test.ts +++ b/apps/tradinggoose/background/indicator-monitor-execution.test.ts @@ -125,7 +125,7 @@ describe('executeIndicatorMonitorJob', () => { workspaceId: 'workspace-1', triggerType: 'webhook', executionTarget: 'deployed', - startBlockId: 'trigger-block', + triggerBlockId: 'trigger-block', }), }) ) diff --git a/apps/tradinggoose/background/indicator-monitor-execution.ts b/apps/tradinggoose/background/indicator-monitor-execution.ts index 8391b64ca..c0d6f8dd7 100644 --- a/apps/tradinggoose/background/indicator-monitor-execution.ts +++ b/apps/tradinggoose/background/indicator-monitor-execution.ts @@ -272,7 +272,7 @@ export async function executeIndicatorMonitorJob(payload: IndicatorMonitorExecut input: budgetResult.payload, triggerType: 'webhook', executionTarget: 'deployed', - startBlockId: payload.monitor.blockId, + triggerBlockId: payload.monitor.blockId, triggerData: { source: INDICATOR_MONITOR_TRIGGER_ID, executionTarget: 'deployed', diff --git a/apps/tradinggoose/background/portfolio-monitor-execution.ts b/apps/tradinggoose/background/portfolio-monitor-execution.ts index 9c748711a..a502633e5 100644 --- a/apps/tradinggoose/background/portfolio-monitor-execution.ts +++ b/apps/tradinggoose/background/portfolio-monitor-execution.ts @@ -79,7 +79,7 @@ export async function executePortfolioMonitorJob(payload: PortfolioMonitorExecut workflowInput, executionTarget: 'deployed', workflowContext: { workspaceId: payload.monitor.workspaceId }, - start: { + triggerTarget: { kind: 'block', blockId: payload.monitor.blockId, }, diff --git a/apps/tradinggoose/background/schedule-execution.ts b/apps/tradinggoose/background/schedule-execution.ts index 7aba4e817..c8ff4891f 100644 --- a/apps/tradinggoose/background/schedule-execution.ts +++ b/apps/tradinggoose/background/schedule-execution.ts @@ -26,7 +26,7 @@ export type ScheduleExecutionPayload = { scheduleId: string workflowId: string executionId?: string - blockId?: string + blockId: string cronExpression?: string lastRanAt?: string failedCount?: number @@ -43,18 +43,19 @@ export function isScheduleExecutionPayload(value: unknown): value is ScheduleExe return ( typeof candidate.scheduleId === 'string' && typeof candidate.workflowId === 'string' && + typeof candidate.blockId === 'string' && typeof candidate.timezone === 'string' && typeof candidate.now === 'string' ) } async function calculateNextRunTime( - schedule: { cronExpression?: string; lastRanAt?: string }, + schedule: { blockId: string; cronExpression?: string; lastRanAt?: string }, blocks: Record, timezone: string ): Promise { - const scheduleBlock = Object.values(blocks).find((block) => block.type === 'schedule') - if (!scheduleBlock) throw new Error('No schedule trigger block found') + const scheduleBlock = blocks[schedule.blockId] + if (!scheduleBlock) throw new Error(`Schedule trigger block ${schedule.blockId} not found`) const scheduleType = getSubBlockValue(scheduleBlock, 'scheduleType') const scheduleValues = getScheduleTimeValues(scheduleBlock) @@ -184,10 +185,11 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { }) const scheduleBlocks = blueprint.workflowData.blocks as Record - if (payload.blockId && !scheduleBlocks[payload.blockId]) { + if (!scheduleBlocks[payload.blockId]) { logger.warn( - `[${requestId}] Schedule trigger block ${payload.blockId} not found in deployed workflow ${payload.workflowId}. Skipping execution.` + `[${requestId}] Schedule trigger block ${payload.blockId} not found in deployed workflow ${payload.workflowId}. Removing schedule.` ) + await db.delete(workflowSchedule).where(eq(workflowSchedule.id, payload.scheduleId)) return } @@ -202,9 +204,9 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { workflowId: payload.workflowId, }, }, - start: { + triggerTarget: { kind: 'block', - blockId: payload.blockId || undefined, + blockId: payload.blockId, }, }) diff --git a/apps/tradinggoose/background/webhook-execution.ts b/apps/tradinggoose/background/webhook-execution.ts index 3c4372f59..4629be449 100644 --- a/apps/tradinggoose/background/webhook-execution.ts +++ b/apps/tradinggoose/background/webhook-execution.ts @@ -63,7 +63,7 @@ export type WebhookExecutionPayload = { provider: string body: any headers: Record - blockId?: string + blockId: string testMode?: boolean executionTarget?: 'deployed' | 'live' } @@ -78,7 +78,8 @@ export function isWebhookExecutionPayload(value: unknown): value is WebhookExecu typeof candidate.webhookId === 'string' && typeof candidate.workflowId === 'string' && typeof candidate.userId === 'string' && - typeof candidate.provider === 'string' + typeof candidate.provider === 'string' && + typeof candidate.blockId === 'string' ) } @@ -257,7 +258,7 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) { executionId, triggerType: 'webhook', workflowInput: airtableInput, - start: { + triggerTarget: { kind: 'block', blockId: payload.blockId, }, @@ -348,7 +349,7 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) { executionId, triggerType: 'webhook', workflowInput: input || {}, - start: { + triggerTarget: { kind: 'block', blockId: payload.blockId, }, diff --git a/apps/tradinggoose/background/workflow-execution.test.ts b/apps/tradinggoose/background/workflow-execution.test.ts index c354156b5..15550a434 100644 --- a/apps/tradinggoose/background/workflow-execution.test.ts +++ b/apps/tradinggoose/background/workflow-execution.test.ts @@ -153,7 +153,7 @@ describe('executeWorkflowJob', () => { executionTarget: 'live', workflowData, workflowVariables: { risk: { value: 1 } }, - startBlockId: 'trigger-1', + triggerBlockId: 'trigger-1', metadata: { source: 'workflow_queue', }, @@ -170,7 +170,7 @@ describe('executeWorkflowJob', () => { workspaceId: 'workspace-1', variables: { risk: { value: 1 } }, }, - start: { + triggerTarget: { kind: 'block', blockId: 'trigger-1', }, @@ -178,7 +178,7 @@ describe('executeWorkflowJob', () => { ) }) - it('preserves manual queued starts when no explicit start block is supplied', async () => { + it('preserves manual queued starts when no explicit trigger block is supplied', async () => { await executeWorkflowJob({ workflowId: 'workflow-1', userId: 'user-1', @@ -191,7 +191,7 @@ describe('executeWorkflowJob', () => { expect(runWorkflowExecutionMock).toHaveBeenCalledWith( expect.objectContaining({ triggerType: 'manual', - start: { + triggerTarget: { kind: 'trigger', triggerType: 'manual', }, @@ -227,7 +227,7 @@ describe('executeWorkflowJob', () => { workflowId: 'workflow-1', userId: 'user-1', triggerType: 'webhook', - startBlockId: 'trigger-1', + triggerBlockId: 'trigger-1', triggerData: { source: 'indicator_trigger', monitor: { id: 'monitor-1' }, diff --git a/apps/tradinggoose/background/workflow-execution.ts b/apps/tradinggoose/background/workflow-execution.ts index 23d17e114..e12502e9b 100644 --- a/apps/tradinggoose/background/workflow-execution.ts +++ b/apps/tradinggoose/background/workflow-execution.ts @@ -8,14 +8,14 @@ import { createWorkflowExecutionTerminalEventInput } from '@/lib/workflows/execu import { runWorkflowExecution, type WorkflowExecutionBlueprint, - type WorkflowStart, + type WorkflowTriggerTarget, } from '@/lib/workflows/execution-runner' import type { TriggerType } from '@/services/queue' import { disableMonitor } from './monitor-disable' const logger = createLogger('TriggerWorkflowExecution') -type WorkflowStartTriggerType = Extract['triggerType'] +type WorkflowTriggerTargetType = Extract['triggerType'] export type WorkflowExecutionPayload = { workflowId: string @@ -24,7 +24,7 @@ export type WorkflowExecutionPayload = { executionId?: string input?: any triggerType?: TriggerType - startBlockId?: string + triggerBlockId?: string executionTarget?: 'deployed' | 'live' workflowData?: WorkflowExecutionBlueprint['workflowData'] workflowVariables?: Record @@ -35,11 +35,11 @@ export type WorkflowExecutionPayload = { metadata?: Record } -function resolveWorkflowStartTriggerType(triggerType: TriggerType): WorkflowStartTriggerType { +function resolveWorkflowTriggerTargetType(triggerType: TriggerType): WorkflowTriggerTargetType { if (triggerType === 'chat') return 'chat' if (triggerType === 'api' || triggerType === 'api-endpoint') return 'api' if (triggerType === 'manual') return 'manual' - throw new Error(`Queued ${triggerType} workflow execution requires an explicit start block`) + throw new Error(`Queued ${triggerType} workflow execution requires an explicit trigger block`) } export function isWorkflowExecutionPayload( @@ -68,14 +68,14 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) { const isLiveExecution = executionTarget === 'live' const isChildExecution = payload.metadata?.source === 'workflow_block' const triggerType = payload.triggerType ?? 'manual' - const start: WorkflowStart = payload.startBlockId + const triggerTarget: WorkflowTriggerTarget = payload.triggerBlockId ? { kind: 'block', - blockId: payload.startBlockId, + blockId: payload.triggerBlockId, } : { kind: 'trigger', - triggerType: resolveWorkflowStartTriggerType(triggerType), + triggerType: resolveWorkflowTriggerTargetType(triggerType), } logger.info(`[${requestId}] Starting workflow execution: ${workflowId}`, { @@ -112,7 +112,7 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) { } : undefined, workflowData: isLiveExecution ? payload.workflowData : undefined, - start, + triggerTarget, triggerData, contextExtensions: { workflowDepth: payload.workflowDepth ?? 0, diff --git a/apps/tradinggoose/executor/__test-utils__/test-executor.ts b/apps/tradinggoose/executor/__test-utils__/test-executor.ts index 281865b29..caded0eb6 100644 --- a/apps/tradinggoose/executor/__test-utils__/test-executor.ts +++ b/apps/tradinggoose/executor/__test-utils__/test-executor.ts @@ -16,11 +16,11 @@ export class TestExecutor extends Executor { /** * Override the execute method to return a pre-defined result for testing */ - async execute(workflowId: string): Promise { + async execute(workflowId: string, triggerBlockId: string): Promise { try { // Call validateWorkflow to ensure we validate the workflow // even though we're not actually executing it - ;(this as any).validateWorkflow() + ;(this as any).validateWorkflow(triggerBlockId) // Return a successful result return { diff --git a/apps/tradinggoose/executor/index.test.ts b/apps/tradinggoose/executor/index.test.ts index c0ee1940c..933ea25f4 100644 --- a/apps/tradinggoose/executor/index.test.ts +++ b/apps/tradinggoose/executor/index.test.ts @@ -141,7 +141,7 @@ describe('Executor', () => { const validateSpy = vi.spyOn(executor as any, 'validateWorkflow') validateSpy.mockClear() - await executor.execute('test-workflow-id') + await executor.execute('test-workflow-id', 'trigger') expect(validateSpy).toHaveBeenCalledTimes(1) }) @@ -283,7 +283,7 @@ describe('Executor', () => { const workflow = createMinimalWorkflow() const executor = createTestExecutor(workflow) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') expect(result).toHaveProperty('success') expect(result).toHaveProperty('output') @@ -302,7 +302,7 @@ describe('Executor', () => { }, }) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') expect(result).toHaveProperty('success') expect(result).toHaveProperty('output') @@ -326,7 +326,7 @@ describe('Executor', () => { // Spy on createExecutionContext to verify context extensions are passed const createContextSpy = vi.spyOn(executor as any, 'createExecutionContext') - await executor.execute('test-workflow-id') + await executor.execute('test-workflow-id', 'trigger') expect(createContextSpy).toHaveBeenCalled() const contextArg = createContextSpy.mock.calls[0][2] // third argument is startTime, context is created internally @@ -341,7 +341,7 @@ describe('Executor', () => { const workflow = createWorkflowWithCondition() const executor = createTestExecutor(workflow) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') // Verify execution completes and returns expected structure if ('success' in result) { @@ -357,7 +357,7 @@ describe('Executor', () => { const workflow = createWorkflowWithLoop() const executor = createTestExecutor(workflow) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') expect(result).toHaveProperty('success') expect(result).toHaveProperty('output') @@ -555,7 +555,7 @@ describe('Executor', () => { }, }) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') expect(result).toHaveProperty('success') expect(result).toHaveProperty('output') @@ -573,7 +573,7 @@ describe('Executor', () => { const createContextSpy = vi.spyOn(executor as any, 'createExecutionContext') - await executor.execute('test-workflow-id') + await executor.execute('test-workflow-id', 'trigger') expect(createContextSpy).toHaveBeenCalled() }) @@ -610,7 +610,7 @@ describe('Executor', () => { }, ] - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') expect(result.success).toBe(false) expect(result.error).toContain('Provider stream failed') @@ -913,7 +913,7 @@ describe('Executor', () => { executor.cancel() // Try to execute - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') // Should immediately return cancelled result if ('success' in result) { @@ -931,7 +931,7 @@ describe('Executor', () => { ;(executor as any).isCancelled = true - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') // Should return cancelled result if ('success' in result) { @@ -1019,7 +1019,7 @@ describe('Executor', () => { updateExecutionPaths: vi.fn(), } - const result = await executor.execute('test-workflow') + const result = await executor.execute('test-workflow', 'trigger') // Should succeed with partial results - not throw an error expect(result).toBeDefined() @@ -1143,7 +1143,7 @@ describe('Executor', () => { workflowInput: {}, }) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') // Verify execution completed (may succeed or fail depending on child workflow availability) expect(result).toBeDefined() @@ -1192,7 +1192,7 @@ describe('Executor', () => { }) // Verify that child executor is created with isChildExecution flag - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') expect(result).toBeDefined() if ('success' in result) { @@ -1274,7 +1274,7 @@ describe('Executor', () => { workflowInput: {}, }) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') // Verify execution completed (may succeed or fail depending on child workflow availability) expect(result).toBeDefined() @@ -1342,7 +1342,7 @@ describe('Executor', () => { workflowInput: {}, }) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') // Verify execution completed (may succeed or fail depending on child workflow availability) expect(result).toBeDefined() @@ -1396,7 +1396,7 @@ describe('Executor', () => { workflowInput: {}, }) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') // Verify that child workflow errors propagate to parent expect(result).toBeDefined() diff --git a/apps/tradinggoose/executor/index.ts b/apps/tradinggoose/executor/index.ts index 3265c396b..2b10dcf92 100644 --- a/apps/tradinggoose/executor/index.ts +++ b/apps/tradinggoose/executor/index.ts @@ -260,10 +260,10 @@ export class Executor { * Executes the workflow and returns the result. * * @param workflowId - Unique identifier for the workflow execution - * @param startBlockId - Optional block ID to start execution from (for webhook or schedule triggers) + * @param triggerBlockId - Trigger block ID to execute from * @returns Execution result containing output, logs, and metadata */ - async execute(workflowId: string, startBlockId?: string): Promise { + async execute(workflowId: string, triggerBlockId: string): Promise { const startTime = new Date() let finalOutput: NormalizedBlockOutput = {} @@ -275,9 +275,9 @@ export class Executor { startTime: startTime.toISOString(), }) - this.validateWorkflow(startBlockId) + this.validateWorkflow(triggerBlockId) - const context = this.createExecutionContext(workflowId, startTime, startBlockId) + const context = this.createExecutionContext(workflowId, startTime, triggerBlockId) try { let hasMoreLayers = true @@ -517,16 +517,15 @@ export class Executor { * Validates that the workflow meets requirements for execution. * Ensures trigger blocks exist along with valid connections and loop configurations. * - * @param startBlockId - Optional specific block to start from + * @param triggerBlockId - Trigger block to execute from * @throws Error if workflow validation fails */ - private validateWorkflow(startBlockId?: string): void { - if (startBlockId) { - const startBlock = this.actualWorkflow.blocks.find((block) => block.id === startBlockId) - if (!startBlock || !startBlock.enabled) { - throw new Error(`Start block ${startBlockId} not found or disabled`) + private validateWorkflow(triggerBlockId?: string): void { + if (triggerBlockId !== undefined) { + const triggerBlock = this.actualWorkflow.blocks.find((block) => block.id === triggerBlockId) + if (!triggerBlock || !triggerBlock.enabled) { + throw new Error(`Trigger block ${triggerBlockId} not found or disabled`) } - return } // Check for any type of trigger block (dedicated triggers or trigger-mode blocks) @@ -584,13 +583,13 @@ export class Executor { * * @param workflowId - Unique identifier for the workflow execution * @param startTime - Execution start time - * @param startBlockId - Optional specific block to start from + * @param triggerBlockId - Trigger block to execute from * @returns Initialized execution context */ private createExecutionContext( workflowId: string, startTime: Date, - startBlockId?: string + triggerBlockId: string ): ExecutionContext { const workspaceId = this.requireExecutionWorkspaceId() const context: ExecutionContext = { @@ -601,6 +600,7 @@ export class Executor { workflowLogId: this.contextExtensions.workflowLogId, submissionSource: this.contextExtensions.submissionSource, triggerType: this.contextExtensions.triggerType, + triggerBlockId: undefined, workflowDepth: this.contextExtensions.workflowDepth ?? 0, isDeployedContext: this.contextExtensions.isDeployedContext || false, blockStates: new Map(), @@ -645,31 +645,11 @@ export class Executor { } } - // Determine which block to initialize as the starting point - let initBlock: SerializedBlock | undefined - if (startBlockId) { - initBlock = this.actualWorkflow.blocks.find((block) => block.id === startBlockId) - } else if (this.isChildExecution) { - const inputTriggerBlocks = this.actualWorkflow.blocks.filter( - (block) => block.metadata?.id === 'input_trigger' - ) - if (inputTriggerBlocks.length === 1) { - initBlock = inputTriggerBlocks[0] - } else if (inputTriggerBlocks.length > 1) { - throw new Error('Child workflow has multiple Input Trigger blocks. Keep only one.') - } - } else { - const triggerBlocks = this.actualWorkflow.blocks.filter((block) => - isSerializedTriggerBlock(block) - ) - if (triggerBlocks.length > 0) { - initBlock = triggerBlocks[0] - } - } - + const initBlock = this.actualWorkflow.blocks.find((block) => block.id === triggerBlockId) if (!initBlock) { - throw new Error('Unable to determine a trigger block to initialize') + throw new Error(`Trigger block ${triggerBlockId} not found or disabled`) } + context.triggerBlockId = initBlock.id // Remove any pre-populated state for the init block so we can inject runtime trigger input. if (context.blockStates.has(initBlock.id)) { diff --git a/apps/tradinggoose/executor/resolver/resolver.test.ts b/apps/tradinggoose/executor/resolver/resolver.test.ts index 5f78ff543..18da3034f 100644 --- a/apps/tradinggoose/executor/resolver/resolver.test.ts +++ b/apps/tradinggoose/executor/resolver/resolver.test.ts @@ -87,6 +87,7 @@ describe('InputResolver', () => { mockContext = { workflowId: 'test-workflow', workflow: sampleWorkflow, + triggerBlockId: 'trigger-block', blockStates: new Map([ [ 'trigger-block', @@ -341,7 +342,7 @@ describe('InputResolver', () => { expect(result.nameRef).toBe('Hello World') // Should resolve using block name }) - it('should handle the special "start" alias for trigger block', () => { + it('should resolve the runtime trigger block through ', () => { const block: SerializedBlock = { id: 'test-block', metadata: { id: 'generic', name: 'Test Block' }, @@ -1338,6 +1339,7 @@ describe('InputResolver', () => { contextWithConnections = { workflowId: 'test-workflow', workspaceId: 'test-workspace-id', + triggerBlockId: 'trigger-1', blockStates: new Map([ ['trigger-1', { output: { input: 'Hello World' }, executed: true, executionTime: 0 }], ['agent-1', { output: { content: 'Agent response' }, executed: true, executionTime: 0 }], @@ -1446,6 +1448,43 @@ describe('InputResolver', () => { expect(result.code).toBe('return "Hello World"') // Should be quoted for function blocks }) + it('resolves start references from the runtime trigger block', () => { + const workflow: SerializedWorkflow = { + ...workflowWithConnections, + blocks: [ + ...workflowWithConnections.blocks, + { + id: 'schedule-trigger', + metadata: { id: 'schedule', name: 'Schedule', category: 'triggers' }, + position: { x: 0, y: 0 }, + config: { tool: 'schedule', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + ], + } + const resolver = createInputResolver(workflow) + const context = { + ...contextWithConnections, + workflow, + triggerBlockId: 'schedule-trigger', + blockStates: new Map([ + ...contextWithConnections.blockStates, + ['trigger-1', { output: { symbol: 'WRONG' }, executed: true, executionTime: 0 }], + ['schedule-trigger', { output: { symbol: 'AAPL' }, executed: true, executionTime: 0 }], + ]), + } + + const result = resolver.resolveBlockReferences( + 'return ', + context, + workflow.blocks.find((block) => block.id === 'function-1')! + ) + + expect(result).toBe('return AAPL') + }) + it('should format start.input properly for different block types', () => { // Test function block - should quote strings const functionBlock: SerializedBlock = { @@ -2611,7 +2650,7 @@ describe('InputResolver', () => { expect(result.deep4).toBe('12') }) - it.concurrent('should handle start block with 2D array access', () => { + it.concurrent('should handle trigger input with 2D array access', () => { arrayContext.blockStates.set('trigger-block', { output: { input: 'Hello World', @@ -3135,6 +3174,7 @@ describe('InputResolver', () => { workflowId: 'test-parallel-workflow', workspaceId: 'test-workspace-id', workflow: parallelWorkflow, + triggerBlockId: 'start-block', blockStates: new Map([ [ 'function1-block', diff --git a/apps/tradinggoose/executor/resolver/resolver.ts b/apps/tradinggoose/executor/resolver/resolver.ts index 85d0144ab..82a3786f1 100644 --- a/apps/tradinggoose/executor/resolver/resolver.ts +++ b/apps/tradinggoose/executor/resolver/resolver.ts @@ -1,8 +1,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { VariableManager } from '@/lib/variables/variable-manager' -import { evaluateSubBlockConditionValues } from '@/lib/workflows/sub-block-conditions' import { extractReferencePrefixes, SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/references' -import { TRIGGER_REFERENCE_ALIAS_MAP } from '@/lib/workflows/triggers' +import { evaluateSubBlockConditionValues } from '@/lib/workflows/sub-block-conditions' import { getBlock } from '@/blocks/index' import type { LoopManager } from '@/executor/loops/loops' import type { ExecutionContext } from '@/executor/types' @@ -40,11 +39,6 @@ export class InputResolver { ]) ) - const startAliasBlock = this.findStartAliasBlock() - if (startAliasBlock) { - this.blockByNormalizedName.set('start', startAliasBlock) - } - // Create efficient loop lookup map this.loopsByBlockId = new Map() for (const [loopId, loop] of Object.entries(workflow.loops || {})) { @@ -62,18 +56,6 @@ export class InputResolver { } } - private findStartAliasBlock(): SerializedBlock | undefined { - const preferredTypes = ['input_trigger', 'api_trigger', 'manual_trigger'] - for (const type of preferredTypes) { - const candidate = this.workflow.blocks.find((block) => block.metadata?.id === type) - if (candidate) { - return candidate - } - } - - return this.workflow.blocks.find((block) => block.metadata?.category === 'triggers') - } - /** * Filters inputs based on sub-block conditions * @param block - Block to filter inputs for @@ -409,149 +391,140 @@ export class InputResolver { // System references (start, loop, parallel, variable) and regular block references are both processed // Accessibility validation happens later in validateBlockReference - // Special case for trigger block references (start, api, chat, manual) + // Special case for the runtime trigger reference. const blockRefLower = blockRef.toLowerCase() - const triggerType = - TRIGGER_REFERENCE_ALIAS_MAP[blockRefLower as keyof typeof TRIGGER_REFERENCE_ALIAS_MAP] - if (triggerType) { - const triggerBlock = this.workflow.blocks.find( - (block) => block.metadata?.id === triggerType - ) - if (triggerBlock) { - const blockState = context.blockStates.get(triggerBlock.id) - if (blockState) { - // For trigger blocks, start directly with the flattened output - // This enables direct access to , , etc - let replacementValue: any = blockState.output - - for (const part of pathParts) { - if (!replacementValue || typeof replacementValue !== 'object') { - logger.warn( - `[resolveBlockReferences] Invalid path "${part}" - replacementValue is not an object:`, - replacementValue + if (blockRefLower === 'start') { + const triggerBlock = context.triggerBlockId + ? this.blockById.get(context.triggerBlockId) + : undefined + if (!triggerBlock) { + throw new Error('Runtime trigger block is not available for reference.') + } + const blockState = context.blockStates.get(triggerBlock.id) + if (blockState) { + // Runtime trigger outputs are exposed through . + let replacementValue: any = blockState.output + + for (const part of pathParts) { + if (!replacementValue || typeof replacementValue !== 'object') { + logger.warn( + `[resolveBlockReferences] Invalid path "${part}" - replacementValue is not an object:`, + replacementValue + ) + throw new Error(`Invalid path "${part}" in "${path}" for trigger block.`) + } + + // Handle array indexing syntax like "files[0]" or "items[1]" + const arrayMatch = part.match(/^([^[]+)\[(\d+)\]$/) + if (arrayMatch) { + const [, arrayName, indexStr] = arrayMatch + const index = Number.parseInt(indexStr, 10) + + // First access the array property + const arrayValue = replacementValue[arrayName] + if (!Array.isArray(arrayValue)) { + throw new Error( + `Property "${arrayName}" is not an array in path "${path}" for trigger block.` ) - throw new Error(`Invalid path "${part}" in "${path}" for trigger block.`) } - // Handle array indexing syntax like "files[0]" or "items[1]" - const arrayMatch = part.match(/^([^[]+)\[(\d+)\]$/) - if (arrayMatch) { - const [, arrayName, indexStr] = arrayMatch - const index = Number.parseInt(indexStr, 10) - - // First access the array property - const arrayValue = replacementValue[arrayName] - if (!Array.isArray(arrayValue)) { - throw new Error( - `Property "${arrayName}" is not an array in path "${path}" for trigger block.` - ) - } - - // Then access the array element - if (index < 0 || index >= arrayValue.length) { - throw new Error( - `Array index ${index} is out of bounds for "${arrayName}" (length: ${arrayValue.length}) in path "${path}" for trigger block.` - ) - } - - replacementValue = arrayValue[index] - } else if (/^(?:[^[]+(?:\[\d+\])+|(?:\[\d+\])+)$/.test(part)) { - // Enhanced: support multiple indices like "values[0][0]" - replacementValue = this.resolvePartWithIndices( - replacementValue, - part, - path, - 'trigger block' + // Then access the array element + if (index < 0 || index >= arrayValue.length) { + throw new Error( + `Array index ${index} is out of bounds for "${arrayName}" (length: ${arrayValue.length}) in path "${path}" for trigger block.` ) - } else { - if (Array.isArray(replacementValue)) { - throw new Error( - `Array path "${path}" in trigger block must use an explicit index.` - ) - } - replacementValue = resolvePropertyAccess(replacementValue, part) } - if (replacementValue === undefined) { - logger.warn( - `[resolveBlockReferences] No value found at path "${part}" in trigger block.` - ) - throw new Error(`No value found at path "${path}" in trigger block.`) + replacementValue = arrayValue[index] + } else if (/^(?:[^[]+(?:\[\d+\])+|(?:\[\d+\])+)$/.test(part)) { + // Enhanced: support multiple indices like "values[0][0]" + replacementValue = this.resolvePartWithIndices( + replacementValue, + part, + path, + 'trigger block' + ) + } else { + if (Array.isArray(replacementValue)) { + throw new Error(`Array path "${path}" in trigger block must use an explicit index.`) } + replacementValue = resolvePropertyAccess(replacementValue, part) } - // Format the value based on block type and path - let formattedValue: string - - // Special handling for all blocks referencing trigger input - // For start and chat triggers, check for 'input' field. For API trigger, any field access counts - const isTriggerInputRef = - (blockRefLower === 'start' && pathParts.join('.').includes('input')) || - (blockRefLower === 'chat' && pathParts.join('.').includes('input')) || - (blockRefLower === 'api' && pathParts.length > 0) - if (isTriggerInputRef) { - const blockType = currentBlock.metadata?.id - - // Format based on which block is consuming this value - if (typeof replacementValue === 'object' && replacementValue !== null) { - // For function blocks, preserve the object structure for code usage - if (blockType === 'function') { - formattedValue = JSON.stringify(replacementValue) - } - // For API blocks, handle body special case - else if (blockType === 'api') { - formattedValue = JSON.stringify(replacementValue) - } - // For condition blocks, ensure proper formatting - else if (blockType === 'condition') { - formattedValue = this.stringifyForCondition(replacementValue) - } - // For response blocks, preserve object structure as-is for proper JSON response - else if (blockType === 'response') { - formattedValue = replacementValue - } - // For all other blocks, stringify objects - else { - // Preserve full JSON structure for objects - formattedValue = JSON.stringify(replacementValue) - } - } else { - // For primitive values, format based on target block type - if (blockType === 'function') { - formattedValue = this.formatValueForCodeContext( - replacementValue, - currentBlock, - isInTemplateLiteral - ) - } else if (blockType === 'condition') { - formattedValue = this.stringifyForCondition(replacementValue) - } else { - formattedValue = String(replacementValue) - } + if (replacementValue === undefined) { + logger.warn( + `[resolveBlockReferences] No value found at path "${part}" in trigger block.` + ) + throw new Error(`No value found at path "${path}" in trigger block.`) + } + } + + // Format the value based on block type and path + let formattedValue: string + + const isTriggerInputRef = pathParts.join('.').includes('input') + if (isTriggerInputRef) { + const blockType = currentBlock.metadata?.id + + // Format based on which block is consuming this value + if (typeof replacementValue === 'object' && replacementValue !== null) { + // For function blocks, preserve the object structure for code usage + if (blockType === 'function') { + formattedValue = JSON.stringify(replacementValue) + } + // For API blocks, handle body special case + else if (blockType === 'api') { + formattedValue = JSON.stringify(replacementValue) + } + // For condition blocks, ensure proper formatting + else if (blockType === 'condition') { + formattedValue = this.stringifyForCondition(replacementValue) + } + // For response blocks, preserve object structure as-is for proper JSON response + else if (blockType === 'response') { + formattedValue = replacementValue + } + // For all other blocks, stringify objects + else { + // Preserve full JSON structure for objects + formattedValue = JSON.stringify(replacementValue) } } else { - // Standard handling for non-input references - const blockType = currentBlock.metadata?.id - - if (blockType === 'response') { - // For response blocks, properly quote string values for JSON context - if (typeof replacementValue === 'string') { - // Properly escape and quote the string for JSON - formattedValue = JSON.stringify(replacementValue) - } else { - formattedValue = replacementValue - } + // For primitive values, format based on target block type + if (blockType === 'function') { + formattedValue = this.formatValueForCodeContext( + replacementValue, + currentBlock, + isInTemplateLiteral + ) + } else if (blockType === 'condition') { + formattedValue = this.stringifyForCondition(replacementValue) } else { - formattedValue = - typeof replacementValue === 'object' - ? JSON.stringify(replacementValue) - : String(replacementValue) + formattedValue = String(replacementValue) } } - - resolvedValue = resolvedValue.replace(raw, formattedValue) - continue + } else { + // Standard handling for non-input references + const blockType = currentBlock.metadata?.id + + if (blockType === 'response') { + // For response blocks, properly quote string values for JSON context + if (typeof replacementValue === 'string') { + // Properly escape and quote the string for JSON + formattedValue = JSON.stringify(replacementValue) + } else { + formattedValue = replacementValue + } + } else { + formattedValue = + typeof replacementValue === 'object' + ? JSON.stringify(replacementValue) + : String(replacementValue) + } } + + resolvedValue = resolvedValue.replace(raw, formattedValue) + continue } } @@ -1013,10 +986,7 @@ export class InputResolver { const accessibleBlocks = this.getAccessibleBlocks(currentBlockId) const isAlwaysAccessibleTrigger = sourceBlock.metadata?.category === 'triggers' || - sourceBlock.metadata?.id === 'input_trigger' || - sourceBlock.metadata?.id === 'api_trigger' || - sourceBlock.metadata?.id === 'manual_trigger' || - sourceBlock.metadata?.id === 'chat_trigger' + sourceBlock.config.params.triggerMode === true if ( sourceBlock.id !== currentBlockId && diff --git a/apps/tradinggoose/executor/tests/executor-layer-validation.test.ts b/apps/tradinggoose/executor/tests/executor-layer-validation.test.ts index 839fda7e0..e4af0f305 100644 --- a/apps/tradinggoose/executor/tests/executor-layer-validation.test.ts +++ b/apps/tradinggoose/executor/tests/executor-layer-validation.test.ts @@ -167,7 +167,7 @@ describe('Full Executor Test', () => { try { // Execute the workflow - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') // Check if it's an ExecutionResult (not StreamingExecution) if ('success' in result) { @@ -186,7 +186,11 @@ describe('Full Executor Test', () => { it('should test the executor getNextExecutionLayer method directly', async () => { // Create a mock context in the exact state after the condition executes - const context = (executor as any).createExecutionContext('test-workflow', new Date()) + const context = (executor as any).createExecutionContext( + 'test-workflow', + new Date(), + 'bd9f4f7d-8aed-4860-a3be-8bebd1931b19' + ) // Set up the state as it would be after the condition executes context.executedBlocks.add('bd9f4f7d-8aed-4860-a3be-8bebd1931b19') // Start diff --git a/apps/tradinggoose/executor/tests/multi-input-routing.test.ts b/apps/tradinggoose/executor/tests/multi-input-routing.test.ts index 8d3c7663b..818a72789 100644 --- a/apps/tradinggoose/executor/tests/multi-input-routing.test.ts +++ b/apps/tradinggoose/executor/tests/multi-input-routing.test.ts @@ -101,9 +101,9 @@ describe('Multi-Input Routing Scenarios', () => { it('should handle multi-input target when router selects function-1', async () => { // Test scenario: Router selects function-1, agent should still execute with function-1's output - const context = (executor as any).createExecutionContext('test-workflow', new Date()) + const context = (executor as any).createExecutionContext('test-workflow', new Date(), 'start') - // Step 1: Execute start block + // Step 1: Execute trigger block context.executedBlocks.add('start') context.activeExecutionPath.add('start') context.activeExecutionPath.add('router-1') @@ -166,7 +166,7 @@ describe('Multi-Input Routing Scenarios', () => { it('should handle multi-input target when router selects function-2', async () => { // Test scenario: Router selects function-2, agent should still execute with function-2's output - const context = (executor as any).createExecutionContext('test-workflow', new Date()) + const context = (executor as any).createExecutionContext('test-workflow', new Date(), 'start') // Step 1: Execute start and router-1 selecting function-2 context.executedBlocks.add('start') @@ -223,7 +223,7 @@ describe('Multi-Input Routing Scenarios', () => { it('should verify the dependency logic for inactive sources', async () => { // This test specifically validates the multi-input dependency logic - const context = (executor as any).createExecutionContext('test-workflow', new Date()) + const context = (executor as any).createExecutionContext('test-workflow', new Date(), 'start') // Setup: Router executed and selected function-1, function-1 executed context.executedBlocks.add('start') diff --git a/apps/tradinggoose/executor/types.ts b/apps/tradinggoose/executor/types.ts index 4ce4e86b7..eab9cd07f 100644 --- a/apps/tradinggoose/executor/types.ts +++ b/apps/tradinggoose/executor/types.ts @@ -115,6 +115,7 @@ export interface ExecutionContext { workflowLogId?: string submissionSource?: ExecutionSubmissionSource triggerType?: TriggerType + triggerBlockId?: string workflowDepth?: number // Whether this execution is running against deployed state (API/webhook/schedule/chat) // Manual executions in the builder should leave this undefined/false diff --git a/apps/tradinggoose/hooks/workflow/use-accessible-reference-prefixes.ts b/apps/tradinggoose/hooks/workflow/use-accessible-reference-prefixes.ts index f98db1ad9..2aa4db375 100644 --- a/apps/tradinggoose/hooks/workflow/use-accessible-reference-prefixes.ts +++ b/apps/tradinggoose/hooks/workflow/use-accessible-reference-prefixes.ts @@ -1,13 +1,13 @@ import { useMemo } from 'react' import { BlockPathCalculator } from '@/lib/block-path-calculator' import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/references' -import { normalizeBlockName } from '@/stores/workflows/utils' import { useWorkflowBlocks, useWorkflowEdges, useWorkflowLoops, useWorkflowParallels, } from '@/lib/yjs/use-workflow-doc' +import { normalizeBlockName } from '@/stores/workflows/utils' import type { Loop, Parallel } from '@/stores/workflows/workflow/types' export function useAccessibleReferencePrefixes(blockId?: string | null): Set | undefined { @@ -49,10 +49,6 @@ export function useAccessibleReferencePrefixes(blockId?: string | null): Set prefixes.add(prefix)) diff --git a/apps/tradinggoose/hooks/workflow/use-workflow-execution.test.tsx b/apps/tradinggoose/hooks/workflow/use-workflow-execution.test.tsx index 014ccd37b..d3225c4d4 100644 --- a/apps/tradinggoose/hooks/workflow/use-workflow-execution.test.tsx +++ b/apps/tradinggoose/hooks/workflow/use-workflow-execution.test.tsx @@ -5,8 +5,12 @@ import { createRoot, type Root } from 'react-dom/client' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const mockRunQueuedWorkflowExecution = vi.hoisted(() => vi.fn()) -const mockUseCurrentWorkflow = vi.hoisted(() => vi.fn()) -const mockUseWorkflowVariables = vi.hoisted(() => vi.fn()) +const mockWorkflowDoc = vi.hoisted(() => ({})) +const mockReadWorkflowSnapshot = vi.hoisted(() => vi.fn()) +const mockUseWorkflowSession = vi.hoisted(() => vi.fn()) +const mockGetVariablesSnapshot = vi.hoisted(() => vi.fn()) + +vi.unmock('@/blocks/registry') const mockConsoleState = vi.hoisted(() => ({ cancelRunningEntries: vi.fn(), @@ -29,22 +33,12 @@ vi.mock('@/lib/workflows/queued-execution-client', () => ({ runQueuedWorkflowExecution: mockRunQueuedWorkflowExecution, })) -vi.mock('@/lib/workflows/triggers', () => ({ - TriggerUtils: { - findStartBlock: vi.fn(() => ({ blockId: 'chat-trigger', block: {} })), - getTriggerValidationMessage: vi.fn(() => 'Missing chat trigger'), - findTriggersByType: vi.fn((blocks, type) => - type === 'manual' - ? Object.values(blocks as Record).filter( - (block: any) => block.type === 'manual_trigger' - ) - : [] - ), - }, +vi.mock('@/lib/yjs/workflow-session', () => ({ + getVariablesSnapshot: mockGetVariablesSnapshot, })) -vi.mock('@/lib/yjs/use-workflow-doc', () => ({ - useWorkflowVariables: mockUseWorkflowVariables, +vi.mock('@/lib/yjs/workflow-session-host', () => ({ + useWorkflowSession: mockUseWorkflowSession, })) vi.mock('@/stores/console/store', () => { @@ -65,39 +59,41 @@ vi.mock('@/stores/execution/store', () => { } }) -vi.mock('@/stores/workflows/registry/store', () => ({ - useWorkflowRegistry: vi.fn((selector) => - selector({ - workflows: { - 'workflow-1': { - workspaceId: 'workspace-1', - }, - }, - getActiveWorkflowId: () => null, - }) - ), -})) - -vi.mock('@/stores/workflows/workflow/utils', () => ({ - generateLoopBlocks: vi.fn(() => ({})), - generateParallelBlocks: vi.fn(() => ({})), -})) - vi.mock('@/widgets/widgets/editor_workflow/context/workflow-route-context', () => ({ useWorkflowRoute: vi.fn(() => ({ workflowId: 'workflow-1', + workspaceId: 'workspace-1', channelId: 'channel-1', })), })) -vi.mock('./use-current-workflow', () => ({ - useCurrentWorkflow: mockUseCurrentWorkflow, -})) - describe('useWorkflowExecution', () => { let container: HTMLDivElement | null = null let root: Root | null = null const previousActEnvironment = (globalThis as any).IS_REACT_ACT_ENVIRONMENT + const agentBlock = { + id: 'agent-1', + type: 'agent', + name: 'Agent', + enabled: true, + subBlocks: {}, + outputs: {}, + } + + function mockSingleTriggerSnapshot( + triggerId: string, + type: string, + name: string, + subBlocks: Record = {} + ) { + mockReadWorkflowSnapshot.mockReturnValue({ + blocks: { + [triggerId]: { id: triggerId, type, name, enabled: true, subBlocks, outputs: {} }, + 'agent-1': agentBlock, + }, + edges: [{ id: 'edge-1', source: triggerId, target: 'agent-1' }], + }) + } async function renderExecutionHook() { const { useWorkflowExecution } = await import('./use-workflow-execution') @@ -133,8 +129,12 @@ describe('useWorkflowExecution', () => { output: {}, logs: [], }) - mockUseWorkflowVariables.mockReturnValue([]) - mockUseCurrentWorkflow.mockReturnValue({ + mockUseWorkflowSession.mockReturnValue({ + doc: mockWorkflowDoc, + readWorkflowSnapshot: mockReadWorkflowSnapshot, + }) + mockGetVariablesSnapshot.mockReturnValue({}) + mockReadWorkflowSnapshot.mockReturnValue({ blocks: { 'chat-trigger': { id: 'chat-trigger', @@ -152,14 +152,7 @@ describe('useWorkflowExecution', () => { subBlocks: {}, outputs: {}, }, - 'agent-1': { - id: 'agent-1', - type: 'agent', - name: 'Agent', - enabled: true, - subBlocks: {}, - outputs: {}, - }, + 'agent-1': agentBlock, }, edges: [ { id: 'edge-1', source: 'chat-trigger', target: 'agent-1' }, @@ -213,7 +206,20 @@ describe('useWorkflowExecution', () => { ) }) + it('does not run chat-only workflows through editor Run', async () => { + mockSingleTriggerSnapshot('chat-trigger', 'chat_trigger', 'Chat Trigger') + + const execution = await renderExecutionHook() + + await act(async () => { + await execution.handleRunWorkflow() + }) + + expect(mockRunQueuedWorkflowExecution).not.toHaveBeenCalled() + }) + it('forwards queued execution events to the workflow caller', async () => { + mockSingleTriggerSnapshot('schedule-trigger', 'schedule', 'Schedule') const streamEvent = { type: 'stream:chunk', executionId: 'execution-1', @@ -237,7 +243,7 @@ describe('useWorkflowExecution', () => { const execution = await renderExecutionHook() await act(async () => { - await execution.handleRunWorkflow({ onEvent }) + await execution.handleRunWorkflow({ triggerBlockId: 'schedule-trigger', onEvent }) }) expect(onEvent).toHaveBeenCalledWith(streamEvent) @@ -245,7 +251,7 @@ describe('useWorkflowExecution', () => { expect(mockRunQueuedWorkflowExecution).toHaveBeenCalledWith( expect.objectContaining({ triggerType: 'manual', - startBlockId: 'manual-trigger', + triggerBlockId: 'schedule-trigger', selectedOutputs: undefined, stream: true, }), diff --git a/apps/tradinggoose/hooks/workflow/use-workflow-execution.ts b/apps/tradinggoose/hooks/workflow/use-workflow-execution.ts index c53551675..df35d5357 100644 --- a/apps/tradinggoose/hooks/workflow/use-workflow-execution.ts +++ b/apps/tradinggoose/hooks/workflow/use-workflow-execution.ts @@ -2,16 +2,14 @@ import { useCallback, useRef, useState } from 'react' import { createLogger } from '@/lib/logs/console/logger' import type { WorkflowExecutionEvent } from '@/lib/workflows/execution-events' import { runQueuedWorkflowExecution } from '@/lib/workflows/queued-execution-client' -import { TriggerUtils } from '@/lib/workflows/triggers' -import { useWorkflowVariables } from '@/lib/yjs/use-workflow-doc' +import { resolveWorkflowRunTrigger, TriggerUtils } from '@/lib/workflows/triggers' +import { getVariablesSnapshot } from '@/lib/yjs/workflow-session' +import { useWorkflowSession } from '@/lib/yjs/workflow-session-host' import type { ExecutionResult } from '@/executor/types' -import { useLatestRef } from '@/hooks/use-latest-ref' import { useConsoleStore } from '@/stores/console/store' import { useExecutionStore } from '@/stores/execution/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' +import { buildExecutableWorkflowData } from '@/stores/workflows/workflow/utils' import { useWorkflowRoute } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' -import { useCurrentWorkflow } from './use-current-workflow' const logger = createLogger('useWorkflowExecution') const WORKFLOW_EXECUTION_FAILURE_MESSAGE = 'Workflow execution failed' @@ -19,6 +17,7 @@ type WorkflowExecutionTriggerType = 'chat' | 'manual' type WorkflowExecutionRequest = { input?: unknown triggerType?: WorkflowExecutionTriggerType + triggerBlockId?: string selectedOutputs?: string[] onEvent?: (event: WorkflowExecutionEvent) => void | Promise } @@ -64,35 +63,15 @@ function createExecutionId() { return globalThis.crypto.randomUUID() } -function getInputFormatTestValues(inputFormatValue: unknown): Record { - const testInput: Record = {} - if (!Array.isArray(inputFormatValue)) return testInput - - for (const field of inputFormatValue) { - if (field && typeof field === 'object' && 'name' in field && 'value' in field) { - const name = (field as { name?: unknown }).name - if (typeof name === 'string' && name.length > 0) { - testInput[name] = (field as { value?: unknown }).value - } - } - } - - return testInput -} - export function useWorkflowExecution() { - const currentWorkflow = useCurrentWorkflow() - const { workflowId: routeWorkflowId, channelId } = useWorkflowRoute() - const workflows = useWorkflowRegistry((state) => state.workflows) - const registryWorkflowId = useWorkflowRegistry((state) => state.getActiveWorkflowId(channelId)) - const activeWorkflowId = routeWorkflowId ?? registryWorkflowId + const { workflowId: activeWorkflowId, workspaceId } = useWorkflowRoute() + const { doc, error, isLoading, readWorkflowSnapshot } = useWorkflowSession() const { cancelRunningEntries } = useConsoleStore() - const yjsVariables = useWorkflowVariables() - const yjsVariablesRef = useLatestRef(yjsVariables) const abortControllerRef = useRef(null) const { isExecuting, setIsExecuting, setIsDebugging, setPendingBlocks, setActiveBlocks } = useExecutionStore() const [executionResult, setExecutionResult] = useState(null) + const isWorkflowSessionReady = Boolean(doc) && !isLoading && !error const applyExecutionEvent = useCallback( (event: WorkflowExecutionEvent) => { @@ -169,89 +148,52 @@ export function useWorkflowExecution() { ) const buildExecutionRequest = useCallback( - async (workflowInput: unknown, triggerType: WorkflowExecutionTriggerType) => { - if (!activeWorkflowId) throw new Error('Workflow target is required') - - const workspaceId = workflows[activeWorkflowId]?.workspaceId + async ( + workflowInput: unknown, + triggerType: WorkflowExecutionTriggerType, + requestedTriggerBlockId?: string + ) => { + const workflowSnapshot = readWorkflowSnapshot() + if (!workflowSnapshot || !doc) { + throw new Error('Workflow session is not ready') + } if (!workspaceId) { throw new Error('Cannot execute workflow without workspaceId') } - const validBlocks = Object.entries(currentWorkflow.blocks).reduce( - (acc, [blockId, block]) => { - if (block?.type && block.enabled !== false) { - acc[blockId] = block - } - return acc - }, - {} as typeof currentWorkflow.blocks + const workflowData = buildExecutableWorkflowData( + workflowSnapshot.blocks, + workflowSnapshot.edges ) const isChatExecution = triggerType === 'chat' - let startBlockId: string | undefined + let triggerBlockId: string | undefined let finalWorkflowInput = workflowInput if (isChatExecution) { - const startBlock = TriggerUtils.findStartBlock(validBlocks, 'chat') - if (!startBlock) { - throw new Error(TriggerUtils.getTriggerValidationMessage('chat', 'missing')) + const chatTrigger = TriggerUtils.findTriggerBlock(workflowData.blocks, 'chat') + if (!chatTrigger) { + throw new Error('Chat execution requires a Chat Trigger block') } - startBlockId = startBlock.blockId + triggerBlockId = chatTrigger.blockId } else { - const entries = Object.entries(validBlocks) - const apiTriggers = TriggerUtils.findTriggersByType(validBlocks, 'api') - const manualTriggers = TriggerUtils.findTriggersByType(validBlocks, 'manual') - - if (apiTriggers.length > 1) { - throw new Error('Multiple API Trigger blocks found. Keep only one.') + if (!requestedTriggerBlockId) { + throw new Error('Run requires choosing a connected configured non-chat trigger block') } - - let selectedTrigger: any = null - let selectedBlockId: string | null = null - - if (apiTriggers.length === 1) { - selectedTrigger = apiTriggers[0] - selectedBlockId = entries.find(([, block]) => block === selectedTrigger)?.[0] ?? null - - const testInput = getInputFormatTestValues(selectedTrigger.subBlocks?.inputFormat?.value) - if (Object.keys(testInput).length > 0) { - finalWorkflowInput = testInput - } - } else if (manualTriggers.length > 0) { - selectedTrigger = - manualTriggers.find((trigger) => trigger.type === 'manual_trigger') ?? - manualTriggers.find((trigger) => trigger.type === 'input_trigger') ?? - manualTriggers[0] - selectedBlockId = entries.find(([, block]) => block === selectedTrigger)?.[0] ?? null - - if (selectedTrigger.type === 'input_trigger') { - const testInput = getInputFormatTestValues( - selectedTrigger.subBlocks?.inputFormat?.value - ) - if (Object.keys(testInput).length > 0) { - finalWorkflowInput = testInput - } + const editorTestTrigger = resolveWorkflowRunTrigger( + workflowData.blocks, + workflowData.edges, + { + surface: 'editor', + workflowInput, + triggerBlockId: requestedTriggerBlockId, } - } else { - throw new Error('Manual run requires a Manual, Input Form, or API Trigger block') - } - - if (!selectedBlockId || !selectedTrigger) { - throw new Error('No valid trigger block found to start execution') - } - - const outgoingConnections = currentWorkflow.edges.filter( - (edge) => edge.source === selectedBlockId ) - if (outgoingConnections.length === 0) { - const triggerName = selectedTrigger.name || selectedTrigger.type - throw new Error(`${triggerName} must be connected to other blocks to execute`) - } - - startBlockId = selectedBlockId + triggerBlockId = editorTestTrigger.blockId + finalWorkflowInput = editorTestTrigger.input } - const workflowVariables = Object.values(yjsVariablesRef.current ?? {}).reduce( + const workflowVariables = Object.values(getVariablesSnapshot(doc)).reduce( (acc, variable: any) => { if (variable?.id) acc[variable.id] = variable return acc @@ -262,18 +204,13 @@ export function useWorkflowExecution() { return { workspaceId, input: finalWorkflowInput, - startBlockId, + triggerBlockId, triggerType, workflowVariables, - workflowData: { - blocks: validBlocks, - edges: currentWorkflow.edges, - loops: generateLoopBlocks(validBlocks), - parallels: generateParallelBlocks(validBlocks), - }, + workflowData, } }, - [activeWorkflowId, currentWorkflow.blocks, currentWorkflow.edges, workflows] + [doc, readWorkflowSnapshot, workspaceId] ) const uploadChatFiles = useCallback( @@ -350,10 +287,14 @@ export function useWorkflowExecution() { abortControllerRef.current = abortController try { - const triggerType = request.triggerType ?? 'manual' - const executionRequest = await buildExecutionRequest(request.input, triggerType) + const requestedTriggerType = request.triggerType ?? 'manual' + const executionRequest = await buildExecutionRequest( + request.input, + requestedTriggerType, + request.triggerBlockId + ) const input = - triggerType === 'chat' + executionRequest.triggerType === 'chat' ? await uploadChatFiles( executionRequest.input, executionId, @@ -366,11 +307,11 @@ export function useWorkflowExecution() { workflowId: activeWorkflowId, executionId, input, - triggerType, + triggerType: executionRequest.triggerType, executionTarget: 'live', workflowData: executionRequest.workflowData, workflowVariables: executionRequest.workflowVariables, - startBlockId: executionRequest.startBlockId, + triggerBlockId: executionRequest.triggerBlockId, selectedOutputs: request.selectedOutputs, stream: true, signal: abortController.signal, @@ -424,6 +365,7 @@ export function useWorkflowExecution() { return { isExecuting, + isWorkflowSessionReady, executionResult, handleRunWorkflow, handleCancelExecution, diff --git a/apps/tradinggoose/lib/block-path-calculator.ts b/apps/tradinggoose/lib/block-path-calculator.ts index 8355fa6c3..a7fa5f37e 100644 --- a/apps/tradinggoose/lib/block-path-calculator.ts +++ b/apps/tradinggoose/lib/block-path-calculator.ts @@ -120,7 +120,7 @@ export class BlockPathCalculator { } names.push(accessibleBlockId) - if (block.metadata?.id === 'input_trigger') { + if (block.metadata?.category === 'triggers' || block.config.params.triggerMode === true) { names.push('start') } } diff --git a/apps/tradinggoose/lib/copilot/registry.ts b/apps/tradinggoose/lib/copilot/registry.ts index f992da6c4..4fde5f7d3 100644 --- a/apps/tradinggoose/lib/copilot/registry.ts +++ b/apps/tradinggoose/lib/copilot/registry.ts @@ -312,6 +312,11 @@ export const ToolArgSchemas = { run_workflow: z.object({ entityId: RequiredId, + triggerBlockId: z + .string() + .trim() + .min(1) + .describe('Exact trigger block id from `read_workflow.workflowSummary.blocks`.'), workflow_input: z.union([z.string(), z.record(z.any())]).optional(), }), diff --git a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts index d519c5de4..c121c6f64 100644 --- a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts +++ b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts @@ -57,7 +57,8 @@ export const TOOL_PROMPT_METADATA: Record = { entityKind: 'workflow', }, run_workflow: { - description: 'Run the target workflow with optional input.', + description: + 'Run the target workflow with optional input and an exact `triggerBlockId` from `read_workflow.workflowSummary.blocks`.', kind: 'run', entityKind: 'workflow', }, diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/run-workflow.test.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/run-workflow.test.ts index de283f913..3115fc817 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/run-workflow.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/workflow/run-workflow.test.ts @@ -110,6 +110,7 @@ describe('RunWorkflowClientTool channel-safe workflow scoping', () => { await tool.handleAccept({ entityId: 'wf-explicit-target', + triggerBlockId: 'schedule-trigger', workflow_input: { symbol: 'AAPL' }, }) @@ -117,6 +118,7 @@ describe('RunWorkflowClientTool channel-safe workflow scoping', () => { workflowInput: { symbol: 'AAPL' }, executionId: toolCallId, workflowId: 'wf-explicit-target', + triggerBlockId: 'schedule-trigger', }) expect(tool.getState()).toBe(ClientToolCallState.success) }) diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/run-workflow.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/run-workflow.ts index 81666e9cc..6df23f2ed 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/run-workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/workflow/run-workflow.ts @@ -13,6 +13,7 @@ import { useExecutionStore } from '@/stores/execution/store' interface RunWorkflowArgs { entityId: string description?: string + triggerBlockId: string workflow_input?: Record | string } @@ -79,6 +80,13 @@ export class RunWorkflowClientTool extends BaseClientTool { } logger.debug('Using target workflow', { workflowId: activeWorkflowId }) + if (typeof params.triggerBlockId !== 'string' || params.triggerBlockId.length === 0) { + logger.debug('Execution prevented: no trigger block selected') + this.setState(ClientToolCallState.error) + await this.markToolComplete(400, 'triggerBlockId is required') + return + } + let workflowInput: Record | undefined if (params.workflow_input !== undefined) { if (typeof params.workflow_input === 'string') { @@ -116,6 +124,7 @@ export class RunWorkflowClientTool extends BaseClientTool { workflowInput, executionId: this.toolCallId, workflowId: activeWorkflowId, + triggerBlockId: params.triggerBlockId, }) // Determine success for both non-streaming and streaming executions diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/workflow-execution-utils.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/workflow-execution-utils.ts index 2d46eefdb..479a643a5 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/workflow-execution-utils.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/workflow/workflow-execution-utils.ts @@ -1,8 +1,9 @@ import { getReadableWorkflowState } from '@/lib/copilot/tools/client/workflow/workflow-review-tool-utils' import { createLogger } from '@/lib/logs/console/logger' import { runQueuedWorkflowExecution } from '@/lib/workflows/queued-execution-client' -import { TriggerUtils } from '@/lib/workflows/triggers' +import { resolveWorkflowRunTrigger } from '@/lib/workflows/triggers' import type { ExecutionResult } from '@/executor/types' +import { buildExecutableWorkflowData } from '@/stores/workflows/workflow/utils' const logger = createLogger('WorkflowExecutionUtils') @@ -10,21 +11,13 @@ type WorkflowExecutionOptions = { workflowInput?: any executionId?: string workflowId: string + triggerBlockId: string } function createExecutionId() { return globalThis.crypto.randomUUID() } -function resolveWorkflowStart(blocks: Record) { - for (const triggerType of ['chat', 'manual', 'api'] as const) { - const start = TriggerUtils.findStartBlock(blocks, triggerType) - if (start) return { triggerType, startBlockId: start.blockId } - } - - return null -} - export async function executeWorkflowWithFullLogging( options: WorkflowExecutionOptions ): Promise { @@ -44,40 +37,29 @@ export async function executeWorkflowWithFullLogging( throw new Error('Workflow execution context requires workspaceId') } - const blocks = Object.entries(workflowState.blocks).reduce( - (acc, [blockId, block]) => { - if (block?.type && block.enabled !== false) { - acc[blockId] = block - } - return acc - }, - {} as typeof workflowState.blocks - ) - const start = resolveWorkflowStart(blocks) - if (!start) { - throw new Error('Workflow requires a chat, API, or manual trigger block to execute') - } + const workflowData = buildExecutableWorkflowData(workflowState.blocks, workflowState.edges) + const start = resolveWorkflowRunTrigger(workflowData.blocks, workflowData.edges, { + surface: 'copilot', + workflowInput: options.workflowInput, + triggerBlockId: options.triggerBlockId, + }) logger.info('Executing workflow through server route', { workflowId: options.workflowId, triggerType: start.triggerType, - blockCount: Object.keys(blocks).length, - edgeCount: workflowState.edges.length, + triggerBlockId: start.blockId, + blockCount: Object.keys(workflowData.blocks).length, + edgeCount: workflowData.edges.length, }) return runQueuedWorkflowExecution({ workflowId: options.workflowId, executionId: options.executionId ?? createExecutionId(), - input: options.workflowInput, + input: start.input, triggerType: start.triggerType, executionTarget: 'live', - workflowData: { - blocks, - edges: workflowState.edges, - loops: workflowState.loops, - parallels: workflowState.parallels, - }, + workflowData, workflowVariables, - startBlockId: start.startBlockId, + triggerBlockId: start.blockId, }) } diff --git a/apps/tradinggoose/lib/copilot/tools/server/router.test.ts b/apps/tradinggoose/lib/copilot/tools/server/router.test.ts index 0a8061aaf..43ea96079 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/router.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/router.test.ts @@ -240,8 +240,17 @@ describe('copilot contract registry', () => { it('accepts explicit entity ids on workflow execution tools', () => { expect(() => getToolContract('run_workflow')?.args.parse({})).toThrow() expect(() => getToolContract('read_workflow')?.args.parse({})).toThrow() - expect(getToolContract('run_workflow')?.args.parse({ entityId: 'workflow-123' })).toEqual({ + expect(() => + getToolContract('run_workflow')?.args.parse({ entityId: 'workflow-123' }) + ).toThrow() + expect( + getToolContract('run_workflow')?.args.parse({ + entityId: 'workflow-123', + triggerBlockId: 'trigger-1', + }) + ).toEqual({ entityId: 'workflow-123', + triggerBlockId: 'trigger-1', }) expect( getToolContract('set_workflow_variables')?.args.parse({ diff --git a/apps/tradinggoose/lib/webhooks/processor.ts b/apps/tradinggoose/lib/webhooks/processor.ts index 926f8bcd2..48d455d2a 100644 --- a/apps/tradinggoose/lib/webhooks/processor.ts +++ b/apps/tradinggoose/lib/webhooks/processor.ts @@ -378,6 +378,10 @@ export async function queueWebhookExecution( } const headers = Object.fromEntries(request.headers.entries()) + if (typeof foundWebhook.blockId !== 'string' || foundWebhook.blockId.length === 0) { + logger.warn(`[${options.requestId}] Webhook ${foundWebhook.id} is missing trigger block`) + return NextResponse.json({ message: 'Webhook trigger block not found' }, { status: 410 }) + } // For Microsoft Teams Graph notifications, extract unique identifiers for idempotency if ( @@ -409,7 +413,7 @@ export async function queueWebhookExecution( const pendingExecutionId = `webhook_execution:${IdempotencyService.createWebhookIdempotencyKey( foundWebhook.id, - headers, + headers )}` const handle = await enqueuePendingExecution({ @@ -429,7 +433,7 @@ export async function queueWebhookExecution( logger.info( `[${options.requestId}] Queued ${options.testMode ? 'TEST ' : ''}webhook execution ${ handle.pendingExecutionId - } for ${foundWebhook.provider} webhook`, + } for ${foundWebhook.provider} webhook` ) } catch (error: any) { if (error instanceof TriggerExecutionUnavailableError) { diff --git a/apps/tradinggoose/lib/workflows/execution-runner.test.ts b/apps/tradinggoose/lib/workflows/execution-runner.test.ts index 3d4e45704..6610a0f81 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.test.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.test.ts @@ -71,7 +71,7 @@ vi.mock('@/lib/workflows/db-helpers', () => ({ vi.mock('@/lib/workflows/triggers', () => ({ TriggerUtils: { - findStartBlock: vi.fn(), + findTriggerBlock: vi.fn(), }, })) @@ -157,7 +157,7 @@ describe('runPreparedWorkflowExecution', () => { triggerType: 'webhook', workflowInput: { symbol: 'AAPL' }, executionId: 'execution-1', - start: { + triggerTarget: { kind: 'block', blockId: 'trigger', }, @@ -219,7 +219,7 @@ describe('runPreparedWorkflowExecution', () => { triggerType: 'manual', workflowInput: {}, executionId: 'execution-1', - start: { + triggerTarget: { kind: 'block', blockId: 'trigger', }, @@ -247,7 +247,7 @@ describe('runPreparedWorkflowExecution', () => { triggerType: 'manual', workflowInput: {}, executionId: 'execution-1', - start: { + triggerTarget: { kind: 'block', blockId: 'trigger', }, @@ -271,14 +271,14 @@ describe('runPreparedWorkflowExecution', () => { expect(result.dispatchFailureReason).toBe('usage_limit_exceeded') }) - it('reports missing start blocks as dispatch failures', async () => { + it('reports missing trigger blocks as dispatch failures', async () => { const result = await runPreparedWorkflowExecution({ blueprint, actorUserId: 'user-1', triggerType: 'webhook', workflowInput: {}, executionId: 'execution-1', - start: { + triggerTarget: { kind: 'block', blockId: 'missing', }, @@ -286,7 +286,7 @@ describe('runPreparedWorkflowExecution', () => { expect(mocks.execute).not.toHaveBeenCalled() expect(result.result.success).toBe(false) - expect(result.dispatchFailureReason).toBe('missing_start_block') + expect(result.dispatchFailureReason).toBe('missing_trigger_block') }) it('does not rewrite successful executions as failed when terminal success logging fails', async () => { @@ -299,7 +299,7 @@ describe('runPreparedWorkflowExecution', () => { triggerType: 'manual', workflowInput: {}, executionId: 'execution-1', - start: { + triggerTarget: { kind: 'block', blockId: 'trigger', }, @@ -310,8 +310,8 @@ describe('runPreparedWorkflowExecution', () => { expect(mocks.completeWithError).not.toHaveBeenCalled() }) - it('resolves queued child API starts through the child input-trigger path', async () => { - vi.mocked(TriggerUtils.findStartBlock).mockReturnValue({ + it('resolves queued child API triggers through the child input-trigger path', async () => { + vi.mocked(TriggerUtils.findTriggerBlock).mockReturnValue({ blockId: 'trigger', block: { type: 'input_trigger' }, }) @@ -322,7 +322,7 @@ describe('runPreparedWorkflowExecution', () => { triggerType: 'manual', workflowInput: { symbol: 'AAPL' }, executionId: 'execution-1', - start: { + triggerTarget: { kind: 'trigger', triggerType: 'api', }, @@ -331,7 +331,7 @@ describe('runPreparedWorkflowExecution', () => { }, }) - expect(TriggerUtils.findStartBlock).toHaveBeenCalledWith( + expect(TriggerUtils.findTriggerBlock).toHaveBeenCalledWith( blueprint.workflowData.blocks, 'api', true @@ -349,7 +349,7 @@ describe('runPreparedWorkflowExecution', () => { triggerType: 'manual', workflowInput: {}, executionId: 'execution-1', - start: { + triggerTarget: { kind: 'trigger', triggerType: 'manual', }, @@ -372,7 +372,7 @@ describe('runPreparedWorkflowExecution', () => { triggerType: 'manual', workflowInput: {}, executionId: 'execution-1', - start: { + triggerTarget: { kind: 'trigger', triggerType: 'manual', }, diff --git a/apps/tradinggoose/lib/workflows/execution-runner.ts b/apps/tradinggoose/lib/workflows/execution-runner.ts index c31f074b7..4ecdd3b6a 100644 --- a/apps/tradinggoose/lib/workflows/execution-runner.ts +++ b/apps/tradinggoose/lib/workflows/execution-runner.ts @@ -35,14 +35,14 @@ type ResolvedWorkflowExecutionContext = { variables: unknown } -export type WorkflowStart = +export type WorkflowTriggerTarget = | { kind: 'trigger' triggerType: 'api' | 'chat' | 'manual' } | { kind: 'block' - blockId?: string + blockId: string } export type WorkflowExecutionBlueprint = { @@ -58,7 +58,7 @@ export type WorkflowExecutionBlueprint = { } export type WorkflowRunnerExecutionResult = ExecutionResult -export type WorkflowDispatchFailureReason = 'usage_limit_exceeded' | 'missing_start_block' +export type WorkflowDispatchFailureReason = 'usage_limit_exceeded' | 'missing_trigger_block' export type WorkflowRunnerResult = { executionId: string @@ -78,7 +78,7 @@ export class WorkflowUsageLimitError extends Error { } } -class WorkflowStartBlockError extends Error {} +class WorkflowTriggerBlockError extends Error {} async function resolveRequiredWorkflowExecutionContext( workflowId: string, @@ -191,70 +191,66 @@ function buildProcessedBlockStates( return processedBlockStates } -function resolveStartBlockId(params: { +function resolveTriggerBlockId(params: { mergedStates: Record serializedWorkflow: { connections: Array<{ source: string }> } - start: WorkflowStart + target: WorkflowTriggerTarget isChildExecution: boolean }) { - if (params.start.kind === 'trigger') { - const startBlock = TriggerUtils.findStartBlock( + if (params.target.kind === 'trigger') { + const triggerBlock = TriggerUtils.findTriggerBlock( params.mergedStates, - params.start.triggerType, + params.target.triggerType, params.isChildExecution ) - if (!startBlock) { + if (!triggerBlock) { const triggerName = - params.start.triggerType === 'api' && params.isChildExecution + params.target.triggerType === 'api' && params.isChildExecution ? 'Input' - : params.start.triggerType === 'api' + : params.target.triggerType === 'api' ? 'API' - : params.start.triggerType === 'chat' + : params.target.triggerType === 'chat' ? 'Chat' : 'Manual' - throw new WorkflowStartBlockError( + throw new WorkflowTriggerBlockError( `No ${triggerName} trigger block found. Add a ${triggerName} Trigger block to this workflow.` ) } const outgoingConnections = params.serializedWorkflow.connections.filter( - (connection) => connection.source === startBlock.blockId + (connection) => connection.source === triggerBlock.blockId ) if (outgoingConnections.length === 0) { - throw new WorkflowStartBlockError( + throw new WorkflowTriggerBlockError( 'Trigger block must be connected to other blocks to execute' ) } - return startBlock.blockId + return triggerBlock.blockId } - if ( - params.start.kind === 'block' && - params.start.blockId && - !params.mergedStates[params.start.blockId] - ) { - throw new WorkflowStartBlockError( - `Workflow does not contain trigger block ${params.start.blockId}` + if (params.target.kind === 'block' && !params.mergedStates[params.target.blockId]) { + throw new WorkflowTriggerBlockError( + `Workflow does not contain trigger block ${params.target.blockId}` ) } - if (params.start.kind === 'block' && params.start.blockId) { - const blockId = params.start.blockId + if (params.target.kind === 'block') { + const blockId = params.target.blockId const outgoingConnections = params.serializedWorkflow.connections.filter( (connection) => connection.source === blockId ) if (outgoingConnections.length === 0) { - throw new WorkflowStartBlockError( + throw new WorkflowTriggerBlockError( `Trigger block ${blockId} must be connected to other blocks to execute` ) } } - return params.start.blockId + return params.target.blockId } export async function loadWorkflowExecutionBlueprint(params: { @@ -295,7 +291,7 @@ export async function runPreparedWorkflowExecution(params: { actorUserId: string triggerType: TriggerType workflowInput: unknown - start: WorkflowStart + triggerTarget: WorkflowTriggerTarget requestId?: string executionId?: string triggerData?: Record @@ -388,14 +384,14 @@ export async function runPreparedWorkflowExecution(params: { contextExtensions, }) - const startBlockId = resolveStartBlockId({ + const triggerBlockId = resolveTriggerBlockId({ mergedStates, serializedWorkflow, - start: params.start, + target: params.triggerTarget, isChildExecution: contextExtensions.isChildExecution === true, }) - result = await executor.execute(params.blueprint.workflowId, startBlockId) + result = await executor.execute(params.blueprint.workflowId, triggerBlockId) if (result.success) { await updateWorkflowRunCounts(params.blueprint.workflowId).catch((error) => @@ -407,8 +403,8 @@ export async function runPreparedWorkflowExecution(params: { const dispatchFailureReason = error instanceof WorkflowUsageLimitError ? 'usage_limit_exceeded' - : error instanceof WorkflowStartBlockError - ? 'missing_start_block' + : error instanceof WorkflowTriggerBlockError + ? 'missing_trigger_block' : undefined result = (error?.executionResult as ExecutionResult | undefined) || { success: false, @@ -469,7 +465,7 @@ export async function runWorkflowExecution(params: { actorUserId: string triggerType: TriggerType workflowInput: unknown - start: WorkflowStart + triggerTarget: WorkflowTriggerTarget executionTarget?: WorkflowExecutionTarget workflowContext?: WorkflowContextHint workflowData?: WorkflowExecutionBlueprint['workflowData'] @@ -502,7 +498,7 @@ export async function runWorkflowExecution(params: { actorUserId: params.actorUserId, triggerType: params.triggerType, workflowInput: params.workflowInput, - start: params.start, + triggerTarget: params.triggerTarget, requestId: params.requestId, executionId: params.executionId, triggerData: params.triggerData, diff --git a/apps/tradinggoose/lib/workflows/queued-execution-client.ts b/apps/tradinggoose/lib/workflows/queued-execution-client.ts index b4ea6d338..7c67c920d 100644 --- a/apps/tradinggoose/lib/workflows/queued-execution-client.ts +++ b/apps/tradinggoose/lib/workflows/queued-execution-client.ts @@ -2,16 +2,17 @@ import type { WorkflowExecutionEvent } from '@/lib/workflows/execution-events' import { isExecutionResult } from '@/lib/workflows/execution-result' import type { WorkflowExecutionBlueprint } from '@/lib/workflows/execution-runner' import type { ExecutionResult } from '@/executor/types' +import type { QueuedWorkflowTriggerType } from '@/services/queue' type QueuedWorkflowExecutionRequest = { workflowId: string executionId?: string input?: unknown - triggerType: 'api' | 'manual' | 'chat' + triggerType: QueuedWorkflowTriggerType executionTarget: 'deployed' | 'live' workflowData?: WorkflowExecutionBlueprint['workflowData'] workflowVariables?: Record - startBlockId?: string + triggerBlockId?: string selectedOutputs?: string[] stream?: boolean signal?: AbortSignal @@ -91,7 +92,7 @@ export async function queueWorkflowExecution( executionTarget: request.executionTarget, workflowData: request.workflowData, workflowVariables: request.workflowVariables, - startBlockId: request.startBlockId, + triggerBlockId: request.triggerBlockId, selectedOutputs: request.selectedOutputs, stream: request.stream === true, }), diff --git a/apps/tradinggoose/lib/workflows/triggers.test.ts b/apps/tradinggoose/lib/workflows/triggers.test.ts new file mode 100644 index 000000000..30abd0d97 --- /dev/null +++ b/apps/tradinggoose/lib/workflows/triggers.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest' +import { listWorkflowRunTriggers, resolveWorkflowRunTrigger } from './triggers' + +vi.mock('@/blocks', () => { + const trigger = (id: string) => ({ + category: 'triggers', + subBlocks: [], + triggers: { available: [id] }, + outputs: {}, + }) + const registry: Record = { + api_trigger: trigger('api'), + chat_trigger: trigger('chat'), + indicator_trigger: { + ...trigger('indicator_trigger'), + outputs: { listing: { type: 'listingIdentity' }, signal: { type: 'string' } }, + }, + manual_trigger: trigger('manual'), + schedule: trigger('schedule'), + slack: { category: 'blocks', triggers: { available: ['slack_webhook', 'github_webhook'] } }, + } + return { getBlock: (type: string) => registry[type] } +}) + +const block = (type: string, extra: Record = {}) => ({ + type, + subBlocks: {}, + ...extra, +}) + +describe('resolveWorkflowRunTrigger', () => { + it('requires an explicit editor trigger when multiple triggers are runnable', () => { + const mixedTriggers = { + manual: block('manual_trigger'), + schedule: block('schedule'), + shared: block('agent'), + } + const mixedTriggerEdges = [ + { source: 'manual', target: 'shared' }, + { source: 'schedule', target: 'shared' }, + ] + + expect( + resolveWorkflowRunTrigger(mixedTriggers, mixedTriggerEdges, { + surface: 'editor', + triggerBlockId: 'schedule', + }).blockId + ).toBe('schedule') + + expect( + listWorkflowRunTriggers(mixedTriggers, mixedTriggerEdges, { surface: 'editor' }) + ).toEqual([ + { blockId: 'manual', name: 'manual_trigger', triggerSource: 'manual', triggerType: 'manual' }, + { blockId: 'schedule', name: 'schedule', triggerSource: 'schedule', triggerType: 'schedule' }, + ]) + + expect(() => + resolveWorkflowRunTrigger(mixedTriggers, [{ source: 'manual', target: 'shared' }], { + surface: 'editor', + triggerBlockId: 'schedule', + }) + ).toThrow('Trigger block schedule is not available for Run') + }) + + it('resolves runnable trigger identity and editor payloads', () => { + expect(() => + resolveWorkflowRunTrigger( + { slack: block('slack', { triggerMode: true }), agent: block('agent') }, + [{ source: 'slack', target: 'agent' }], + { surface: 'editor', triggerBlockId: 'slack' } + ) + ).toThrow('slack requires a selected trigger type') + + expect( + resolveWorkflowRunTrigger( + { indicator: block('indicator_trigger'), agent: block('agent') }, + [{ source: 'indicator', target: 'agent' }], + { surface: 'editor', triggerBlockId: 'indicator' } + ).input + ).toEqual({ + listing: { listing_id: 'AAPL', base_id: '', quote_id: '', listing_type: 'default' }, + signal: 'mock_signal', + }) + + const copilotRun = resolveWorkflowRunTrigger( + { trigger: block('indicator_trigger'), agent: block('agent') }, + [{ source: 'trigger', target: 'agent' }], + { surface: 'copilot', triggerBlockId: 'trigger' } + ) + expect(copilotRun.triggerType).toBe('manual') + expect(copilotRun.input).toEqual({ + listing: { listing_id: 'AAPL', base_id: '', quote_id: '', listing_type: 'default' }, + signal: 'mock_signal', + }) + + const explicitInput = { listing: { listing_id: 'MSFT' }, signal: 'buy' } + expect( + resolveWorkflowRunTrigger( + { trigger: block('indicator_trigger'), agent: block('agent') }, + [{ source: 'trigger', target: 'agent' }], + { surface: 'copilot', triggerBlockId: 'trigger', workflowInput: explicitInput } + ).input + ).toBe(explicitInput) + }) +}) diff --git a/apps/tradinggoose/lib/workflows/triggers.ts b/apps/tradinggoose/lib/workflows/triggers.ts index bf8bcf9e1..712d93453 100644 --- a/apps/tradinggoose/lib/workflows/triggers.ts +++ b/apps/tradinggoose/lib/workflows/triggers.ts @@ -1,8 +1,9 @@ +import { readBlockOutputs } from '@/lib/workflows/block-outputs' import { getBlock } from '@/blocks' +import type { QueuedWorkflowTriggerType } from '@/services/queue' +import { resolveTriggerExecutionIdentity } from '@/triggers/resolution' +import { generateMockPayloadFromOutputsDefinition } from './triggers/trigger-utils' -/** - * Unified trigger type definitions - */ export const TRIGGER_TYPES = { INPUT: 'input_trigger', MANUAL: 'manual_trigger', @@ -12,84 +13,13 @@ export const TRIGGER_TYPES = { SCHEDULE: 'schedule', } as const -export type TriggerType = (typeof TRIGGER_TYPES)[keyof typeof TRIGGER_TYPES] - -/** - * Mapping from reference alias (used in inline refs like , , etc.) - * to concrete trigger block type identifiers used across the system. - */ -export const TRIGGER_REFERENCE_ALIAS_MAP = { - start: TRIGGER_TYPES.INPUT, - api: TRIGGER_TYPES.API, - chat: TRIGGER_TYPES.CHAT, - manual: TRIGGER_TYPES.INPUT, -} as const - -export type TriggerReferenceAlias = keyof typeof TRIGGER_REFERENCE_ALIAS_MAP - -/** - * Trigger classification and utilities - */ export class TriggerUtils { - /** - * Check if a block is any kind of trigger - */ static isTriggerBlock(block: { type: string; triggerMode?: boolean }): boolean { const blockConfig = getBlock(block.type) - return ( - // New trigger blocks (explicit category) - blockConfig?.category === 'triggers' || - // Blocks with trigger mode enabled - block.triggerMode === true - ) - } - - /** - * Check if a block is a specific trigger type - */ - static isTriggerType(block: { type: string }, triggerType: TriggerType): boolean { - return block.type === triggerType + return blockConfig?.category === 'triggers' || block.triggerMode === true } - /** - * Check if a type string is any trigger type - */ - static isAnyTriggerType(type: string): boolean { - return Object.values(TRIGGER_TYPES).includes(type as TriggerType) - } - - /** - * Check if a block is a chat trigger - */ - static isChatTrigger(block: { type: string; subBlocks?: any }): boolean { - return block.type === TRIGGER_TYPES.CHAT - } - - /** - * Check if a block is a manual trigger - */ - static isManualTrigger(block: { type: string; subBlocks?: any }): boolean { - return block.type === TRIGGER_TYPES.INPUT || block.type === TRIGGER_TYPES.MANUAL - } - - /** - * Check if a block is an API trigger - * @param block - Block to check - * @param isChildWorkflow - Whether this is being called from a child workflow context - */ - static isApiTrigger(block: { type: string; subBlocks?: any }, isChildWorkflow = false): boolean { - if (isChildWorkflow) { - // Child workflows (workflow-in-workflow) only work with input_trigger - return block.type === TRIGGER_TYPES.INPUT - } - // Direct API calls only work with api_trigger - return block.type === TRIGGER_TYPES.API - } - - /** - * Get the default name for a trigger type - */ static getDefaultTriggerName(triggerType: string): string | null { const block = getBlock(triggerType) if ( @@ -107,120 +37,39 @@ export class TriggerUtils { return null } - /** - * Find trigger blocks of a specific type in a workflow - */ - static findTriggersByType( - blocks: T[] | Record, - triggerType: 'chat' | 'manual' | 'api', - isChildWorkflow = false - ): T[] { - const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks) - - switch (triggerType) { - case 'chat': - return blockArray.filter((block) => TriggerUtils.isChatTrigger(block)) - case 'manual': - return blockArray.filter((block) => TriggerUtils.isManualTrigger(block)) - case 'api': - return blockArray.filter((block) => TriggerUtils.isApiTrigger(block, isChildWorkflow)) - default: - return [] - } - } - - /** - * Find the appropriate start block for a given execution context - */ - static findStartBlock( + static findTriggerBlock( blocks: Record, executionType: 'chat' | 'manual' | 'api', isChildWorkflow = false ): { blockId: string; block: T } | null { - const entries = Object.entries(blocks) - - // Look for new trigger blocks first - const triggers = TriggerUtils.findTriggersByType(blocks, executionType, isChildWorkflow) - if (triggers.length > 0) { - const blockId = entries.find(([, b]) => b === triggers[0])?.[0] - if (blockId) { - return { blockId, block: triggers[0] } + const entry = Object.entries(blocks).find(([, block]) => { + if (executionType === 'chat') return block.type === TRIGGER_TYPES.CHAT + if (executionType === 'manual') { + return block.type === TRIGGER_TYPES.INPUT || block.type === TRIGGER_TYPES.MANUAL } - } - - return null - } + return isChildWorkflow ? block.type === TRIGGER_TYPES.INPUT : block.type === TRIGGER_TYPES.API + }) - /** - * Check if multiple triggers of a restricted type exist - */ - static hasMultipleTriggers( - blocks: T[] | Record, - triggerType: TriggerType - ): boolean { - const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks) - const count = blockArray.filter((block) => block.type === triggerType).length - return count > 1 + return entry ? { blockId: entry[0], block: entry[1] } : null } - /** - * Check if a trigger type requires single instance constraint - */ - static requiresSingleInstance(triggerType: string): boolean { - // Each trigger type can only have one instance of itself - // Manual and Input Form can coexist - // API, Chat triggers must be unique - // Schedules and webhooks can have multiple instances - return ( - triggerType === TRIGGER_TYPES.API || - triggerType === TRIGGER_TYPES.INPUT || - triggerType === TRIGGER_TYPES.MANUAL || - triggerType === TRIGGER_TYPES.CHAT - ) - } - - /** - * Check if adding a trigger would violate single instance constraint - */ static wouldViolateSingleInstance( blocks: T[] | Record, triggerType: string ): boolean { - const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks) - - // Only one Input trigger allowed - if (triggerType === TRIGGER_TYPES.INPUT) { - return blockArray.some((block) => block.type === TRIGGER_TYPES.INPUT) - } - - // Only one Manual trigger allowed - if (triggerType === TRIGGER_TYPES.MANUAL) { - return blockArray.some((block) => block.type === TRIGGER_TYPES.MANUAL) - } - - // Only one API trigger allowed - if (triggerType === TRIGGER_TYPES.API) { - return blockArray.some((block) => block.type === TRIGGER_TYPES.API) - } - - // Chat trigger must be unique - if (triggerType === TRIGGER_TYPES.CHAT) { - return blockArray.some((block) => block.type === TRIGGER_TYPES.CHAT) - } - - // Centralized rule: only API, Input, Chat are single-instance - if (!TriggerUtils.requiresSingleInstance(triggerType)) { + if ( + triggerType !== TRIGGER_TYPES.API && + triggerType !== TRIGGER_TYPES.INPUT && + triggerType !== TRIGGER_TYPES.MANUAL && + triggerType !== TRIGGER_TYPES.CHAT + ) { return false } + const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks) return blockArray.some((block) => block.type === triggerType) } - /** - * Evaluate whether adding a trigger of the given type is allowed and, if not, why. - * Returns null if allowed; otherwise returns an object describing the violation. - * This avoids duplicating UI logic across toolbar/drop handlers. - */ static getTriggerAdditionIssue( blocks: T[] | Record, triggerType: string @@ -229,24 +78,148 @@ export class TriggerUtils { return null } - // Otherwise treat as duplicate of a single-instance trigger const triggerName = TriggerUtils.getDefaultTriggerName(triggerType) || 'trigger' return { issue: 'duplicate', triggerName } } +} + +export type WorkflowRunTriggerBlock = { + type: string + name?: string + enabled?: boolean + triggerMode?: boolean + subBlocks?: Record +} - /** - * Get trigger validation message - */ - static getTriggerValidationMessage( - triggerType: 'chat' | 'manual' | 'api', - issue: 'missing' | 'multiple' - ): string { - const triggerName = triggerType.charAt(0).toUpperCase() + triggerType.slice(1) - - if (issue === 'missing') { - return `${triggerName} execution requires a ${triggerName} Trigger block` +type WorkflowRunSurface = 'editor' | 'copilot' + +type WorkflowRunTriggerCandidate = { + blockId: string + block: T + triggerSource: string + triggerType: QueuedWorkflowTriggerType +} + +export type WorkflowRunTriggerOption = { + blockId: string + name: string + triggerSource: string + triggerType: QueuedWorkflowTriggerType +} + +function getTriggerName(blockId: string, block: WorkflowRunTriggerBlock) { + return block.name || TriggerUtils.getDefaultTriggerName(block.type) || block.type || blockId +} + +function getTriggerCandidates( + blocks: Record, + edges: Array<{ source: string; target: string }>, + surface: WorkflowRunSurface +) { + return Object.entries(blocks).filter(([blockId, block]) => { + if (!block?.type || block.enabled === false || !TriggerUtils.isTriggerBlock(block)) { + return false + } + if (surface === 'editor' && block.type === TRIGGER_TYPES.CHAT) { + return false } + return edges.some((edge) => edge.source === blockId) + }) +} + +function getRunnableTriggerCandidates( + blocks: Record, + edges: Array<{ source: string; target: string }>, + surface: WorkflowRunSurface +): Array> { + return getTriggerCandidates(blocks, edges, surface).flatMap(([blockId, block]) => { + try { + const identity = resolveTriggerExecutionIdentity(block) + return [{ blockId, block, ...identity }] + } catch { + return [] + } + }) +} + +export function listWorkflowRunTriggers( + blocks: Record, + edges: Array<{ source: string; target: string }>, + options: { surface: WorkflowRunSurface } +): WorkflowRunTriggerOption[] { + return getRunnableTriggerCandidates(blocks, edges, options.surface).map( + ({ blockId, block, triggerSource, triggerType }) => ({ + blockId, + name: getTriggerName(blockId, block), + triggerSource, + triggerType, + }) + ) +} + +function buildManualRunTriggerInput( + block: WorkflowRunTriggerBlock, + workflowInput: unknown, + options: { preserveProvidedInput: boolean } +): unknown { + if (options.preserveProvidedInput && workflowInput !== undefined) { + return workflowInput + } + + const inputFormat = block.subBlocks?.inputFormat?.value + if (Array.isArray(inputFormat)) { + const testInput: Record = {} + for (const field of inputFormat) { + const name = field && typeof field === 'object' ? (field as { name?: unknown }).name : null + if (typeof name === 'string' && name.length > 0) { + testInput[name] = (field as { value?: unknown }).value + } + } + return Object.keys(testInput).length > 0 ? testInput : (workflowInput ?? {}) + } + + const outputs = readBlockOutputs(block.type, block.subBlocks, true) + return Object.keys(outputs).length > 0 + ? generateMockPayloadFromOutputsDefinition(outputs) + : (workflowInput ?? {}) +} + +export function resolveWorkflowRunTrigger( + blocks: Record, + edges: Array<{ source: string; target: string }>, + options: { + surface: WorkflowRunSurface + workflowInput?: unknown + triggerBlockId: string + } +): { + blockId: string + input: unknown + triggerType: QueuedWorkflowTriggerType +} { + const triggerBlockId = options.triggerBlockId + const triggerCandidates = getTriggerCandidates(blocks, edges, options.surface) + + if (options.surface === 'editor' && blocks[triggerBlockId]?.type === TRIGGER_TYPES.CHAT) { + throw new Error('Chat Trigger blocks run from the chat widget, not editor Run') + } + + const candidate = triggerCandidates.find(([blockId]) => blockId === triggerBlockId) + if (!candidate) { + throw new Error(`Trigger block ${triggerBlockId} is not available for Run`) + } - return `Multiple ${triggerName} Trigger blocks found. Keep only one.` + const [blockId, block] = candidate + const identity = resolveTriggerExecutionIdentity(block) + const isChatRun = identity.triggerType === 'chat' + + return { + blockId, + input: isChatRun + ? options.workflowInput + : buildManualRunTriggerInput(block, options.workflowInput, { + preserveProvidedInput: options.surface === 'copilot', + }), + triggerType: isChatRun ? 'chat' : 'manual', } } diff --git a/apps/tradinggoose/lib/workflows/triggers/trigger-utils.ts b/apps/tradinggoose/lib/workflows/triggers/trigger-utils.ts index 884781abe..29f9913f4 100644 --- a/apps/tradinggoose/lib/workflows/triggers/trigger-utils.ts +++ b/apps/tradinggoose/lib/workflows/triggers/trigger-utils.ts @@ -1,3 +1,5 @@ +import { LISTING_IDENTITY_VALUE_TYPE, type ListingIdentity } from '@/lib/listing/identity' + /** * Generates mock data based on the output type definition */ @@ -26,6 +28,13 @@ function generateMockValue(type: string, _description?: string, fieldName?: stri name: 'Sample Object', status: 'active', } + case LISTING_IDENTITY_VALUE_TYPE: + return { + listing_id: 'AAPL', + base_id: '', + quote_id: '', + listing_type: 'default', + } satisfies ListingIdentity default: return null } diff --git a/apps/tradinggoose/lib/yjs/workflow-session-host.tsx b/apps/tradinggoose/lib/yjs/workflow-session-host.tsx index 76dd606ea..322df4e10 100644 --- a/apps/tradinggoose/lib/yjs/workflow-session-host.tsx +++ b/apps/tradinggoose/lib/yjs/workflow-session-host.tsx @@ -1,34 +1,24 @@ 'use client' -import React, { - createContext, - useCallback, - useContext, - useEffect, - useState, - type ReactNode, -} from 'react' -import * as Y from 'yjs' +import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react' +import type * as Y from 'yjs' +import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' +import { readWorkflowSnapshotCloned, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' import { - EMPTY_SHARED_WORKFLOW_SESSION_STATE, acquireSharedWorkflowSession, + EMPTY_SHARED_WORKFLOW_SESSION_STATE, getSharedWorkflowSessionState, redoSharedWorkflowSession, + type SharedWorkflowSessionState, setSharedWorkflowSessionUser, subscribeToSharedWorkflowSession, undoSharedWorkflowSession, - type SharedWorkflowSessionState, } from '@/lib/yjs/workflow-shared-session' -import { - readWorkflowSnapshotCloned, - type WorkflowSnapshot, -} from '@/lib/yjs/workflow-session' -import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' export interface WorkflowSessionContextValue { workflowId: string doc: Y.Doc | null - awareness: any | null + awareness: SharedWorkflowSessionState['awareness'] isSynced: boolean isLoading: boolean error: string | null @@ -74,7 +64,9 @@ export function WorkflowSessionProvider({ children, }: WorkflowSessionProviderProps) { const [state, setState] = useState(() => - workflowId ? getSharedWorkflowSessionState(workflowId) : { ...EMPTY_SHARED_WORKFLOW_SESSION_STATE } + workflowId + ? getSharedWorkflowSessionState(workflowId) + : { ...EMPTY_SHARED_WORKFLOW_SESSION_STATE } ) const { doc, awareness, isSynced, isLoading, error, canUndo, canRedo } = state @@ -143,9 +135,5 @@ export function WorkflowSessionProvider({ redo, } - return ( - - {children} - - ) + return {children} } diff --git a/apps/tradinggoose/lib/yjs/workflow-shared-session.test.ts b/apps/tradinggoose/lib/yjs/workflow-shared-session.test.ts index 736470b05..d0aca6f7d 100644 --- a/apps/tradinggoose/lib/yjs/workflow-shared-session.test.ts +++ b/apps/tradinggoose/lib/yjs/workflow-shared-session.test.ts @@ -1,5 +1,5 @@ -import * as Y from 'yjs' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' const mockBootstrapYjsProvider = vi.fn() @@ -39,6 +39,26 @@ function createMockProvider() { } } +function createBootstrapResult(doc: Y.Doc, provider: ReturnType) { + return { + doc, + provider, + descriptor: { + workspaceId: 'workspace-1', + entityKind: 'workflow', + entityId: 'workflow-1', + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: 'workflow-1', + }, + runtime: { + docState: 'active', + replaySafe: true, + reseededFromCanonical: false, + }, + } +} + async function waitForCondition(assertion: () => void, timeoutMs = 1000) { const start = Date.now() @@ -68,12 +88,52 @@ describe('workflow shared session lifecycle', () => { mockWaitForYjsWriteSync.mockResolvedValue(undefined) mockRegisterWorkflowSession.mockReset() mockUnregisterWorkflowSession.mockReset() - delete globalThis.__workflowYjsSessionEntries + globalThis.__workflowYjsSessionEntries = undefined }) afterEach(() => { vi.useRealTimers() - delete globalThis.__workflowYjsSessionEntries + globalThis.__workflowYjsSessionEntries = undefined + }) + + it('does not publish a readable doc before bootstrap completes', async () => { + const doc = new Y.Doc() + const provider = createMockProvider() + let finishBootstrap!: () => void + const bootstrapReady = new Promise((resolve) => { + finishBootstrap = resolve + }) + + mockBootstrapYjsProvider.mockImplementation(async () => { + await bootstrapReady + return createBootstrapResult(doc, provider) + }) + + const { acquireSharedWorkflowSession, getSharedWorkflowSessionState } = await import( + './workflow-shared-session' + ) + + const release = acquireSharedWorkflowSession({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + }) + + await waitForCondition(() => { + expect(mockBootstrapYjsProvider).toHaveBeenCalledTimes(1) + }) + expect(getSharedWorkflowSessionState('workflow-1')).toMatchObject({ + doc: null, + isLoading: true, + }) + + finishBootstrap() + + await waitForCondition(() => { + expect(getSharedWorkflowSessionState('workflow-1').doc).toBe(doc) + expect(getSharedWorkflowSessionState('workflow-1').isLoading).toBe(false) + }) + + release() }) it('reuses one bootstrapped workflow session across multiple acquisitions', async () => { @@ -82,23 +142,7 @@ describe('workflow shared session lifecycle', () => { const destroyDoc = vi.spyOn(doc, 'destroy') const provider = createMockProvider() - mockBootstrapYjsProvider.mockResolvedValue({ - doc, - provider, - descriptor: { - workspaceId: 'workspace-1', - entityKind: 'workflow', - entityId: 'workflow-1', - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: 'workflow-1', - }, - runtime: { - docState: 'active', - replaySafe: true, - reseededFromCanonical: false, - }, - }) + mockBootstrapYjsProvider.mockResolvedValue(createBootstrapResult(doc, provider)) const { acquireSharedWorkflowSession, @@ -168,23 +212,7 @@ describe('workflow shared session lifecycle', () => { const destroyDoc = vi.spyOn(doc, 'destroy') const provider = createMockProvider() - mockBootstrapYjsProvider.mockResolvedValue({ - doc, - provider, - descriptor: { - workspaceId: 'workspace-1', - entityKind: 'workflow', - entityId: 'workflow-1', - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: 'workflow-1', - }, - runtime: { - docState: 'active', - replaySafe: true, - reseededFromCanonical: false, - }, - }) + mockBootstrapYjsProvider.mockResolvedValue(createBootstrapResult(doc, provider)) const { acquireSharedWorkflowSession, getSharedWorkflowSessionState } = await import( './workflow-shared-session' @@ -228,23 +256,7 @@ describe('workflow shared session lifecycle', () => { const doc = new Y.Doc() const provider = createMockProvider() - mockBootstrapYjsProvider.mockResolvedValue({ - doc, - provider, - descriptor: { - workspaceId: 'workspace-1', - entityKind: 'workflow', - entityId: 'workflow-1', - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: 'workflow-1', - }, - runtime: { - docState: 'active', - replaySafe: true, - reseededFromCanonical: false, - }, - }) + mockBootstrapYjsProvider.mockResolvedValue(createBootstrapResult(doc, provider)) const { acquireSharedWorkflowSession, diff --git a/apps/tradinggoose/lib/yjs/workflow-shared-session.ts b/apps/tradinggoose/lib/yjs/workflow-shared-session.ts index 3eff46c8b..40b125542 100644 --- a/apps/tradinggoose/lib/yjs/workflow-shared-session.ts +++ b/apps/tradinggoose/lib/yjs/workflow-shared-session.ts @@ -1,7 +1,7 @@ 'use client' -import * as Y from 'yjs' import type { WebsocketProvider } from 'y-websocket' +import * as Y from 'yjs' import type { ReviewTargetDescriptor } from '@/lib/copilot/review-sessions/types' import { deriveUserColor } from '@/lib/utils' import { @@ -9,23 +9,23 @@ import { waitForYjsWriteSync, type YjsProviderBootstrapResult, } from '@/lib/yjs/provider' +import { createYjsUndoTrackedOrigins } from '@/lib/yjs/transaction-origins' import { getMetadataMap, getVariablesMap, readWorkflowMap, readWorkflowTextFieldsMap, } from '@/lib/yjs/workflow-session' -import { createYjsUndoTrackedOrigins } from '@/lib/yjs/transaction-origins' import { + type RegisteredWorkflowSession, registerWorkflowSession, unregisterWorkflowSession, - type RegisteredWorkflowSession, } from '@/lib/yjs/workflow-session-registry' export interface SharedWorkflowSessionState { doc: Y.Doc | null provider: WebsocketProvider | null - awareness: any | null + awareness: WebsocketProvider['awareness'] | null canUndo: boolean canRedo: boolean isSynced: boolean @@ -177,7 +177,11 @@ async function initializeSharedSession(entry: SharedWorkflowSessionEntry): Promi } const undoManager = new Y.UndoManager( - [readWorkflowMap(result.doc), readWorkflowTextFieldsMap(result.doc), getVariablesMap(result.doc)], + [ + readWorkflowMap(result.doc), + readWorkflowTextFieldsMap(result.doc), + getVariablesMap(result.doc), + ], { trackedOrigins: createYjsUndoTrackedOrigins(), } diff --git a/apps/tradinggoose/services/queue/index.ts b/apps/tradinggoose/services/queue/index.ts index 13de63515..45be40759 100644 --- a/apps/tradinggoose/services/queue/index.ts +++ b/apps/tradinggoose/services/queue/index.ts @@ -1,3 +1,3 @@ export { ExecutionLimiter } from '@/services/queue/ExecutionLimiter' -export type { TriggerType } from '@/services/queue/types' +export type { QueuedWorkflowTriggerType, TriggerType } from '@/services/queue/types' export { RateLimitError } from '@/services/queue/types' diff --git a/apps/tradinggoose/services/queue/types.ts b/apps/tradinggoose/services/queue/types.ts index d73167d6b..6561d13c9 100644 --- a/apps/tradinggoose/services/queue/types.ts +++ b/apps/tradinggoose/services/queue/types.ts @@ -1,5 +1,6 @@ // Trigger types for rate limiting export type TriggerType = 'api' | 'webhook' | 'schedule' | 'manual' | 'chat' | 'api-endpoint' +export type QueuedWorkflowTriggerType = Exclude // Rate limit counter types - which counter to increment in the database export type RateLimitCounterType = 'sync' | 'async' | 'api-endpoint' diff --git a/apps/tradinggoose/stores/workflows/registry/store.ts b/apps/tradinggoose/stores/workflows/registry/store.ts index 0b8fa0804..94166bcf0 100644 --- a/apps/tradinggoose/stores/workflows/registry/store.ts +++ b/apps/tradinggoose/stores/workflows/registry/store.ts @@ -1,5 +1,5 @@ -import { createWithEqualityFn as create } from 'zustand/traditional' import { devtools } from 'zustand/middleware' +import { createWithEqualityFn as create } from 'zustand/traditional' import { getStableVibrantColor } from '@/lib/colors' import { createLogger } from '@/lib/logs/console/logger' import { generateCreativeWorkflowName } from '@/lib/naming' @@ -799,7 +799,7 @@ export const useWorkflowRegistry = create()( } logger.warn( - `Workflow ${workflowId} has no state in DB - this should not happen with server-side start block creation` + `Workflow ${workflowId} has no state in DB - this should not happen with server-side trigger block creation` ) } @@ -1063,9 +1063,7 @@ export const useWorkflowRegistry = create()( }, error: null, })) - logger.info( - `Duplicated workflow ${sourceId} to ${id} in workspace ${workspaceId}` - ) + logger.info(`Duplicated workflow ${sourceId} to ${id} in workspace ${workspaceId}`) return id }, diff --git a/apps/tradinggoose/stores/workflows/workflow/utils.test.ts b/apps/tradinggoose/stores/workflows/workflow/utils.test.ts index 01403f95d..96eb5dbe6 100644 --- a/apps/tradinggoose/stores/workflows/workflow/utils.test.ts +++ b/apps/tradinggoose/stores/workflows/workflow/utils.test.ts @@ -1,6 +1,43 @@ import { describe, expect, it } from 'vitest' import type { BlockState } from '@/stores/workflows/workflow/types' -import { convertLoopBlockToLoop } from '@/stores/workflows/workflow/utils' +import { + buildExecutableWorkflowData, + convertLoopBlockToLoop, +} from '@/stores/workflows/workflow/utils' + +const block = (id: string, type = 'agent', extra: Partial = {}): BlockState => ({ + id, + type, + name: id, + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + ...extra, +}) + +describe('buildExecutableWorkflowData', () => { + it.concurrent('keeps blocks, edges, loops, and parallels consistent with enabled blocks', () => { + const blocks: Record = { + trigger: block('trigger', 'manual_trigger'), + loop: block('loop', 'loop'), + parallel: block('parallel', 'parallel'), + active: block('active', 'agent', { data: { parentId: 'loop' } }), + disabled: block('disabled', 'agent', { enabled: false, data: { parentId: 'parallel' } }), + } + + const result = buildExecutableWorkflowData(blocks, [ + { id: 'edge-1', source: 'trigger', target: 'active' }, + { id: 'edge-2', source: 'active', target: 'disabled' }, + { id: 'edge-3', source: 'disabled', target: 'parallel' }, + ]) + + expect(Object.keys(result.blocks).sort()).toEqual(['active', 'loop', 'parallel', 'trigger']) + expect(result.edges).toEqual([{ id: 'edge-1', source: 'trigger', target: 'active' }]) + expect(result.loops.loop.nodes).toEqual(['active']) + expect(result.parallels.parallel.nodes).toEqual([]) + }) +}) describe('convertLoopBlockToLoop', () => { it.concurrent('should parse JSON array string for forEach loops', () => { diff --git a/apps/tradinggoose/stores/workflows/workflow/utils.ts b/apps/tradinggoose/stores/workflows/workflow/utils.ts index 341d0d30a..f240527a1 100644 --- a/apps/tradinggoose/stores/workflows/workflow/utils.ts +++ b/apps/tradinggoose/stores/workflows/workflow/utils.ts @@ -1,3 +1,4 @@ +import type { Edge } from '@xyflow/react' import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' const DEFAULT_LOOP_ITERATIONS = 5 @@ -200,3 +201,20 @@ export function generateParallelBlocks( return parallels } + +export function buildExecutableWorkflowData(blocks: Record, edges: Edge[]) { + const executableBlocks = Object.fromEntries( + Object.entries(blocks).filter(([, block]) => block?.type && block.enabled !== false) + ) + const executableBlockIds = new Set(Object.keys(executableBlocks)) + const executableEdges = edges.filter( + (edge) => executableBlockIds.has(edge.source) && executableBlockIds.has(edge.target) + ) + + return { + blocks: executableBlocks, + edges: executableEdges, + loops: generateLoopBlocks(executableBlocks), + parallels: generateParallelBlocks(executableBlocks), + } +} diff --git a/apps/tradinggoose/triggers/resolution.ts b/apps/tradinggoose/triggers/resolution.ts index c73c725f5..6c4fe7aa6 100644 --- a/apps/tradinggoose/triggers/resolution.ts +++ b/apps/tradinggoose/triggers/resolution.ts @@ -1,10 +1,12 @@ import { getBlock } from '@/blocks' +import type { QueuedWorkflowTriggerType } from '@/services/queue' import { TRIGGER_REGISTRY } from '@/triggers/registry' type TriggerSubBlockValue = { value?: unknown } | unknown type TriggerResolvableBlock = { type: string + name?: string triggerMode?: boolean subBlocks?: Record } @@ -65,3 +67,24 @@ export function resolveTriggerIdForBlock(block: TriggerResolvableBlock): string return resolveTriggerIdFromSubBlocks(block.subBlocks, blockConfig.triggers?.available) } + +export function resolveTriggerExecutionIdentity(block: TriggerResolvableBlock): { + triggerSource: string + triggerType: QueuedWorkflowTriggerType +} { + const triggerSource = resolveTriggerIdForBlock(block) + if (!triggerSource) { + const blockConfig = getBlock(block.type) + throw new Error( + `${block.name || blockConfig?.name || block.type} requires a selected trigger type` + ) + } + + if (block.type === 'api_trigger') return { triggerSource, triggerType: 'api' } + if (block.type === 'chat_trigger') return { triggerSource, triggerType: 'chat' } + if (block.type === 'schedule') return { triggerSource, triggerType: 'schedule' } + if (block.type === 'input_trigger' || block.type === 'manual_trigger') { + return { triggerSource, triggerType: 'manual' } + } + return { triggerSource, triggerType: 'webhook' } +} 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 e08c9fc76..2c44e721a 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,15 +1,27 @@ 'use client' -import { useEffect, useState } from 'react' -import { LayoutDashboard, Play, RefreshCw, X } from 'lucide-react' -import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui' +import { useEffect, useMemo, useState } from 'react' +import { ChevronDown, LayoutDashboard, Play, RefreshCw, X } from 'lucide-react' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui' import { widgetHeaderButtonGroupClassName, widgetHeaderIconButtonClassName, + widgetHeaderMenuContentClassName, + widgetHeaderMenuItemClassName, } from '@/components/widget-header-control' import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' +import { listWorkflowRunTriggers } from '@/lib/workflows/triggers' import { useWorkflowBlocks, useWorkflowEdges } from '@/lib/yjs/use-workflow-doc' import { getKeyboardShortcutText, @@ -17,15 +29,15 @@ import { } from '@/app/workspace/[workspaceId]/components/use-keyboard-shortcuts' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useWorkflowExecution } from '@/hooks/workflow/use-workflow-execution' +import { formatTemplate } from '@/i18n/utils' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import { formatTemplate } from '@/i18n/utils' import { DeploymentControls, ExportControls, } from '@/widgets/widgets/editor_workflow/components/control-bar/components' -import { useWorkflowEditorCopy } from '@/widgets/widgets/editor_workflow/copy' import { useWorkflowRoute } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' +import { useWorkflowEditorCopy } from '@/widgets/widgets/editor_workflow/copy' const logger = createLogger('ControlBar') @@ -87,7 +99,8 @@ export function ControlBar({ const { workflowId, channelId } = useWorkflowRoute() const isRegistryLoading = useWorkflowRegistry((state) => state.isLoading) const activeWorkflowId = workflowId - const { isExecuting, handleRunWorkflow, handleCancelExecution } = useWorkflowExecution() + const { isExecuting, isWorkflowSessionReady, handleRunWorkflow, handleCancelExecution } = + useWorkflowExecution() // User permissions - use stable activeWorkspaceId from registry instead of deriving from currentWorkflow const userPermissions = useUserPermissionsContext() @@ -113,15 +126,27 @@ export function ControlBar({ limit: number } | null>(null) + const currentBlocks = useWorkflowBlocks() + const currentEdges = useWorkflowEdges() + const runTriggers = useMemo( + () => listWorkflowRunTriggers(currentBlocks, currentEdges, { surface: 'editor' }), + [currentBlocks, currentEdges] + ) + // Shared condition for keyboard shortcut and button disabled state - const isWorkflowBlocked = isExecuting || hasValidationErrors + const isWorkflowBlocked = + isExecuting || hasValidationErrors || !isWorkflowSessionReady || runTriggers.length === 0 + const canRunWithShortcut = runTriggers.length === 1 // Register keyboard shortcut for running workflow - useKeyboardShortcuts(() => { - if (!isWorkflowBlocked && userPermissions.canEdit) { - handleRunWorkflow() - } - }, isWorkflowBlocked || !userPermissions.canEdit) + useKeyboardShortcuts( + () => { + if (!isWorkflowBlocked && userPermissions.canEdit && canRunWithShortcut) { + handleRunWorkflow({ triggerBlockId: runTriggers[0].blockId }) + } + }, + isWorkflowBlocked || !userPermissions.canEdit || !canRunWithShortcut + ) // Get deployment status from registry const deploymentStatus = useWorkflowRegistry((state) => @@ -211,10 +236,6 @@ export function ControlBar({ } }, [activeWorkflowId, isDeployed, isRegistryLoading]) - // Get current state for change detection (from Yjs doc) - const currentBlocks = useWorkflowBlocks() - const currentEdges = useWorkflowEdges() - useEffect(() => { if (!activeWorkflowId || !deployedState) { setChangeDetected(false) @@ -432,6 +453,10 @@ export function ControlBar({ return copy.controlBar.writePermissionRequiredToRunWorkflows } + if (runTriggers.length === 0) { + return 'Run requires a connected configured trigger block' + } + if (usageExceeded) { return (
@@ -449,20 +474,60 @@ export function ControlBar({ return copy.controlBar.run } - const handleRunClick = () => { + const handleRunClick = (triggerBlockId: string) => { if (usageExceeded) { openSubscriptionSettings() } else { - handleRunWorkflow() + handleRunWorkflow({ triggerBlockId }) } } + if (runTriggers.length > 1) { + return ( + + + + + + + + + + + {getTooltipContent()} + + + + {runTriggers.map((trigger) => ( + { + event.preventDefault() + handleRunClick(trigger.blockId) + }} + > + + {trigger.name} + + ))} + + + ) + } + return ( - - -

{isPendingInvitation ? 'Revoke invite' : 'Remove member'}

-
-
- )} + + + + + +

{isPendingInvitation ? 'Revoke invite' : 'Remove member'}

+
+
+ )}
@@ -445,6 +449,7 @@ export function WorkspaceInviteModal({ onOpenChange, workspaceName, workspaceId, + workspaceOwnerId, }: WorkspaceInviteModalProps) { const locale = useLocale() as LocaleCode const formRef = useRef(null) @@ -655,8 +660,12 @@ export function WorkspaceInviteModal({ throw new Error(data.error || 'Failed to update permissions') } - if (data.users && data.total !== undefined) { - updatePermissions({ users: data.users, total: data.total }) + if (data.users && data.total !== undefined && data.currentUserPermission) { + updatePermissions({ + users: data.users, + total: data.total, + currentUserPermission: data.currentUserPermission, + }) } setExistingUserPermissionChanges({}) @@ -732,6 +741,7 @@ export function WorkspaceInviteModal({ (user) => user.userId !== memberToRemove.userId ) updatePermissions({ + ...workspacePermissions, users: updatedUsers, total: workspacePermissions.total - 1, }) @@ -1047,9 +1057,7 @@ export function WorkspaceInviteModal({ - - Invite members to {workspaceName || 'Workspace'} - + Invite members to {workspaceName || 'Workspace'}
@@ -1116,6 +1124,7 @@ export function WorkspaceInviteModal({ permissionsLoading={permissionsLoading} pendingInvitations={pendingInvitations} isPendingInvitationsLoading={isPendingInvitationsLoading} + workspaceOwnerId={workspaceOwnerId} resendingInvitationIds={resendingInvitationIds} resentInvitationIds={resentInvitationIds} resendCooldowns={resendCooldowns} @@ -1156,7 +1165,11 @@ export function WorkspaceInviteModal({ type='button' onClick={() => formRef.current?.requestSubmit()} disabled={ - !userPerms.canAdmin || isSubmitting || isSaving || !resolvedWorkspaceId || !hasNewInvites + !userPerms.canAdmin || + isSubmitting || + isSaving || + !resolvedWorkspaceId || + !hasNewInvites } className={cn( 'ml-auto flex h-9 items-center justify-center gap-2 rounded-sm px-4 py-2 font-medium transition-all duration-200', @@ -1280,6 +1293,7 @@ export function WorkspaceDialogs({ onOpenChange={onInviteDialogChange} workspaceName={inviteWorkspace?.name} workspaceId={inviteWorkspace?.id} + workspaceOwnerId={inviteWorkspace.ownerId} /> ) : null} diff --git a/apps/tradinggoose/global-navbar/types.ts b/apps/tradinggoose/global-navbar/types.ts index 39d1031de..1de46f1a2 100644 --- a/apps/tradinggoose/global-navbar/types.ts +++ b/apps/tradinggoose/global-navbar/types.ts @@ -17,7 +17,7 @@ export interface Workspace { name: string ownerId: string billingOwner?: { type: 'user'; userId: string } | { type: 'organization'; organizationId: string } - role?: string + role: 'owner' | 'member' membershipId?: string - permissions?: 'admin' | 'write' | 'read' | null + permissions: 'admin' | 'write' | 'read' } diff --git a/apps/tradinggoose/global-navbar/use-workspace-switcher.test.ts b/apps/tradinggoose/global-navbar/use-workspace-switcher.test.ts index b9e8807a1..b7bb50be5 100644 --- a/apps/tradinggoose/global-navbar/use-workspace-switcher.test.ts +++ b/apps/tradinggoose/global-navbar/use-workspace-switcher.test.ts @@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const mockPush = vi.fn() +const mockReplace = vi.fn() let mockSwitchToWorkspace = vi.fn() let fetchMock: ReturnType let originalFetch: typeof globalThis.fetch @@ -29,6 +30,7 @@ afterAll(() => { vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push: mockPush, + replace: mockReplace, }), })) @@ -44,6 +46,7 @@ vi.mock('@/stores/workflows/registry/store', () => ({ describe('useWorkspaceSwitcher', () => { beforeEach(() => { mockPush.mockReset() + mockReplace.mockReset() mockSwitchToWorkspace = vi.fn() latestValue = null @@ -100,6 +103,7 @@ describe('useWorkspaceSwitcher', () => { expect(latestValue.canManageWorkspaces).toBe(true) expect(latestValue.activeWorkspace?.id).toBe('ws-1') expect(fetchMock.mock.calls.map(([url]) => String(url))).toContain('/api/workspaces') + expect(mockReplace).not.toHaveBeenCalled() await act(async () => { latestValue.setWorkspaceMenuOpen(true) @@ -115,4 +119,26 @@ describe('useWorkspaceSwitcher', () => { expect(latestValue.inviteDialogOpen).toBe(true) expect(latestValue.deleteDialogOpen).toBe(true) }) + + it('does not redirect during the workspace bootstrap fetch (server owns the root redirect)', async () => { + const { useWorkspaceSwitcher } = await import('@/global-navbar/use-workspace-switcher') + + function Harness() { + latestValue = useWorkspaceSwitcher({ + enabled: true, + section: 'dashboard', + }) + return null + } + + await act(async () => { + root?.render(React.createElement(Harness)) + await flush() + }) + + expect(fetchMock.mock.calls.map(([url]) => String(url))).toContain('/api/workspaces') + expect(latestValue.activeWorkspace?.id).toBe('ws-1') + expect(mockReplace).not.toHaveBeenCalled() + expect(mockPush).not.toHaveBeenCalled() + }) }) diff --git a/apps/tradinggoose/global-navbar/use-workspace-switcher.ts b/apps/tradinggoose/global-navbar/use-workspace-switcher.ts index 124878bac..5dd71e3f0 100644 --- a/apps/tradinggoose/global-navbar/use-workspace-switcher.ts +++ b/apps/tradinggoose/global-navbar/use-workspace-switcher.ts @@ -18,7 +18,7 @@ export function useWorkspaceSwitcher({ workspaceId, section, }: UseWorkspaceSwitcherOptions) { - const router = useRouter() + const { push } = useRouter() const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace) const canManageWorkspaces = true const [workspaces, setWorkspaces] = React.useState([]) @@ -55,20 +55,19 @@ export function useWorkspaceSwitcher({ return } - const data = await response.json() - const items = ((data.workspaces ?? []) as Workspace[]).map((workspace) => ({ - ...workspace, - permissions: workspace.permissions ?? 'admin', - role: workspace.role ?? (workspace.permissions === 'admin' ? 'owner' : 'member'), - })) + const data = (await response.json()) as { workspaces?: Workspace[] } + const items = data.workspaces ?? [] setWorkspaces(items) + const firstWorkspace = items[0] ?? null + if (workspaceId) { - const match = items.find((workspace) => workspace.id === workspaceId) - setActiveWorkspace(match ?? items[0] ?? null) + setActiveWorkspace( + items.find((workspace) => workspace.id === workspaceId) ?? firstWorkspace + ) } else { - setActiveWorkspace((current) => current ?? items[0] ?? null) + setActiveWorkspace((current) => current ?? firstWorkspace) } } catch (error) { console.error('Error fetching workspaces:', error) @@ -100,9 +99,9 @@ export function useWorkspaceSwitcher({ } } - router.push(getWorkspaceSwitchPath(workspace.id, section)) + push(getWorkspaceSwitchPath(workspace.id, section)) }, - [router, section, switchToWorkspace, workspaceId] + [push, section, switchToWorkspace, workspaceId] ) const handleCreateWorkspace = React.useCallback(async () => { @@ -128,15 +127,11 @@ export function useWorkspaceSwitcher({ throw new Error(error?.error ?? 'Failed to create workspace') } - const data = await response.json() + const data = (await response.json()) as { workspace?: Workspace } await fetchWorkspaces() if (data.workspace) { - await handleSwitchWorkspace({ - ...data.workspace, - permissions: data.workspace.permissions ?? 'admin', - role: data.workspace.role ?? 'owner', - } satisfies Workspace) + await handleSwitchWorkspace(data.workspace) } } catch (error) { console.error('Error creating workspace:', error) diff --git a/apps/tradinggoose/hooks/queries/workspace.ts b/apps/tradinggoose/hooks/queries/workspace.ts index c292fcae4..e36a581ac 100644 --- a/apps/tradinggoose/hooks/queries/workspace.ts +++ b/apps/tradinggoose/hooks/queries/workspace.ts @@ -81,6 +81,7 @@ export interface WorkspaceSettingsResponse { } | null permissions: { users: WorkspaceSettingsUser[] + currentUserPermission: 'admin' | 'write' | 'read' } | null } @@ -176,15 +177,7 @@ async function fetchAdminWorkspaces(userId: string | undefined): Promise - user.id === userId || user.userId === userId - ) - hasAdminAccess = currentUserPermission?.permissionType === 'admin' - } + const hasAdminAccess = permissionData.currentUserPermission === 'admin' const isOwner = workspace.isOwner || workspace.ownerId === userId diff --git a/apps/tradinggoose/hooks/use-user-permissions.test.tsx b/apps/tradinggoose/hooks/use-user-permissions.test.tsx index 7b65ce389..5931d5047 100644 --- a/apps/tradinggoose/hooks/use-user-permissions.test.tsx +++ b/apps/tradinggoose/hooks/use-user-permissions.test.tsx @@ -1,10 +1,9 @@ /** @vitest-environment jsdom */ -import React, { act } from 'react' +import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' -const mockUseSession = vi.fn() const reactActEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -14,18 +13,6 @@ let container: HTMLDivElement | null = null let root: Root | null = null let latestValue: unknown = null -vi.mock('@/lib/auth-client', () => ({ - useSession: () => mockUseSession(), -})) - -vi.mock('@/lib/logs/console/logger', () => ({ - createLogger: () => ({ - error: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - }), -})) - beforeAll(() => { reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true }) @@ -37,12 +24,6 @@ afterAll(() => { describe('useUserPermissions', () => { beforeEach(() => { latestValue = null - mockUseSession.mockReset() - mockUseSession.mockReturnValue({ - data: null, - isPending: true, - error: null, - }) container = document.createElement('div') document.body.appendChild(container) @@ -61,26 +42,11 @@ describe('useUserPermissions', () => { container = null }) - it('keeps permissions loading while the auth session is still pending', async () => { + it('keeps permissions loading while workspace permissions are still pending', async () => { const { useUserPermissions } = await import('@/hooks/use-user-permissions') function Harness() { - latestValue = useUserPermissions( - { - users: [ - { - userId: 'user-1', - email: 'member@example.com', - name: 'Member', - image: null, - permissionType: 'admin', - }, - ], - total: 1, - }, - false, - null - ) + latestValue = useUserPermissions(null, true, null) return null } @@ -97,18 +63,7 @@ describe('useUserPermissions', () => { }) }) - it('returns resolved permissions once the auth session is available', async () => { - mockUseSession.mockReturnValue({ - data: { - user: { - id: 'user-1', - email: 'member@example.com', - }, - }, - isPending: false, - error: null, - }) - + it('returns server-derived current user permissions', async () => { const { useUserPermissions } = await import('@/hooks/use-user-permissions') function Harness() { @@ -124,6 +79,7 @@ describe('useUserPermissions', () => { }, ], total: 1, + currentUserPermission: 'write', }, false, null diff --git a/apps/tradinggoose/hooks/use-user-permissions.ts b/apps/tradinggoose/hooks/use-user-permissions.ts index 5292b7726..13bb68d3b 100644 --- a/apps/tradinggoose/hooks/use-user-permissions.ts +++ b/apps/tradinggoose/hooks/use-user-permissions.ts @@ -1,10 +1,6 @@ import { useMemo } from 'react' -import { useSession } from '@/lib/auth-client' -import { createLogger } from '@/lib/logs/console/logger' import type { PermissionType, WorkspacePermissions } from '@/hooks/use-workspace-permissions' -const logger = createLogger('useUserPermissions') - export interface WorkspaceUserPermissions { // Core permission checks canRead: boolean @@ -31,78 +27,53 @@ export function useUserPermissions( permissionsLoading = false, permissionsError: string | null = null ): WorkspaceUserPermissions { - const { - data: session, - isPending: isSessionPending, - error: sessionError, - } = useSession() - const userPermissions = useMemo((): WorkspaceUserPermissions => { - const sessionEmail = session?.user?.email - const sessionErrorMessage = sessionError?.message ?? null - const resolvedError = permissionsError ?? sessionErrorMessage - - if (permissionsLoading || isSessionPending) { + if (permissionsLoading) { return { canRead: false, canEdit: false, canAdmin: false, userPermissions: 'read', isLoading: true, - error: resolvedError, + error: permissionsError, } } - if (!sessionEmail) { + if (permissionsError) { return { canRead: false, canEdit: false, canAdmin: false, userPermissions: 'read', isLoading: false, - error: sessionErrorMessage ?? 'Authentication required', + error: permissionsError, } } - // Find current user in workspace permissions (case-insensitive) - const currentUser = workspacePermissions?.users?.find( - (user) => user.email.toLowerCase() === sessionEmail.toLowerCase() - ) - - // If user not found in workspace, they have no permissions - if (!currentUser) { - logger.warn('User not found in workspace permissions', { - userEmail: sessionEmail, - hasPermissions: !!workspacePermissions, - userCount: workspacePermissions?.users?.length || 0, - }) - + const userPerms = workspacePermissions?.currentUserPermission + if (!userPerms) { return { canRead: false, canEdit: false, canAdmin: false, userPermissions: 'read', isLoading: false, - error: resolvedError || 'User not found in workspace', + error: 'User not found in workspace', } } - const userPerms = currentUser.permissionType || 'read' - - // Core permission checks const canAdmin = userPerms === 'admin' const canEdit = userPerms === 'write' || userPerms === 'admin' - const canRead = true // If user is found in workspace permissions, they have read access return { - canRead, + canRead: true, canEdit, canAdmin, userPermissions: userPerms, isLoading: false, - error: resolvedError, + error: null, } - }, [session, workspacePermissions, permissionsLoading, permissionsError, isSessionPending, sessionError]) + }, [workspacePermissions?.currentUserPermission, permissionsLoading, permissionsError]) return userPermissions } diff --git a/apps/tradinggoose/hooks/use-workspace-permissions.test.tsx b/apps/tradinggoose/hooks/use-workspace-permissions.test.tsx index b3320f750..61cd465b4 100644 --- a/apps/tradinggoose/hooks/use-workspace-permissions.test.tsx +++ b/apps/tradinggoose/hooks/use-workspace-permissions.test.tsx @@ -5,21 +5,31 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useWorkspacePermissions } from './use-workspace-permissions' +import { + resetWorkspacePermissionsStore, + useWorkspacePermissions, +} from './use-workspace-permissions' const mockHandleAuthError = vi.hoisted(() => vi.fn()) +const mockUseSession = vi.hoisted(() => vi.fn()) +let latestValue: ReturnType | null = null +let workspaceId = 'workspace-401' vi.mock('@/lib/auth/auth-error-handler', () => ({ handleAuthError: mockHandleAuthError, isAuthErrorStatus: (status?: number | null) => status === 401, })) +vi.mock('@/lib/auth-client', () => ({ + useSession: mockUseSession, +})) + vi.mock('@/i18n/navigation', () => ({ usePathname: () => '/workspace/workspace-1/dashboard', })) function WorkspacePermissionsProbe() { - useWorkspacePermissions('workspace-401') + latestValue = useWorkspacePermissions(workspaceId) return null } @@ -32,7 +42,19 @@ describe('useWorkspacePermissions', () => { beforeEach(() => { reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + latestValue = null + workspaceId = 'workspace-401' mockHandleAuthError.mockResolvedValue(undefined) + mockUseSession.mockReturnValue({ + data: { + user: { + id: 'user-1', + }, + }, + isPending: false, + error: null, + refetch: vi.fn(), + }) vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401, statusText: 'Unauthorized' })) @@ -49,6 +71,7 @@ describe('useWorkspacePermissions', () => { container.remove() vi.unstubAllGlobals() vi.clearAllMocks() + resetWorkspacePermissionsStore() reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false }) @@ -62,5 +85,85 @@ describe('useWorkspacePermissions', () => { 'workspace-permissions', '/workspace/workspace-1/dashboard' ) + expect(latestValue).toMatchObject({ + loading: true, + error: null, + permissions: null, + }) + }) + + it('routes resolved missing sessions through auth recovery without completing permission load', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + mockUseSession.mockReturnValue({ + data: null, + isPending: false, + error: null, + refetch: vi.fn(), + }) + + await act(async () => { + root.render() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(fetchMock).not.toHaveBeenCalled() + expect(mockHandleAuthError).toHaveBeenCalledWith( + 'workspace-permissions', + '/workspace/workspace-1/dashboard' + ) + expect(latestValue).toMatchObject({ + loading: true, + error: null, + permissions: null, + }) + }) + + it('does not reuse a cached workspace permission record after the active user changes', async () => { + workspaceId = 'workspace-1' + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + users: [], + total: 0, + currentUserPermission: 'admin', + }) + ) + .mockResolvedValueOnce( + Response.json({ + users: [], + total: 0, + currentUserPermission: 'read', + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await act(async () => { + root.render() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(latestValue?.permissions?.currentUserPermission).toBe('admin') + + mockUseSession.mockReturnValue({ + data: { + user: { + id: 'user-2', + }, + }, + isPending: false, + error: null, + refetch: vi.fn(), + }) + + await act(async () => { + root.render() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(latestValue?.permissions?.currentUserPermission).toBe('read') }) }) diff --git a/apps/tradinggoose/hooks/use-workspace-permissions.ts b/apps/tradinggoose/hooks/use-workspace-permissions.ts index 95cca9c6c..e63f6a10b 100644 --- a/apps/tradinggoose/hooks/use-workspace-permissions.ts +++ b/apps/tradinggoose/hooks/use-workspace-permissions.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect } from 'react' import type { permissionTypeEnum } from '@tradinggoose/db/schema' import { createWithEqualityFn as create } from 'zustand/traditional' import { handleAuthError, isAuthErrorStatus } from '@/lib/auth/auth-error-handler' +import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { usePathname } from '@/i18n/navigation' import { API_ENDPOINTS } from '@/stores/constants' @@ -23,6 +24,7 @@ export interface WorkspaceUser { export interface WorkspacePermissions { users: WorkspaceUser[] total: number + currentUserPermission: PermissionType } interface UseWorkspacePermissionsReturn { @@ -48,8 +50,10 @@ type WorkspacePermissionsRecord = { interface WorkspacePermissionsStoreState { records: Record inFlight: Partial>> - setRecord: (workspaceId: string, partial: Partial) => void + setRecord: (recordKey: string, partial: Partial) => void + clearRecord: (recordKey: string) => void fetchPermissions: ( + recordKey: string, workspaceId: string, options: { callbackPathname: string; force?: boolean } ) => Promise @@ -64,29 +68,35 @@ const createDefaultRecord = (): WorkspacePermissionsRecord => ({ const useWorkspacePermissionsStore = create((set, get) => ({ records: {}, inFlight: {}, - setRecord: (workspaceId, partial) => + setRecord: (recordKey, partial) => set((state) => { - const prev = state.records[workspaceId] ?? createDefaultRecord() + const prev = state.records[recordKey] ?? createDefaultRecord() return { records: { ...state.records, - [workspaceId]: { + [recordKey]: { ...prev, ...partial, }, }, } }), - fetchPermissions: async (workspaceId, options) => { + clearRecord: (recordKey) => + set((state) => { + const records = { ...state.records } + delete records[recordKey] + return { records } + }), + fetchPermissions: async (recordKey, workspaceId, options) => { const { callbackPathname, force = false } = options - const { records, inFlight, setRecord } = get() + const { records, inFlight, setRecord, clearRecord } = get() if (!force) { - if (inFlight[workspaceId]) { - return inFlight[workspaceId] + if (inFlight[recordKey]) { + return inFlight[recordKey] } - const existing = records[workspaceId] + const existing = records[recordKey] if (existing?.permissions && !existing?.error) { return } @@ -94,7 +104,7 @@ const useWorkspacePermissionsStore = create((set const fetchPromise = (async () => { try { - setRecord(workspaceId, { loading: true, error: null }) + setRecord(recordKey, { loading: true, error: null }) const response = await fetch(API_ENDPOINTS.WORKSPACE_PERMISSIONS(workspaceId)) @@ -104,7 +114,8 @@ const useWorkspacePermissionsStore = create((set } if (isAuthErrorStatus(response.status)) { await handleAuthError('workspace-permissions', callbackPathname) - throw new Error('Authentication required') + clearRecord(recordKey) + return } throw new Error(`Failed to fetch permissions: ${response.statusText}`) } @@ -117,7 +128,7 @@ const useWorkspacePermissionsStore = create((set users: data.users.map((u) => ({ email: u.email, permissions: u.permissionType })), }) - setRecord(workspaceId, { + setRecord(recordKey, { permissions: data, loading: false, error: null, @@ -128,14 +139,14 @@ const useWorkspacePermissionsStore = create((set workspaceId, error: errorMessage, }) - setRecord(workspaceId, { + setRecord(recordKey, { loading: false, error: errorMessage, }) } finally { set((state) => { const next = { ...state.inFlight } - delete next[workspaceId] + delete next[recordKey] return { inFlight: next } }) } @@ -144,7 +155,7 @@ const useWorkspacePermissionsStore = create((set set((state) => ({ inFlight: { ...state.inFlight, - [workspaceId]: fetchPromise, + [recordKey]: fetchPromise, }, })) @@ -152,41 +163,66 @@ const useWorkspacePermissionsStore = create((set }, })) +function getRecordKey(workspaceId: string, userId: string) { + return `${userId}:${workspaceId}` +} + +export function resetWorkspacePermissionsStore() { + useWorkspacePermissionsStore.setState({ records: {}, inFlight: {} }) +} + export function useWorkspacePermissions(workspaceId: string | null): UseWorkspacePermissionsReturn { const callbackPathname = usePathname() + const session = useSession() + const userId = session.data?.user?.id ?? null + const recordKey = workspaceId && userId ? getRecordKey(workspaceId, userId) : null const record = useWorkspacePermissionsStore((state) => - workspaceId ? state.records[workspaceId] : undefined + recordKey ? state.records[recordKey] : undefined ) const fetchPermissions = useWorkspacePermissionsStore((state) => state.fetchPermissions) const setRecord = useWorkspacePermissionsStore((state) => state.setRecord) useEffect(() => { - if (!workspaceId) { + if (!workspaceId || session.isPending) { return () => {} } - fetchPermissions(workspaceId, { callbackPathname }).catch((error) => { - logger.error('Failed to load workspace permissions', { workspaceId, error }) - }) - }, [workspaceId, callbackPathname, fetchPermissions]) + + if (!userId) { + handleAuthError('workspace-permissions', callbackPathname).catch((error) => + logger.error('Failed to route missing workspace session through auth recovery', { + workspaceId, + error, + }) + ) + return () => {} + } + + fetchPermissions(getRecordKey(workspaceId, userId), workspaceId, { callbackPathname }).catch( + (error) => { + logger.error('Failed to load workspace permissions', { workspaceId, error }) + } + ) + }, [workspaceId, session.isPending, userId, callbackPathname, fetchPermissions]) const refetch = useCallback(async () => { - if (!workspaceId) return - await fetchPermissions(workspaceId, { callbackPathname, force: true }) - }, [workspaceId, callbackPathname, fetchPermissions]) + if (!workspaceId || !recordKey) return + await fetchPermissions(recordKey, workspaceId, { callbackPathname, force: true }) + }, [workspaceId, recordKey, callbackPathname, fetchPermissions]) const updatePermissions = useCallback( (newPermissions: WorkspacePermissions) => { - if (!workspaceId) return - setRecord(workspaceId, { + if (!recordKey) return + setRecord(recordKey, { permissions: newPermissions, loading: false, error: null, }) }, - [workspaceId, setRecord] + [recordKey, setRecord] ) - const isInitialLoad = Boolean(workspaceId) && !record + const isInitialLoad = + Boolean(workspaceId) && (session.isPending || !userId || Boolean(recordKey && !record)) return { permissions: record?.permissions ?? null, diff --git a/apps/tradinggoose/i18n/utils.ts b/apps/tradinggoose/i18n/utils.ts index b080f0e00..8c9e6f72f 100644 --- a/apps/tradinggoose/i18n/utils.ts +++ b/apps/tradinggoose/i18n/utils.ts @@ -27,6 +27,15 @@ export function normalizeLocaleCode(locale: LocaleInput): LocaleCode { return locale && isLocaleCode(locale) ? locale : defaultLocale } +export function requireCanonicalCallbackPath(headers: Headers, routeName: string) { + const callbackUrl = headers.get(CANONICAL_CALLBACK_PATH_HEADER) + if (!callbackUrl) { + throw new Error(`Missing canonical callback path for ${routeName} reauth redirect`) + } + + return callbackUrl +} + export function getLocaleDisplayName(locale: LocaleCode) { return LOCALE_DISPLAY_NAMES[locale] } diff --git a/apps/tradinggoose/lib/admin/access.ts b/apps/tradinggoose/lib/admin/access.ts index 83f3aac2c..cdce464c1 100644 --- a/apps/tradinggoose/lib/admin/access.ts +++ b/apps/tradinggoose/lib/admin/access.ts @@ -38,8 +38,8 @@ export async function claimFirstSystemAdmin(userId: string) { }) } -export async function getSystemAdminAccess() { - const session = await getSession() +export async function getSystemAdminAccess(headersOverride?: Headers) { + const session = await getSession(headersOverride) const user = session?.user ?? null const userId = user?.id ?? null diff --git a/apps/tradinggoose/lib/copilot/review-sessions/permissions.test.ts b/apps/tradinggoose/lib/copilot/review-sessions/permissions.test.ts index 423eccfb5..c7f7c36dd 100644 --- a/apps/tradinggoose/lib/copilot/review-sessions/permissions.test.ts +++ b/apps/tradinggoose/lib/copilot/review-sessions/permissions.test.ts @@ -63,7 +63,9 @@ import { loadReviewSessionForUser, loadReviewSessionForUserByConversationId, verifyReviewTargetAccess, + verifyWorkflowAccess, } from '@/lib/copilot/review-sessions/permissions' +import { readWorkflowAccessContext } from '@/lib/workflows/utils' import { resolveEntityWorkspaceId } from '@/lib/yjs/server/entity-loaders' type MockChain = { @@ -77,6 +79,7 @@ type MockChain = { const mockDb = db as unknown as { select: ReturnType } const mockResolveEntityWorkspaceId = vi.mocked(resolveEntityWorkspaceId) +const mockReadWorkflowAccessContext = vi.mocked(readWorkflowAccessContext) function createMockChain(finalResult: any): MockChain { const chain: any = {} @@ -272,4 +275,27 @@ describe('review session permissions', () => { expect(result).toBeNull() }) + + it('treats canonical workspace owners as workflow admins without permission rows', async () => { + mockReadWorkflowAccessContext.mockResolvedValueOnce({ + workflow: { + id: 'workflow-1', + userId: 'member-1', + workspaceId: 'workspace-1', + } as NonNullable>>['workflow'], + workspaceOwnerId: 'owner-1', + workspacePermission: null, + isOwner: false, + isWorkspaceOwner: true, + }) + + const result = await verifyWorkflowAccess('owner-1', 'workflow-1', 'write') + + expect(result).toEqual({ + hasAccess: true, + userPermission: 'admin', + workspaceId: 'workspace-1', + isOwner: false, + }) + }) }) diff --git a/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts b/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts index a9d6351c0..5c47c2070 100644 --- a/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts +++ b/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts @@ -247,7 +247,9 @@ export async function verifyWorkflowAccess( return buildAccessResult({ isOwner: accessContext.isOwner, - userPermission: accessContext.workspacePermission ?? null, + userPermission: accessContext.isWorkspaceOwner + ? 'admin' + : (accessContext.workspacePermission ?? null), workspaceId: accessContext.workflow.workspaceId ?? null, accessMode, }) diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index 118f0f76e..a6f9dd199 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -5,9 +5,8 @@ import { embedding, knowledgeBase, knowledgeBaseTagDefinitions, - permissions, } from '@tradinggoose/db/schema' -import { and, count, eq, inArray, isNotNull, isNull } from 'drizzle-orm' +import { and, count, eq, inArray, isNull } from 'drizzle-orm' import { checkStorageQuota, incrementStorageUsage } from '@/lib/billing/storage' import { enqueueDocumentProcessingJobs } from '@/lib/knowledge/documents/service' import { @@ -20,7 +19,7 @@ import type { KnowledgeBaseWithCounts, } from '@/lib/knowledge/types' import { createLogger } from '@/lib/logs/console/logger' -import { getUserEntityPermissions } from '@/lib/permissions/utils' +import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/permissions/utils' const logger = createLogger('KnowledgeBaseService') @@ -31,6 +30,11 @@ export async function getKnowledgeBases( userId: string, workspaceId: string ): Promise { + const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + if (!workspaceAccess.hasAccess) { + return [] + } + const knowledgeBasesWithCounts = await db .select({ id: knowledgeBase.id, @@ -50,21 +54,7 @@ export async function getKnowledgeBases( document, and(eq(document.knowledgeBaseId, knowledgeBase.id), isNull(document.deletedAt)) ) - .leftJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, knowledgeBase.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where( - and( - isNull(knowledgeBase.deletedAt), - eq(knowledgeBase.workspaceId, workspaceId), - isNotNull(permissions.userId) - ) - ) + .where(and(isNull(knowledgeBase.deletedAt), eq(knowledgeBase.workspaceId, workspaceId))) .groupBy(knowledgeBase.id) .orderBy(knowledgeBase.createdAt) diff --git a/apps/tradinggoose/lib/permissions/utils.test.ts b/apps/tradinggoose/lib/permissions/utils.test.ts index 8e1232a9f..e21cbdb68 100644 --- a/apps/tradinggoose/lib/permissions/utils.test.ts +++ b/apps/tradinggoose/lib/permissions/utils.test.ts @@ -27,7 +27,6 @@ vi.mock('@tradinggoose/db/schema', () => ({ id: 'user_id', email: 'user_email', name: 'user_name', - image: 'user_image', }, workspace: { id: 'workspace_id', @@ -54,10 +53,7 @@ import { getUserEntityPermissions, getUsersWithPermissions, getWorkspaceById, - getWorkspaceMemberProfiles, - hasAdminPermission, hasWorkspaceAdminAccess, - workspaceExists, } from '@/lib/permissions/utils' const mockDb = db as any @@ -197,16 +193,18 @@ describe('Permission Utils', () => { expect(result).toBeNull() }) - }) - describe('workspace helpers', () => { - it('should report when a workspace exists', async () => { - const chain = createMockChain([{ id: 'workspace123' }]) - mockDb.select.mockReturnValue(chain) + it('should return admin for workspace owners without permission rows', async () => { + mockDb.select.mockReturnValueOnce(createMockChain([{ ownerId: 'owner-1' }])) - await expect(workspaceExists('workspace123')).resolves.toBe(true) + const result = await getUserEntityPermissions('owner-1', 'workspace', 'workspace456') + + expect(result).toBe('admin') + expect(mockDb.select).toHaveBeenCalledTimes(1) }) + }) + describe('workspace helpers', () => { it('should return the workspace row by id', async () => { const workspaceRow = { id: 'workspace123', @@ -334,84 +332,38 @@ describe('Permission Utils', () => { }) }) - describe('getWorkspaceMemberProfiles', () => { - it('should return minimal member profiles for a workspace', async () => { - const members = [ - { userId: 'user-1', name: 'Alice', image: 'alice.png' }, - { userId: 'user-2', name: 'Bob', image: null }, - ] - mockDb.select.mockReturnValue(createMockChain(members)) - - const result = await getWorkspaceMemberProfiles('workspace123') - - expect(result).toEqual(members) - }) - }) - - describe('hasAdminPermission', () => { - it('should return true when user has admin permission for workspace', async () => { - const chain = createMockChain([{ id: 'perm1' }]) - mockDb.select.mockReturnValue(chain) - - const result = await hasAdminPermission('admin-user', 'workspace123') - - expect(result).toBe(true) - }) - - it('should return false when user has no admin permission for workspace', async () => { - const chain = createMockChain([]) - mockDb.select.mockReturnValue(chain) - - const result = await hasAdminPermission('regular-user', 'workspace123') - - expect(result).toBe(false) - }) - - it('should return false when user has write permission but not admin', async () => { - const chain = createMockChain([]) - mockDb.select.mockReturnValue(chain) - - const result = await hasAdminPermission('write-user', 'workspace123') - - expect(result).toBe(false) - }) - - it('should return false when user has read permission but not admin', async () => { - const chain = createMockChain([]) - mockDb.select.mockReturnValue(chain) - - const result = await hasAdminPermission('read-user', 'workspace123') - - expect(result).toBe(false) - }) - - it('should handle non-existent workspace', async () => { - const chain = createMockChain([]) - mockDb.select.mockReturnValue(chain) - - const result = await hasAdminPermission('user123', 'non-existent-workspace') - - expect(result).toBe(false) - }) - - it('should handle empty user ID', async () => { - const chain = createMockChain([]) - mockDb.select.mockReturnValue(chain) + describe('getUsersWithPermissions', () => { + it('should return empty array when the workspace owner is unavailable', async () => { + const ownerChain = createMockChain([]) + const usersChain = createMockChain([]) + mockDb.select.mockReturnValueOnce(ownerChain).mockReturnValueOnce(usersChain) - const result = await hasAdminPermission('', 'workspace123') + const result = await getUsersWithPermissions('workspace123') - expect(result).toBe(false) + expect(result).toEqual([]) }) - }) - describe('getUsersWithPermissions', () => { - it('should return empty array when no users have permissions for workspace', async () => { + it('should include the workspace owner as admin without a permission row', async () => { + const ownerChain = createMockChain([ + { + userId: 'owner-1', + email: 'owner@example.com', + name: 'Owner User', + }, + ]) const usersChain = createMockChain([]) - mockDb.select.mockReturnValue(usersChain) + mockDb.select.mockReturnValueOnce(ownerChain).mockReturnValueOnce(usersChain) const result = await getUsersWithPermissions('workspace123') - expect(result).toEqual([]) + expect(result).toEqual([ + { + userId: 'owner-1', + email: 'owner@example.com', + name: 'Owner User', + permissionType: 'admin', + }, + ]) }) it('should return users with their permissions for workspace', async () => { @@ -424,8 +376,9 @@ describe('Permission Utils', () => { }, ] + const ownerChain = createMockChain([]) const usersChain = createMockChain(mockUsersResults) - mockDb.select.mockReturnValue(usersChain) + mockDb.select.mockReturnValueOnce(ownerChain).mockReturnValueOnce(usersChain) const result = await getUsersWithPermissions('workspace456') @@ -461,15 +414,16 @@ describe('Permission Utils', () => { }, ] + const ownerChain = createMockChain([]) const usersChain = createMockChain(mockUsersResults) - mockDb.select.mockReturnValue(usersChain) + mockDb.select.mockReturnValueOnce(ownerChain).mockReturnValueOnce(usersChain) const result = await getUsersWithPermissions('workspace456') expect(result).toHaveLength(3) - expect(result[0].permissionType).toBe('admin') - expect(result[1].permissionType).toBe('write') - expect(result[2].permissionType).toBe('read') + expect(result.find((row) => row.userId === 'user1')?.permissionType).toBe('admin') + expect(result.find((row) => row.userId === 'user2')?.permissionType).toBe('write') + expect(result.find((row) => row.userId === 'user3')?.permissionType).toBe('read') }) it('should handle users with empty names', async () => { @@ -482,8 +436,9 @@ describe('Permission Utils', () => { }, ] + const ownerChain = createMockChain([]) const usersChain = createMockChain(mockUsersResults) - mockDb.select.mockReturnValue(usersChain) + mockDb.select.mockReturnValueOnce(ownerChain).mockReturnValueOnce(usersChain) const result = await getUsersWithPermissions('workspace123') @@ -508,7 +463,7 @@ describe('Permission Utils', () => { if (callCount === 1) { return createMockChain([{ ownerId: 'other-user' }]) } - return createMockChain([{ id: 'perm1' }]) + return createMockChain([{ permissionType: 'admin' }]) }) const result = await hasWorkspaceAdminAccess('user123', 'workspace456') @@ -532,7 +487,7 @@ describe('Permission Utils', () => { if (callCount === 1) { return createMockChain([{ ownerId: 'other-user' }]) } - return createMockChain([]) + return createMockChain([{ permissionType: 'write' }]) }) const result = await hasWorkspaceAdminAccess('user123', 'workspace456') @@ -547,7 +502,7 @@ describe('Permission Utils', () => { if (callCount === 1) { return createMockChain([{ ownerId: 'other-user' }]) } - return createMockChain([]) + return createMockChain([{ permissionType: 'read' }]) }) const result = await hasWorkspaceAdminAccess('user123', 'workspace456') diff --git a/apps/tradinggoose/lib/permissions/utils.ts b/apps/tradinggoose/lib/permissions/utils.ts index e9ccedeb5..42b12fd50 100644 --- a/apps/tradinggoose/lib/permissions/utils.ts +++ b/apps/tradinggoose/lib/permissions/utils.ts @@ -13,27 +13,11 @@ export interface WorkspaceAccess { workspace: WorkspaceRecord | null } -export interface WorkspaceMemberProfile { - userId: string - name: string - image: string | null -} - async function selectWorkspaceById(workspaceId: string): Promise { const [row] = await db.select().from(workspace).where(eq(workspace.id, workspaceId)).limit(1) return row ?? null } -export async function workspaceExists(workspaceId: string): Promise { - const [row] = await db - .select({ id: workspace.id }) - .from(workspace) - .where(eq(workspace.id, workspaceId)) - .limit(1) - - return !!row -} - export async function getWorkspaceById(workspaceId: string): Promise { return await selectWorkspaceById(workspaceId) } @@ -111,10 +95,14 @@ export async function getUserEntityPermissions( entityId: string ): Promise { if (entityType === 'workspace') { - const activeWorkspace = await workspaceExists(entityId) + const activeWorkspace = await selectWorkspaceById(entityId) if (!activeWorkspace) { return null } + + if (activeWorkspace.ownerId === userId) { + return 'admin' + } } const result = await db @@ -142,30 +130,6 @@ export async function getUserEntityPermissions( return highestPermission.permissionType } -/** - * Check if a user has admin permission for a specific workspace - * - * @param userId - The ID of the user to check - * @param workspaceId - The ID of the workspace to check - * @returns Promise - True if the user has admin permission for the workspace, false otherwise - */ -export async function hasAdminPermission(userId: string, workspaceId: string): Promise { - const result = await db - .select({ id: permissions.id }) - .from(permissions) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId), - eq(permissions.permissionType, 'admin') - ) - ) - .limit(1) - - return result.length > 0 -} - /** * Retrieves a list of users with their associated permissions for a given workspace. * @@ -180,6 +144,17 @@ export async function getUsersWithPermissions(workspaceId: string): Promise< permissionType: PermissionType }> > { + const [owner] = await db + .select({ + userId: user.id, + email: user.email, + name: user.name, + }) + .from(workspace) + .innerJoin(user, eq(workspace.ownerId, user.id)) + .where(eq(workspace.id, workspaceId)) + .limit(1) + const usersWithPermissions = await db .select({ userId: user.id, @@ -193,12 +168,35 @@ export async function getUsersWithPermissions(workspaceId: string): Promise< .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) .orderBy(user.email) - return usersWithPermissions.map((row) => ({ - userId: row.userId, - email: row.email, - name: row.name, - permissionType: row.permissionType, - })) + const usersById = new Map< + string, + { + userId: string + email: string + name: string + permissionType: PermissionType + } + >() + + if (owner) { + usersById.set(owner.userId, { + ...owner, + permissionType: 'admin', + }) + } + + for (const row of usersWithPermissions) { + if (!usersById.has(row.userId)) { + usersById.set(row.userId, { + userId: row.userId, + email: row.email, + name: row.name, + permissionType: row.permissionType, + }) + } + } + + return [...usersById.values()].sort((a, b) => a.email.localeCompare(b.email)) } /** @@ -212,17 +210,7 @@ export async function hasWorkspaceAdminAccess( userId: string, workspaceId: string ): Promise { - const ws = await selectWorkspaceById(workspaceId) - - if (!ws) { - return false - } - - if (ws.ownerId === userId) { - return true - } - - return await hasAdminPermission(userId, workspaceId) + return (await getUserEntityPermissions(userId, 'workspace', workspaceId)) === 'admin' } /** @@ -279,20 +267,3 @@ export async function getManageableWorkspaces(userId: string): Promise< return combined } - -export async function getWorkspaceMemberProfiles( - workspaceId: string -): Promise { - const rows = await db - .select({ - userId: user.id, - name: user.name, - image: user.image, - }) - .from(permissions) - .innerJoin(user, eq(permissions.userId, user.id)) - .innerJoin(workspace, eq(permissions.entityId, workspace.id)) - .where(and(eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId))) - - return rows -} diff --git a/apps/tradinggoose/lib/workflows/utils.ts b/apps/tradinggoose/lib/workflows/utils.ts index f61f6c1b1..1cb6f6005 100644 --- a/apps/tradinggoose/lib/workflows/utils.ts +++ b/apps/tradinggoose/lib/workflows/utils.ts @@ -569,9 +569,9 @@ export async function validateWorkflowPermissions( } } - const { workflow, workspacePermission, isOwner } = accessContext + const { workflow, workspacePermission, isOwner, isWorkspaceOwner } = accessContext - if (isOwner) { + if (isOwner || isWorkspaceOwner) { return { error: null, session, diff --git a/apps/tradinggoose/lib/workspaces/service.ts b/apps/tradinggoose/lib/workspaces/service.ts new file mode 100644 index 000000000..aef7c5a75 --- /dev/null +++ b/apps/tradinggoose/lib/workspaces/service.ts @@ -0,0 +1,165 @@ +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 { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' +import { toWorkspaceApiRecord } from '@/lib/workspaces/billing-owner' +import { tryApplyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' + +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 + .select({ + workspace: workspace, + permissionType: permissions.permissionType, + }) + .from(workspace) + .leftJoin(permissions, workspaceAccess.permissionJoin) + .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) { + throw new Error(`Expected workspace permission for ${workspaceDetails.id}`) + } + + return { + ...toWorkspaceApiRecord(workspaceDetails), + role: resolvedPermissionType === 'admin' ? 'owner' : 'member', + permissions: resolvedPermissionType, + } + }) +} + +export async function createWorkspace(userId: string, name: string) { + const workspaceId = crypto.randomUUID() + const workflowId = crypto.randomUUID() + const now = new Date() + const workspaceDetails = { + id: workspaceId, + name, + ownerId: userId, + billingOwnerType: 'user', + billingOwnerUserId: userId, + billingOwnerOrganizationId: null, + allowPersonalApiKeys: true, + 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() + 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 seedResult = await tryApplyWorkflowState( + 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, + lastSaved, + isDeployed: false, + }), + undefined, + 'default-agent' + ) + if (!seedResult.success) { + throw seedResult.error instanceof Error + ? seedResult.error + : new Error('Failed to seed 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 + } + + return { + ...toWorkspaceApiRecord(workspaceDetails), + role: 'owner', + 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/proxy.test.ts b/apps/tradinggoose/proxy.test.ts index 70a260fe6..a303bfaf0 100644 --- a/apps/tradinggoose/proxy.test.ts +++ b/apps/tradinggoose/proxy.test.ts @@ -1,9 +1,6 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const PLAIN_SESSION_COOKIE = 'better-auth.session_token=session-cookie' -const SECURE_SESSION_COOKIE = '__Secure-better-auth.session_token=session-cookie' - vi.mock('./lib/logs/console/logger', () => ({ createLogger: () => ({ warn: vi.fn(), @@ -40,15 +37,19 @@ describe('proxy auth routing', () => { process.env.NEXT_PUBLIC_APP_URL = 'https://www.tradinggoose.ai' }) - it('uses the request host for localhost auth redirects instead of hosted-mode rewrites', async () => { + it('uses the request host for protected route locale redirects', async () => { const { proxy } = await import('./proxy') const response = await proxy( - new NextRequest('http://localhost:3000/workspace/ws-1/dashboard?layoutId=layout-1') + new NextRequest('http://localhost:3000/workspace/ws-1/dashboard?layoutId=layout-1', { + headers: { + 'user-agent': 'vitest', + }, + }) ) expect(response.status).toBe(307) expect(response.headers.get('location')).toBe( - 'http://localhost:3000/en/login?callbackUrl=%2Fworkspace%2Fws-1%2Fdashboard%3FlayoutId%3Dlayout-1' + 'http://localhost:3000/en/workspace/ws-1/dashboard?layoutId=layout-1' ) expect(response.headers.get('x-middleware-rewrite')).toBeNull() expect(response.cookies.get('NEXT_LOCALE')?.value).toBe('en') @@ -98,7 +99,7 @@ describe('proxy auth routing', () => { expect(response.cookies.get('NEXT_LOCALE')?.value).toBe('zh') }) - it('redirects anonymous unprefixed protected routes using Accept-Language', async () => { + it('localizes unprefixed protected routes using Accept-Language', async () => { const { proxy } = await import('./proxy') const response = await proxy( new NextRequest('http://localhost:3000/workspace/ws-1/dashboard', { @@ -111,30 +112,16 @@ describe('proxy auth routing', () => { expect(response.status).toBe(307) expect(response.headers.get('location')).toBe( - 'http://localhost:3000/es/login?callbackUrl=%2Fworkspace%2Fws-1%2Fdashboard' + 'http://localhost:3000/es/workspace/ws-1/dashboard' ) expect(response.cookies.get('NEXT_LOCALE')?.value).toBe('es') }) - it('redirects hosted protected routes to login when no session is present', async () => { - const { proxy } = await import('./proxy') - const response = await proxy( - new NextRequest('https://www.tradinggoose.ai/workspace/ws-1/dashboard') - ) - - expect(response.status).toBe(307) - expect(response.headers.get('location')).toBe( - 'https://www.tradinggoose.ai/en/login?callbackUrl=%2Fworkspace%2Fws-1%2Fdashboard' - ) - expect(response.headers.get('x-middleware-rewrite')).toBeNull() - }) - - it('accepts Better Auth plain session cookies on HTTPS protected routes', async () => { + it('localizes hosted protected routes before the app auth boundary handles access', async () => { const { proxy } = await import('./proxy') const response = await proxy( new NextRequest('https://www.tradinggoose.ai/workspace/ws-1/dashboard', { headers: { - cookie: PLAIN_SESSION_COOKIE, 'user-agent': 'vitest', }, }) @@ -147,12 +134,13 @@ describe('proxy auth routing', () => { expect(response.headers.get('x-middleware-rewrite')).toBeNull() }) - it('accepts Better Auth secure session cookies on HTTPS protected routes', async () => { + it('lets the default-locale reauth login route reach its page boundary', async () => { + process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' + const { proxy } = await import('./proxy') const response = await proxy( - new NextRequest('https://www.tradinggoose.ai/en/workspace', { + new NextRequest('http://localhost:3000/en/login?reauth=1&callbackUrl=%2Fworkspace%2Fws-1', { headers: { - cookie: SECURE_SESSION_COOKIE, 'user-agent': 'vitest', }, }) @@ -163,38 +151,20 @@ describe('proxy auth routing', () => { expect(response.cookies.get('NEXT_LOCALE')?.value).toBe('en') }) - it('allows the default-locale reauth login route through without proxy-owned cookie cleanup', async () => { - process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' - + it('keeps localized protected routes at the app auth boundary with a canonical callback header', async () => { const { proxy } = await import('./proxy') const response = await proxy( - new NextRequest( - 'http://localhost:3000/en/login?reauth=1&callbackUrl=%2Fworkspace%2Fws-1', - { - headers: { - cookie: PLAIN_SESSION_COOKIE, - 'user-agent': 'vitest', - }, - } - ) + new NextRequest('http://localhost:3000/es/workspace/ws-1/dashboard?layoutId=layout-1', { + headers: { + 'user-agent': 'vitest', + }, + }) ) expect(response.status).toBe(200) expect(response.headers.get('location')).toBeNull() - expect(response.cookies.get('NEXT_LOCALE')?.value).toBe('en') - expect(response.cookies.get('better-auth.session_token')).toBeUndefined() - expect(response.cookies.get('__Secure-better-auth.session_token')).toBeUndefined() - }) - - it('preserves locale on the login route while keeping callback targets canonical', async () => { - const { proxy } = await import('./proxy') - const response = await proxy( - new NextRequest('http://localhost:3000/es/workspace/ws-1/dashboard?layoutId=layout-1') - ) - - expect(response.status).toBe(307) - expect(response.headers.get('location')).toBe( - 'http://localhost:3000/es/login?callbackUrl=%2Fworkspace%2Fws-1%2Fdashboard%3FlayoutId%3Dlayout-1' + expect(response.headers.get('x-middleware-request-x-tradinggoose-callback-path')).toBe( + '/workspace/ws-1/dashboard?layoutId=layout-1' ) expect(response.cookies.get('NEXT_LOCALE')?.value).toBe('es') }) @@ -207,14 +177,13 @@ describe('proxy auth routing', () => { '/es/verify', '/es/sso', '/es/error', - ])('lets session-cookie auth route %s reach its page boundary', async (pathname) => { + ])('lets auth route %s reach its page boundary', async (pathname) => { process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' const { proxy } = await import('./proxy') const response = await proxy( new NextRequest(`http://localhost:3000${pathname}`, { headers: { - cookie: PLAIN_SESSION_COOKIE, 'user-agent': 'vitest', }, }) @@ -260,14 +229,14 @@ describe('proxy auth routing', () => { it.each([ ['root', 'http://localhost:3000/?source=nav', 'http://localhost:3000/es?source=nav'], ['workspace', 'http://localhost:3000/workspace', 'http://localhost:3000/es/workspace'], - ])('redirects session-cookie %s requests to the request locale', async (_, url, location) => { + ])('redirects locale-cookie %s requests to the request locale', async (_, url, location) => { process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' const { proxy } = await import('./proxy') const response = await proxy( new NextRequest(url, { headers: { - cookie: `NEXT_LOCALE=es; ${PLAIN_SESSION_COOKIE}`, + cookie: 'NEXT_LOCALE=es', 'user-agent': 'vitest', }, }) @@ -278,14 +247,14 @@ describe('proxy auth routing', () => { expect(response.cookies.get('NEXT_LOCALE')?.value).toBe('es') }) - it('keeps session-cookie prefixed requests canonical to the URL locale', async () => { + it('keeps locale-cookie prefixed requests canonical to the URL locale', async () => { process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' const { proxy } = await import('./proxy') const response = await proxy( new NextRequest('http://localhost:3000/en/workspace', { headers: { - cookie: `NEXT_LOCALE=zh; ${PLAIN_SESSION_COOKIE}`, + cookie: 'NEXT_LOCALE=zh', 'user-agent': 'vitest', }, }) @@ -296,7 +265,7 @@ describe('proxy auth routing', () => { expect(response.cookies.get('NEXT_LOCALE')?.value).toBe('en') }) - it('rewrites session-cookie POST protected requests with the canonical callback header', async () => { + it('rewrites POST protected requests with the canonical callback header', async () => { process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' const { proxy } = await import('./proxy') @@ -304,7 +273,7 @@ describe('proxy auth routing', () => { new NextRequest('http://localhost:3000/workspace/ws-1/dashboard?layoutId=layout-1', { method: 'POST', headers: { - cookie: `NEXT_LOCALE=es; ${PLAIN_SESSION_COOKIE}`, + cookie: 'NEXT_LOCALE=es', 'user-agent': 'vitest', }, }) @@ -333,7 +302,6 @@ describe('proxy auth routing', () => { 'http://localhost:3000/es/api/workspaces/invitations/invitation-1?token=abc', { headers: { - cookie: PLAIN_SESSION_COOKIE, 'user-agent': 'vitest', }, } diff --git a/apps/tradinggoose/proxy.ts b/apps/tradinggoose/proxy.ts index de89acde0..671ecb383 100644 --- a/apps/tradinggoose/proxy.ts +++ b/apps/tradinggoose/proxy.ts @@ -1,4 +1,3 @@ -import { getSessionCookie } from 'better-auth/cookies' import { type NextRequest, NextResponse } from 'next/server' import createMiddleware from 'next-intl/middleware' import { appendHomepageDiscoveryLinks } from '@/lib/discovery/link-headers' @@ -123,17 +122,6 @@ function resolveRequestLocale(request: NextRequest): LocaleCode { ) } -function buildLoginRedirect(request: NextRequest, route: LocaleRoute, callback?: string) { - const { locale } = route - const loginUrl = new URL(localizeUrl(request.nextUrl.origin, locale, '/login')) - - if (callback) { - loginUrl.searchParams.set('callbackUrl', callback) - } - - return withLocaleCookie(NextResponse.redirect(loginUrl), locale) -} - function isProtectedAppPath(pathname: string): boolean { const { pathname: normalizedPathname } = resolveLocaleRoute(pathname) @@ -147,10 +135,7 @@ function isProtectedAppPath(pathname: string): boolean { function buildProtectedRequestHeaders(request: NextRequest, route: LocaleRoute) { const requestHeaders = new Headers(request.headers) - requestHeaders.set( - CANONICAL_CALLBACK_PATH_HEADER, - `${route.pathname}${request.nextUrl.search}` - ) + requestHeaders.set(CANONICAL_CALLBACK_PATH_HEADER, `${route.pathname}${request.nextUrl.search}`) return requestHeaders } @@ -279,16 +264,11 @@ function handleSecurityFiltering(request: NextRequest): NextResponse | null { export async function proxy(request: NextRequest) { const url = request.nextUrl const initialRoute = resolveLocaleRoute(url.pathname) - const hasSessionCookie = Boolean(getSessionCookie(request)) const route = resolveCanonicalLocaleRoute(request, initialRoute) const { locale, pathname: normalizedPathname } = route const isProtectedPath = isProtectedAppPath(url.pathname) - if (isProtectedPath && !hasSessionCookie) { - return buildLoginRedirect(request, route, `${route.pathname}${url.search}`) - } - const protectedRequestHeaders = isProtectedPath ? buildProtectedRequestHeaders(request, route) : undefined diff --git a/apps/tradinggoose/stores/index.ts b/apps/tradinggoose/stores/index.ts index a37fc490f..124a01d7d 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 { resetWorkspacePermissionsStore } from '@/hooks/use-workspace-permissions' import { useConsoleStore } from '@/stores/console/store' import { getCopilotStore, useCopilotStore } from '@/stores/copilot/store' import { useCustomToolsStore } from '@/stores/custom-tools/store' @@ -72,6 +73,7 @@ export const resetAllStores = () => { useCustomToolsStore.getState().resetAll() useSkillsStore.getState().resetAll() useIndicatorsStore.getState().resetAll() + resetWorkspacePermissionsStore() // Variables store has no tracking to reset; registry hydrates useSubscriptionStore.getState().reset() // Reset subscription store } diff --git a/apps/tradinggoose/stores/organization/store.ts b/apps/tradinggoose/stores/organization/store.ts index 1d658730b..edd2d234b 100644 --- a/apps/tradinggoose/stores/organization/store.ts +++ b/apps/tradinggoose/stores/organization/store.ts @@ -1,5 +1,5 @@ -import { createWithEqualityFn as create } from 'zustand/traditional' import { devtools } from 'zustand/middleware' +import { createWithEqualityFn as create } from 'zustand/traditional' import { client } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { stripLocaleFromPathname } from '@/i18n/utils' @@ -297,18 +297,8 @@ export const useOrganizationStore = create()( if (permissionResponse.ok) { const permissionData = await permissionResponse.json() - // Check if current user has admin permission - // Use userId if provided, otherwise fall back to checking isOwner from workspace data - let hasAdminAccess = false - - if (userId && permissionData.users) { - const currentUserPermission = permissionData.users.find( - (user: any) => user.id === userId || user.userId === userId - ) - hasAdminAccess = currentUserPermission?.permissionType === 'admin' - } + const hasAdminAccess = permissionData.currentUserPermission === 'admin' - // Also check if user is the workspace owner const isOwner = workspace.isOwner || workspace.ownerId === userId if (hasAdminAccess || isOwner) { From 61514c430b40a7ab9b260c2fb2554a1768933846 Mon Sep 17 00:00:00 2001 From: Bruzzz BackUp <149516937+BWJ2310-backup@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:56:55 -0600 Subject: [PATCH 12/18] =?UTF-8?q?refactor(workspace-permissions):=20thread?= =?UTF-8?q?=20authenticated=20user=20id=20through=E2=80=A6=20(#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(workspace-permissions): thread authenticated user id through permission loading Use the server-provided user id at the layout boundary, remove redundant nested workspace permission providers from widgets, and update the permission hook/tests to depend on explicit auth context. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(auth): thread authenticated user id through navbar Pass the authenticated user id from the admin and workspace layouts into GlobalNavbar so WorkspaceDialogs can reuse the server-provided identity instead of calling useSession. Update the admin layout test to assert the propagated user id. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(auth): use authenticated user id for workspace dialogs Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workspace-invite): prevent self-invites Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(auth): propagate authenticated email through navbar Use the authenticated user email from layouts in the global navbar and workspace invite modal so self-invite checks and role detection don't depend on workspace permission lookups. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * feat(navbar): portal header slots into targets Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(auth): preserve auth recovery during workspace permissions fetch Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workspace-permissions): restore current user row in invite modal Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * refactor(workspace-permissions): simplify initial loading fallback Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(auth): wait for client auth before loading workspaces Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(auth): reset workspace redirect state per user access key Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(auth): avoid refetching session-recovery permissions Skip reloading a workspace permission record that already resolved to SESSION_EXPIRED when the same user/workspace key remounts. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(auth): gate auth-dependent queries until session matches user Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workspace): gate permissions UI on matching session user Require the authenticated session user to match the active user before loading workspace permissions or organization data, and hide the permissions table when there are no users to display. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workspace): gate workspace data on client auth Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(auth): guard workspace data on stable session state Derive client auth readiness from the session hook and ignore stale workspace responses when user/session identity changes. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(navbar): keep the latest header owner active Track header slot ownership so overlapping contributors do not render stale content after one unmounts. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(auth): scope cached billing queries by user id Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * refactor(auth): use server-authenticated workspace context Propagate authenticated user IDs through workspace permissions, navbar, and widget consumers while removing client-session readiness gates. Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workspace): use workspace user identity in invite dialogs Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(auth): pass workspace user to global navbar Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workspace-auth): require inherited workspace sessions in nested providers Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --------- Co-authored-by: Codex Co-authored-by: BWJ2310 --- .../app/[locale]/admin/layout.test.tsx | 11 +- .../app/[locale]/admin/layout.tsx | 13 +- .../workspace/[workspaceId]/layout.test.tsx | 21 +++- .../workspace/[workspaceId]/layout.tsx | 2 +- .../app/[locale]/workspace/layout.tsx | 14 ++- .../[workspaceId]/providers/providers.tsx | 14 ++- .../workspace-permissions-provider.test.tsx | 111 +++++++++++++++++- .../workspace-permissions-provider.tsx | 83 +++++++------ .../components/workspace-dialogs.tsx | 93 +++++++++++---- .../global-navbar/global-navbar.tsx | 3 + .../hooks/use-workspace-permissions.test.tsx | 95 ++++++++------- .../hooks/use-workspace-permissions.ts | 64 ++++------ .../copilot/components/copilot-app.tsx | 2 +- .../workflow-controlbar/controlbar.tsx | 2 +- .../components/workflow-editor-app.tsx | 2 +- .../workflow-editor/workflow-canvas.tsx | 10 +- .../workflow-toolbar/workflow-toolbar.tsx | 2 +- .../widgets/list_custom_tool/index.tsx | 4 +- .../widgets/widgets/list_indicator/index.tsx | 4 +- .../widgets/widgets/list_mcp/index.tsx | 4 +- .../widgets/widgets/list_skill/index.tsx | 4 +- .../widgets/widgets/list_workflow/index.tsx | 4 +- .../components/workflow-chat-app.tsx | 2 +- .../components/workflow-console-app.tsx | 2 +- .../components/workflow-variables-app.tsx | 2 +- 25 files changed, 388 insertions(+), 180 deletions(-) diff --git a/apps/tradinggoose/app/[locale]/admin/layout.test.tsx b/apps/tradinggoose/app/[locale]/admin/layout.test.tsx index ab48facc5..6e86f0004 100644 --- a/apps/tradinggoose/app/[locale]/admin/layout.test.tsx +++ b/apps/tradinggoose/app/[locale]/admin/layout.test.tsx @@ -6,6 +6,7 @@ import { CANONICAL_CALLBACK_PATH_HEADER } from '@/i18n/utils' let capturedGlobalNavbarProps: | { isSystemAdmin?: boolean + workspaceUser?: { id: string; email: string | null } | null navigationMode?: 'workspace' | 'admin' } | undefined @@ -54,13 +55,15 @@ vi.mock('@/global-navbar', () => ({ GlobalNavbar: ({ children, isSystemAdmin, + workspaceUser, navigationMode, }: { children: React.ReactNode isSystemAdmin?: boolean + workspaceUser?: { id: string; email: string | null } | null navigationMode?: 'workspace' | 'admin' }) => { - capturedGlobalNavbarProps = { isSystemAdmin, navigationMode } + capturedGlobalNavbarProps = { isSystemAdmin, workspaceUser, navigationMode } return
{children}
}, })) @@ -81,6 +84,8 @@ describe('Admin layout', () => { mockGetSystemAdminAccess.mockResolvedValue({ isAuthenticated: true, isSystemAdmin: false, + userId: 'admin-user-1', + user: { email: 'admin@example.com' }, canBootstrapSystemAdmin: true, }) @@ -93,6 +98,10 @@ describe('Admin layout', () => { expect(renderToStaticMarkup(result)).toContain('admin content') expect(capturedGlobalNavbarProps).toEqual({ isSystemAdmin: false, + workspaceUser: { + id: 'admin-user-1', + email: 'admin@example.com', + }, navigationMode: 'admin', }) expect(mockGetSystemAdminAccess).toHaveBeenCalledWith(expect.any(Headers)) diff --git a/apps/tradinggoose/app/[locale]/admin/layout.tsx b/apps/tradinggoose/app/[locale]/admin/layout.tsx index d44db0ad7..c9a8781e5 100644 --- a/apps/tradinggoose/app/[locale]/admin/layout.tsx +++ b/apps/tradinggoose/app/[locale]/admin/layout.tsx @@ -39,7 +39,18 @@ export default async function AdminLayout({ return ( - + {children} diff --git a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/layout.test.tsx b/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/layout.test.tsx index b2a4dd296..7fcfec243 100644 --- a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/layout.test.tsx +++ b/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/layout.test.tsx @@ -42,8 +42,18 @@ vi.mock('@/lib/permissions/utils', () => ({ })) vi.mock('@/app/workspace/[workspaceId]/providers/providers', () => ({ - default: ({ children, workspaceId }: { children: React.ReactNode; workspaceId?: string }) => ( -
{children}
+ default: ({ + children, + workspaceId, + userId, + }: { + children: React.ReactNode + workspaceId: string + userId: string + }) => ( +
+ {children} +
), })) @@ -155,8 +165,11 @@ describe('Workspace layout access guard', () => { params: Promise.resolve({ locale: 'en', workspaceId: 'ws-1' }), }) - expect(renderToStaticMarkup(result)).toContain('data-workspace-id="ws-1"') - expect(renderToStaticMarkup(result)).toContain('workspace') + const markup = renderToStaticMarkup(result) + + expect(markup).toContain('workspace') + expect(markup).toContain('data-workspace-id="ws-1"') + expect(markup).toContain('data-user-id="user-1"') expect(mockRedirect).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/layout.tsx b/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/layout.tsx index 0a330e308..f7832c191 100644 --- a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/layout.tsx +++ b/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/layout.tsx @@ -39,7 +39,7 @@ export default async function WorkspaceLayout({ } return ( - +
{children}
diff --git a/apps/tradinggoose/app/[locale]/workspace/layout.tsx b/apps/tradinggoose/app/[locale]/workspace/layout.tsx index 39a0817b7..589a72de0 100644 --- a/apps/tradinggoose/app/[locale]/workspace/layout.tsx +++ b/apps/tradinggoose/app/[locale]/workspace/layout.tsx @@ -19,7 +19,19 @@ export default async function WorkspaceRootLayout({ return ( - {children} + + {children} + ) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/providers/providers.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/providers/providers.tsx index 6ea02c2ad..61da69244 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/providers/providers.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/providers/providers.tsx @@ -4,15 +4,19 @@ import React from 'react' import { TooltipProvider } from '@/components/ui/tooltip' import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -interface ProvidersProps { +type ProvidersProps = { children: React.ReactNode - workspaceId?: string -} + workspaceId: string +} & ({ userId: string; inheritUser?: never } | { inheritUser: true; userId?: never }) + +const Providers = React.memo((props) => { + const { children, workspaceId } = props + const workspaceIdentityProps = + props.inheritUser === true ? { inheritUser: true as const } : { userId: props.userId } -const Providers = React.memo(({ children, workspaceId }) => { return ( - + {children} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/providers/workspace-permissions-provider.test.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/providers/workspace-permissions-provider.test.tsx index 4c1d12c8c..cbdc7a695 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/providers/workspace-permissions-provider.test.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/providers/workspace-permissions-provider.test.tsx @@ -112,7 +112,7 @@ describe('WorkspacePermissionsProvider', () => { await act(async () => { root?.render( - +
workspace
) @@ -121,4 +121,113 @@ describe('WorkspacePermissionsProvider', () => { expect(mockReplace).toHaveBeenCalledWith('/workspace') expect(container?.textContent).toBe('') }) + + it('blocks rendering during auth recovery without replacing the auth redirect', async () => { + mockUseWorkspacePermissions.mockReturnValue({ + permissions: null, + loading: false, + error: 'SESSION_EXPIRED', + updatePermissions: mockUpdatePermissions, + refetch: mockRefetchPermissions, + }) + + mockUseUserPermissions.mockReturnValue({ + canRead: false, + canEdit: false, + canAdmin: false, + userPermissions: 'read', + isLoading: false, + error: 'SESSION_EXPIRED', + }) + + const { WorkspacePermissionsProvider } = await import('./workspace-permissions-provider') + + await act(async () => { + root?.render( + +
workspace
+
+ ) + }) + + expect(mockReplace).not.toHaveBeenCalled() + expect(container?.textContent).toBe('') + }) + + it('inherits the server-authenticated user id for nested workspace providers', async () => { + const { WorkspacePermissionsProvider } = await import('./workspace-permissions-provider') + + await act(async () => { + root?.render( + + +
workspace
+
+
+ ) + }) + + expect(mockUseWorkspacePermissions).toHaveBeenCalledWith('ws-1', 'user-1') + expect(mockUseWorkspacePermissions).toHaveBeenCalledWith('ws-2', 'user-1') + expect(container?.textContent).toBe('workspace') + }) + + it('unblocks children when the authenticated user changes on the same workspace', async () => { + mockUseWorkspacePermissions.mockReturnValue({ + permissions: null, + loading: false, + error: 'Workspace not found or access denied', + updatePermissions: mockUpdatePermissions, + refetch: mockRefetchPermissions, + }) + mockUseUserPermissions.mockReturnValue({ + canRead: false, + canEdit: false, + canAdmin: false, + userPermissions: 'read', + isLoading: false, + error: 'Workspace not found or access denied', + }) + + const { WorkspacePermissionsProvider } = await import('./workspace-permissions-provider') + + await act(async () => { + root?.render( + +
workspace
+
+ ) + }) + + expect(container?.textContent).toBe('') + + mockUseWorkspacePermissions.mockReturnValue({ + permissions: { + users: [], + total: 0, + currentUserPermission: 'admin', + }, + loading: false, + error: null, + updatePermissions: mockUpdatePermissions, + refetch: mockRefetchPermissions, + }) + mockUseUserPermissions.mockReturnValue({ + canRead: true, + canEdit: true, + canAdmin: true, + userPermissions: 'admin', + isLoading: false, + error: null, + }) + await act(async () => { + root?.render( + +
workspace
+
+ ) + }) + + expect(container?.textContent).toBe('workspace') + }) }) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx index c8b622398..01576de6f 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx @@ -2,8 +2,8 @@ import type React from 'react' import { createContext, useContext, useEffect, useMemo, useState } from 'react' -import { useParams } from 'next/navigation' import { createLogger } from '@/lib/logs/console/logger' +import { isSessionRecoveryAuthError } from '@/lib/auth/auth-error-copy' import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions' import { useWorkspacePermissions, @@ -24,42 +24,47 @@ interface WorkspacePermissionsContextType { setOfflineMode: (isOffline: boolean) => void } -const WorkspacePermissionsContext = createContext({ - workspacePermissions: null, - permissionsLoading: false, - permissionsError: null, - updatePermissions: () => {}, - refetchPermissions: async () => {}, - userPermissions: { - canRead: false, - canEdit: false, - canAdmin: false, - userPermissions: 'read', - isLoading: false, - error: null, - }, - setOfflineMode: () => {}, -}) - -interface WorkspacePermissionsProviderProps { +const WorkspaceAuthenticatedUserContext = createContext(null) +const WorkspacePermissionsContext = createContext(null) + +type WorkspacePermissionsProviderProps = { children: React.ReactNode - workspaceId?: string + workspaceId: string +} & ({ userId: string; inheritUser?: never } | { inheritUser: true; userId?: never }) + +export function WorkspacePermissionsProvider(props: WorkspacePermissionsProviderProps) { + const { children, workspaceId } = props + const inheritedUserId = useContext(WorkspaceAuthenticatedUserContext) + const workspaceUserId = props.userId ?? inheritedUserId + + if (!workspaceUserId) { + throw new Error( + 'WorkspacePermissionsProvider requires userId or inheritUser inside an existing WorkspacePermissionsProvider' + ) + } + + return ( + + {children} + + ) } -export function WorkspacePermissionsProvider({ +function WorkspacePermissionsProviderInner({ children, - workspaceId: workspaceIdProp, -}: WorkspacePermissionsProviderProps) { - const params = useParams() + workspaceId, + userId, +}: { + children: React.ReactNode + workspaceId: string + userId: string +}) { const router = useRouter() - const workspaceId = workspaceIdProp ?? (params?.workspaceId as string | undefined) ?? null const [isOfflineMode, setIsOfflineMode] = useState(false) - const [hasRedirected, setHasRedirected] = useState(false) - - useEffect(() => { - setHasRedirected(false) - }, [workspaceId]) + const [redirectedAccessKey, setRedirectedAccessKey] = useState(null) + const accessKey = `${userId}:${workspaceId}` + const hasRedirected = redirectedAccessKey === accessKey const { permissions: workspacePermissions, @@ -67,7 +72,7 @@ export function WorkspacePermissionsProvider({ error: permissionsError, updatePermissions, refetch: refetchPermissions, - } = useWorkspacePermissions(workspaceId) + } = useWorkspacePermissions(workspaceId, userId) const baseUserPermissions = useUserPermissions( workspacePermissions, @@ -113,12 +118,14 @@ export function WorkspacePermissionsProvider({ ) const combinedError = userPermissions.error || permissionsError + const isAuthRecoveryError = isSessionRecoveryAuthError(permissionsError) const normalizedError = combinedError?.toLowerCase() ?? '' const isAccessDeniedError = normalizedError ? ACCESS_DENIED_PATTERNS.some((pattern) => normalizedError.includes(pattern)) : false const shouldTriggerRedirect = Boolean( workspaceId && + !isAuthRecoveryError && !permissionsLoading && !userPermissions.isLoading && (isAccessDeniedError || !userPermissions.canRead) @@ -129,20 +136,22 @@ export function WorkspacePermissionsProvider({ return } - setHasRedirected(true) + setRedirectedAccessKey(accessKey) logger.warn('Redirecting user without workspace access', { workspaceId, error: combinedError ?? 'missing read permissions', }) router.replace('/workspace') - }, [combinedError, hasRedirected, router, shouldTriggerRedirect, workspaceId]) + }, [accessKey, combinedError, hasRedirected, router, shouldTriggerRedirect, workspaceId]) - const shouldBlockRender = hasRedirected || shouldTriggerRedirect + const shouldBlockRender = isAuthRecoveryError || hasRedirected || shouldTriggerRedirect return ( - - {shouldBlockRender ? null : children} - + + + {shouldBlockRender ? null : children} + + ) } diff --git a/apps/tradinggoose/global-navbar/components/workspace-dialogs.tsx b/apps/tradinggoose/global-navbar/components/workspace-dialogs.tsx index 8145259b3..df543a542 100644 --- a/apps/tradinggoose/global-navbar/components/workspace-dialogs.tsx +++ b/apps/tradinggoose/global-navbar/components/workspace-dialogs.tsx @@ -18,7 +18,6 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { useSession } from '@/lib/auth-client' import { quickValidateEmail } from '@/lib/email/validation' import { createLogger } from '@/lib/logs/console/logger' import type { PermissionType } from '@/lib/permissions/utils' @@ -39,6 +38,8 @@ const logger = createLogger('WorkspaceInviteModal') interface WorkspaceInviteModalProps { open: boolean onOpenChange: (open: boolean) => void + currentUserId: string + currentUserEmail: string | null workspaceName?: string workspaceId?: string workspaceOwnerId?: string @@ -62,6 +63,8 @@ interface UserPermissions { } interface PermissionsTableProps { + currentUserId: string + currentUserEmail: string | null userPermissions: UserPermissions[] onPermissionChange: (userId: string, permissionType: PermissionType) => void onRemoveMember?: (userId: string, email: string) => void @@ -182,6 +185,8 @@ const PermissionsTableSkeleton = React.memo(() => ( PermissionsTableSkeleton.displayName = 'PermissionsTableSkeleton' const PermissionsTable = ({ + currentUserId, + currentUserEmail, userPermissions, onPermissionChange, onRemoveMember, @@ -199,7 +204,6 @@ const PermissionsTable = ({ resentInvitationIds, resendCooldowns, }: PermissionsTableProps) => { - const { data: session } = useSession() const userPerms = useUserPermissionsContext() const existingUsers: UserPermissions[] = useMemo( @@ -213,23 +217,29 @@ const PermissionsTable = ({ email: user.email, permissionType: changes.permissionType !== undefined ? changes.permissionType : permissionType, - isCurrentUser: user.email === session?.user?.email, + isCurrentUser: user.userId === currentUserId, } }) || [], - [workspacePermissions?.users, existingUserPermissionChanges, session?.user?.email] + [workspacePermissions?.users, existingUserPermissionChanges, currentUserId] ) - const currentUser: UserPermissions | null = useMemo( - () => - session?.user?.email - ? existingUsers.find((user) => user.isCurrentUser) || { - email: session.user.email, - permissionType: 'admin', - isCurrentUser: true, - } - : null, - [session?.user?.email, existingUsers] - ) + const currentUser: UserPermissions | null = useMemo(() => { + const existingCurrentUser = existingUsers.find((user) => user.isCurrentUser) + if (existingCurrentUser) { + return existingCurrentUser + } + + if (!currentUserEmail || !workspacePermissions?.currentUserPermission) { + return null + } + + return { + userId: currentUserId, + email: currentUserEmail, + permissionType: workspacePermissions.currentUserPermission, + isCurrentUser: true, + } + }, [currentUserEmail, currentUserId, existingUsers, workspacePermissions?.currentUserPermission]) const filteredExistingUsers = useMemo( () => existingUsers.filter((user) => !user.isCurrentUser), @@ -258,8 +268,7 @@ const PermissionsTable = ({ return } - if (userPermissions.length === 0 && !session?.user?.email && !workspacePermissions?.users?.length) - return null + if (allUsers.length === 0) return null if (isSaving) { return ( @@ -447,6 +456,8 @@ const PermissionsTable = ({ export function WorkspaceInviteModal({ open, onOpenChange, + currentUserId, + currentUserEmail, workspaceName, workspaceId, workspaceOwnerId, @@ -483,7 +494,6 @@ export function WorkspaceInviteModal({ const resolvedWorkspaceId = workspaceId ?? optionalRoute?.workspaceId ?? (params?.workspaceId as string | undefined) ?? null - const { data: session } = useSession() const { workspacePermissions, permissionsLoading, @@ -491,6 +501,13 @@ export function WorkspaceInviteModal({ refetchPermissions, userPermissions: userPerms, } = useWorkspacePermissionsContext() + const currentUserEmailFromPermissions = workspacePermissions?.users?.find( + (user) => user.userId === currentUserId + )?.email + const normalizedCurrentUserEmail = + currentUserEmailFromPermissions?.trim().toLowerCase() ?? + currentUserEmail?.trim().toLowerCase() ?? + null const hasPendingChanges = Object.keys(existingUserPermissionChanges).length > 0 const hasNewInvites = emails.length > 0 || inputValue.trim() @@ -567,7 +584,7 @@ export function WorkspaceInviteModal({ return false } - if (session?.user?.email && session.user.email.toLowerCase() === normalized) { + if (normalizedCurrentUserEmail === normalized) { setErrorMessage('You cannot invite yourself') setInputValue('') return false @@ -593,7 +610,13 @@ export function WorkspaceInviteModal({ setInputValue('') return true }, - [emails, invalidEmails, pendingInvitations, workspacePermissions?.users, session?.user?.email] + [ + emails, + invalidEmails, + pendingInvitations, + workspacePermissions?.users, + normalizedCurrentUserEmail, + ] ) const removeEmail = useCallback( @@ -1112,6 +1135,8 @@ export function WorkspaceInviteModal({
void inviteWorkspace: Workspace | null @@ -1274,6 +1300,7 @@ interface WorkspaceDialogsProps { } export function WorkspaceDialogs({ + workspaceUser, inviteDialogOpen, onInviteDialogChange, inviteWorkspace, @@ -1284,13 +1311,17 @@ export function WorkspaceDialogs({ isDeletingWorkspace, onConfirmDelete, }: WorkspaceDialogsProps) { + const inviteIdentityMissing = Boolean(inviteWorkspace && !workspaceUser) + return ( <> - {inviteWorkspace ? ( - + {inviteWorkspace && workspaceUser ? ( + ) : null} + {inviteIdentityMissing ? ( + + + + Workspace session unavailable + + Workspace management requires an authenticated workspace session. + + + + onInviteDialogChange(false)}> + Close + + + + + ) : null} + diff --git a/apps/tradinggoose/global-navbar/global-navbar.tsx b/apps/tradinggoose/global-navbar/global-navbar.tsx index 489c4d506..ed0d3103f 100644 --- a/apps/tradinggoose/global-navbar/global-navbar.tsx +++ b/apps/tradinggoose/global-navbar/global-navbar.tsx @@ -40,10 +40,12 @@ import { export function GlobalNavbar({ children, isSystemAdmin = false, + workspaceUser = null, navigationMode = 'workspace', }: { children: React.ReactNode isSystemAdmin?: boolean + workspaceUser?: { id: string; email: string | null } | null navigationMode?: 'workspace' | 'admin' }) { const selectedSegments = useSelectedLayoutSegments() @@ -313,6 +315,7 @@ export function GlobalNavbar({ {canManageWorkspaces ? ( vi.fn()) -const mockUseSession = vi.hoisted(() => vi.fn()) let latestValue: ReturnType | null = null let workspaceId = 'workspace-401' +let userId = 'user-1' vi.mock('@/lib/auth/auth-error-handler', () => ({ handleAuthError: mockHandleAuthError, isAuthErrorStatus: (status?: number | null) => status === 401, })) -vi.mock('@/lib/auth-client', () => ({ - useSession: mockUseSession, -})) - vi.mock('@/i18n/navigation', () => ({ usePathname: () => '/workspace/workspace-1/dashboard', })) function WorkspacePermissionsProbe() { - latestValue = useWorkspacePermissions(workspaceId) + latestValue = useWorkspacePermissions(workspaceId, userId) return null } @@ -44,17 +40,8 @@ describe('useWorkspacePermissions', () => { reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true latestValue = null workspaceId = 'workspace-401' + userId = 'user-1' mockHandleAuthError.mockResolvedValue(undefined) - mockUseSession.mockReturnValue({ - data: { - user: { - id: 'user-1', - }, - }, - isPending: false, - error: null, - refetch: vi.fn(), - }) vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401, statusText: 'Unauthorized' })) @@ -86,36 +73,69 @@ describe('useWorkspacePermissions', () => { '/workspace/workspace-1/dashboard' ) expect(latestValue).toMatchObject({ - loading: true, - error: null, + loading: false, + error: 'SESSION_EXPIRED', permissions: null, }) }) - it('routes resolved missing sessions through auth recovery without completing permission load', async () => { - const fetchMock = vi.fn() - vi.stubGlobal('fetch', fetchMock) - mockUseSession.mockReturnValue({ - data: null, - isPending: false, - error: null, - refetch: vi.fn(), + it('does not refetch a session recovery record after remounting the same user workspace key', async () => { + const fetchMock = fetch as unknown as ReturnType + + await act(async () => { + root.render() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(latestValue).toMatchObject({ + loading: false, + error: 'SESSION_EXPIRED', + permissions: null, }) + await act(async () => { + root.unmount() + }) + root = createRoot(container) + await act(async () => { root.render() await new Promise((resolve) => setTimeout(resolve, 0)) }) - expect(fetchMock).not.toHaveBeenCalled() - expect(mockHandleAuthError).toHaveBeenCalledWith( - 'workspace-permissions', - '/workspace/workspace-1/dashboard' + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(mockHandleAuthError).toHaveBeenCalledTimes(1) + expect(latestValue).toMatchObject({ + loading: false, + error: 'SESSION_EXPIRED', + permissions: null, + }) + }) + + it('uses the server-authenticated user id instead of client session state', async () => { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + users: [], + total: 0, + currentUserPermission: 'admin', + }) ) + vi.stubGlobal('fetch', fetchMock) + + await act(async () => { + root.render() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(fetchMock).toHaveBeenCalledWith('/api/workspaces/workspace-401/permissions') + expect(mockHandleAuthError).not.toHaveBeenCalled() expect(latestValue).toMatchObject({ - loading: true, + loading: false, error: null, - permissions: null, + permissions: { + currentUserPermission: 'admin', + }, }) }) @@ -147,16 +167,7 @@ describe('useWorkspacePermissions', () => { expect(fetchMock).toHaveBeenCalledTimes(1) expect(latestValue?.permissions?.currentUserPermission).toBe('admin') - mockUseSession.mockReturnValue({ - data: { - user: { - id: 'user-2', - }, - }, - isPending: false, - error: null, - refetch: vi.fn(), - }) + userId = 'user-2' await act(async () => { root.render() diff --git a/apps/tradinggoose/hooks/use-workspace-permissions.ts b/apps/tradinggoose/hooks/use-workspace-permissions.ts index e63f6a10b..06a95dc8f 100644 --- a/apps/tradinggoose/hooks/use-workspace-permissions.ts +++ b/apps/tradinggoose/hooks/use-workspace-permissions.ts @@ -4,7 +4,7 @@ import { useCallback, useEffect } from 'react' import type { permissionTypeEnum } from '@tradinggoose/db/schema' import { createWithEqualityFn as create } from 'zustand/traditional' import { handleAuthError, isAuthErrorStatus } from '@/lib/auth/auth-error-handler' -import { useSession } from '@/lib/auth-client' +import { isSessionRecoveryAuthError } from '@/lib/auth/auth-error-copy' import { createLogger } from '@/lib/logs/console/logger' import { usePathname } from '@/i18n/navigation' import { API_ENDPOINTS } from '@/stores/constants' @@ -51,7 +51,6 @@ interface WorkspacePermissionsStoreState { records: Record inFlight: Partial>> setRecord: (recordKey: string, partial: Partial) => void - clearRecord: (recordKey: string) => void fetchPermissions: ( recordKey: string, workspaceId: string, @@ -81,15 +80,9 @@ const useWorkspacePermissionsStore = create((set }, } }), - clearRecord: (recordKey) => - set((state) => { - const records = { ...state.records } - delete records[recordKey] - return { records } - }), fetchPermissions: async (recordKey, workspaceId, options) => { const { callbackPathname, force = false } = options - const { records, inFlight, setRecord, clearRecord } = get() + const { records, inFlight, setRecord } = get() if (!force) { if (inFlight[recordKey]) { @@ -97,6 +90,9 @@ const useWorkspacePermissionsStore = create((set } const existing = records[recordKey] + if (isSessionRecoveryAuthError(existing?.error)) { + return + } if (existing?.permissions && !existing?.error) { return } @@ -114,7 +110,11 @@ const useWorkspacePermissionsStore = create((set } if (isAuthErrorStatus(response.status)) { await handleAuthError('workspace-permissions', callbackPathname) - clearRecord(recordKey) + setRecord(recordKey, { + permissions: null, + loading: false, + error: 'SESSION_EXPIRED', + }) return } throw new Error(`Failed to fetch permissions: ${response.statusText}`) @@ -171,47 +171,28 @@ export function resetWorkspacePermissionsStore() { useWorkspacePermissionsStore.setState({ records: {}, inFlight: {} }) } -export function useWorkspacePermissions(workspaceId: string | null): UseWorkspacePermissionsReturn { +export function useWorkspacePermissions( + workspaceId: string, + userId: string +): UseWorkspacePermissionsReturn { const callbackPathname = usePathname() - const session = useSession() - const userId = session.data?.user?.id ?? null - const recordKey = workspaceId && userId ? getRecordKey(workspaceId, userId) : null - const record = useWorkspacePermissionsStore((state) => - recordKey ? state.records[recordKey] : undefined - ) + const recordKey = getRecordKey(workspaceId, userId) + const record = useWorkspacePermissionsStore((state) => state.records[recordKey]) const fetchPermissions = useWorkspacePermissionsStore((state) => state.fetchPermissions) const setRecord = useWorkspacePermissionsStore((state) => state.setRecord) useEffect(() => { - if (!workspaceId || session.isPending) { - return () => {} - } - - if (!userId) { - handleAuthError('workspace-permissions', callbackPathname).catch((error) => - logger.error('Failed to route missing workspace session through auth recovery', { - workspaceId, - error, - }) - ) - return () => {} - } - - fetchPermissions(getRecordKey(workspaceId, userId), workspaceId, { callbackPathname }).catch( - (error) => { - logger.error('Failed to load workspace permissions', { workspaceId, error }) - } - ) - }, [workspaceId, session.isPending, userId, callbackPathname, fetchPermissions]) + fetchPermissions(recordKey, workspaceId, { callbackPathname }).catch((error) => { + logger.error('Failed to load workspace permissions', { workspaceId, error }) + }) + }, [workspaceId, recordKey, callbackPathname, fetchPermissions]) const refetch = useCallback(async () => { - if (!workspaceId || !recordKey) return await fetchPermissions(recordKey, workspaceId, { callbackPathname, force: true }) }, [workspaceId, recordKey, callbackPathname, fetchPermissions]) const updatePermissions = useCallback( (newPermissions: WorkspacePermissions) => { - if (!recordKey) return setRecord(recordKey, { permissions: newPermissions, loading: false, @@ -221,12 +202,9 @@ export function useWorkspacePermissions(workspaceId: string | null): UseWorkspac [recordKey, setRecord] ) - const isInitialLoad = - Boolean(workspaceId) && (session.isPending || !userId || Boolean(recordKey && !record)) - return { permissions: record?.permissions ?? null, - loading: record?.loading ?? isInitialLoad, + loading: record?.loading ?? true, error: record?.error ?? null, updatePermissions, refetch, diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-app.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-app.tsx index 10d1fdc63..b6bd45f6b 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-app.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-app.tsx @@ -81,7 +81,7 @@ const CopilotApp = ({ : undefined return ( - + - + + - + { dispatchToolbarAddBlock(request, toolbarScopeId) diff --git a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx index f0870cb08..3e91b0cc9 100644 --- a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx @@ -312,7 +312,7 @@ const ListCustomToolHeaderRight = ({ } return ( - +
{ } return ( - + ) diff --git a/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx b/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx index 066b988bc..e14d9fcec 100644 --- a/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx @@ -163,7 +163,7 @@ const ListIndicatorHeaderRight = ({ } return ( - +
{ } return ( - + ) diff --git a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx index 6cf47b3b2..24c88f50c 100644 --- a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx @@ -190,7 +190,7 @@ const ListMcpHeaderRight = ({ } return ( - +
+ ) diff --git a/apps/tradinggoose/widgets/widgets/list_skill/index.tsx b/apps/tradinggoose/widgets/widgets/list_skill/index.tsx index 29d62d153..39ceccd8f 100644 --- a/apps/tradinggoose/widgets/widgets/list_skill/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_skill/index.tsx @@ -165,7 +165,7 @@ const ListSkillHeaderRight = ({ } return ( - +
@@ -182,7 +182,7 @@ const ListSkillWidgetBody = (props: WidgetComponentProps) => { } return ( - + ) diff --git a/apps/tradinggoose/widgets/widgets/list_workflow/index.tsx b/apps/tradinggoose/widgets/widgets/list_workflow/index.tsx index 283a4dae0..2a3f8f8d8 100644 --- a/apps/tradinggoose/widgets/widgets/list_workflow/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_workflow/index.tsx @@ -282,7 +282,7 @@ const WorkflowListWidgetBody = ({ } return ( - + { } return ( - +
+ + + Date: Fri, 19 Jun 2026 12:09:05 -0600 Subject: [PATCH 13/18] fix(socket): log socket auth failures without redirecting (#153) * fix(auth): pass server user into workspace socket provider Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(socket): improve logging for socket authentication errors * fix(workspace): simplify user prop handling in WorkspaceLayoutClient * test(auth): adjust auth error handler workspace permission case --- apps/tradinggoose/contexts/socket-context.tsx | 4 +--- apps/tradinggoose/lib/auth/auth-error-handler.test.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/tradinggoose/contexts/socket-context.tsx b/apps/tradinggoose/contexts/socket-context.tsx index fa8f7eddf..ecd5ea07a 100644 --- a/apps/tradinggoose/contexts/socket-context.tsx +++ b/apps/tradinggoose/contexts/socket-context.tsx @@ -2,7 +2,6 @@ import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from 'react' import { io, type Socket } from 'socket.io-client' -import { handleAuthError } from '@/lib/auth/auth-error-handler' import { getEnv } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { usePathname } from '@/i18n/navigation' @@ -22,8 +21,7 @@ const logSocketIssue = ( callbackPathname: string ) => { if (isSocketAuthError(details.message)) { - logger.warn(event, details) - void handleAuthError('socket-auth', callbackPathname) + logger.warn(event, { ...details, callbackPathname }) } else { logger.error(event, details) } diff --git a/apps/tradinggoose/lib/auth/auth-error-handler.test.ts b/apps/tradinggoose/lib/auth/auth-error-handler.test.ts index e83503798..0c96bcbbb 100644 --- a/apps/tradinggoose/lib/auth/auth-error-handler.test.ts +++ b/apps/tradinggoose/lib/auth/auth-error-handler.test.ts @@ -32,7 +32,7 @@ describe('handleAuthError', () => { const { handleAuthError } = await import('./auth-error-handler') - await handleAuthError('socket-auth', '/login') + await handleAuthError('workspace-permissions', '/login') expect(replaceMock).toHaveBeenCalledWith('/login?callbackUrl=%2Fworkspace&reauth=1#credentials') }) From 61dc75e4449ae1b9f7a9aaa55cc25bf0c46cfb71 Mon Sep 17 00:00:00 2001 From: Alfredo Date: Tue, 23 Jun 2026 11:45:20 -0500 Subject: [PATCH 14/18] feat(copilot): localize mention labels and mention search (#155) * feat(copilot): localize mention labels and mention search Centralize copilot mention copy and switch mention options and submenus to stable internal ids so the mention menu can render localized labels, empty states, and fallback names across chats, workspace entities, knowledge bases, docs, and logs. Localize block and workflow block names, use localized log trigger labels in search, and normalize queries for accent-insensitive matching while reloading mention sources when the locale changes. Add en, es, and zh mention message keys plus focused tests for mention menu rendering, filtering, source reloading, and keyboard mention selection flows. * fix(copilot): stabilize mention submenu reload deps and add staging changelog Capture ensureSubmenuLoaded from the loaders object and reuse that stable callback throughout use-user-input-mentions. This narrows the locale submenu reload effect dependency to the actual loader callback instead of the inline loaders object identity. Add changelog/June-22-2026.md with the required staging entry for feat/copilot_mentions_translations, documenting the localized mention copy, normalized filtering, locale-aware source reloads, and reuse guardrails for future branches. * chore: restore recovered local changes Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(custom-tools): treat titles as canonical tool names Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(executor): resolve dynamic MCP tool ids at runtime Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(agent): require exact skill ids for skill loading Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * refactor(copilot): reuse centralized mention empty states Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * test(agent): remove stale MCP agent handler coverage Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * test(custom-tools): reject function names in imported schemas Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(function): allow workspace-scoped function execution Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(copilot): resolve custom indicators by runtime id Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * test(copilot): align custom tool schema fixture Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(custom-tools): separate runtime ids from entity ids Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(copilot): keep deleted mentions removed Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(copilot): normalize final streamed messages Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(copilot): filter markdown renderer node props Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(agent): preserve custom tool display names Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(copilot): dedupe docs context mentions Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(function-execution): authorize requests by workflow workspace Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(mcp): persist canonical tool identifiers Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * refactor(mcp): use hyphenated dynamic tool ids Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * test(generic-handler): remove stale mcp id resolution coverage Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(mcp): use explicit server and tool params for execution Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(copilot): treat punctuation as mention boundaries Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(mcp): preserve selected tool IDs for execution Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(copilot): retain contexts for repeated mentions Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * style(custom-tools): format entity state import Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(custom-tools): allow updates with existing titles Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(copilot): reuse mention boundary detection Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(copilot): refresh selected mention contexts Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * feat(function): support workspace-scoped execution Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(custom-tools): normalize function schemas Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * style(tools): sort utility imports Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(custom-tools): normalize API custom tool titles Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(copilot): include log triggers in mention search Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * style: normalize formatting in agent and indicator files Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * style(indicators): format runtime imports Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * refactor(indicators): remove runtime indicator names Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(agent): preserve custom tool ids with display names Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * feat(copilot): localize implicit context labels Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(copilot): preserve duplicate mention insertion order Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * test(custom-tools): align export schema fixture with canonical shape Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(trace-spans): type tool call ids in trace output Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(workflows): reject invalid custom tool runtime ids Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * fix(function-execute): require write access for workspace execution Co-authored-by: Codex Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup * docs(changelog): add June 23 changelog Co-authored-by: BWJ2310 Co-authored-by: BWJ2310-backup --------- Co-authored-by: BWJ2310-backup Co-authored-by: Codex Co-authored-by: BWJ2310 --- .../app/api/function/e2b-execution.ts | 14 +- .../app/api/function/execute/route.test.ts | 88 +++- .../app/api/function/execute/route.ts | 33 +- .../app/api/function/local-execution.ts | 8 +- .../app/api/indicators/options/route.ts | 3 +- apps/tradinggoose/blocks/blocks/agent.test.ts | 6 +- apps/tradinggoose/blocks/blocks/agent.ts | 17 +- apps/tradinggoose/blocks/blocks/function.ts | 8 +- apps/tradinggoose/contexts/socket-context.tsx | 2 + .../handlers/agent/agent-handler.test.ts | 385 ++--------------- .../executor/handlers/agent/agent-handler.ts | 72 ++-- .../executor/handlers/agent/skill-loader.ts | 19 +- .../handlers/agent/skills-resolver.ts | 12 +- .../executor/handlers/agent/types.ts | 1 + .../hooks/queries/custom-tools.ts | 13 +- apps/tradinggoose/i18n/messages/en.json | 42 +- apps/tradinggoose/i18n/messages/es.json | 14 +- apps/tradinggoose/i18n/messages/zh.json | 12 +- .../i18n/workspace-widget-hooks.ts | 7 +- .../lib/auth/auth-error-handler.test.ts | 2 +- .../tradinggoose/lib/copilot/chat-contexts.ts | 12 +- .../lib/copilot/entity-documents.ts | 2 +- apps/tradinggoose/lib/copilot/registry.ts | 5 +- .../lib/copilot/runtime-tool-manifest.test.ts | 4 +- .../lib/copilot/tool-prompt-metadata.ts | 8 +- .../entities/entity-document-tool-utils.ts | 31 +- .../client/entities/entity-tools.test.ts | 61 ++- .../agent/get-agent-accessory-catalog.ts | 3 +- .../server/blocks/block-mermaid-catalog.ts | 5 +- .../server/blocks/get-blocks-metadata.test.ts | 3 +- .../lib/custom-tools/import-export.test.ts | 229 +++++------ .../lib/custom-tools/import-export.ts | 61 +-- .../lib/custom-tools/operations.ts | 41 +- apps/tradinggoose/lib/custom-tools/schema.ts | 52 ++- apps/tradinggoose/lib/function/execution.ts | 17 +- .../lib/indicators/custom/operations.ts | 20 +- .../lib/indicators/default/runtime.ts | 16 - .../execution/e2b-script-builder.ts | 12 +- .../execution/function-indicator-runtime.ts | 29 +- .../execution/trace-spans/trace-spans.test.ts | 32 +- .../logs/execution/trace-spans/trace-spans.ts | 14 +- apps/tradinggoose/lib/logs/types.ts | 1 + .../lib/workflows/custom-tools-persistence.ts | 31 +- apps/tradinggoose/lib/workflows/utils.ts | 4 - .../lib/workflows/validation.test.ts | 30 ++ apps/tradinggoose/lib/workflows/validation.ts | 14 +- apps/tradinggoose/providers/ai/types.ts | 1 + apps/tradinggoose/providers/ai/utils.test.ts | 24 +- apps/tradinggoose/providers/ai/utils.ts | 17 +- apps/tradinggoose/stores/copilot/store.ts | 9 +- .../tradinggoose/stores/custom-tools/types.ts | 1 - .../tools/function/execute.test.ts | 5 - apps/tradinggoose/tools/function/execute.ts | 14 +- apps/tradinggoose/tools/index.test.ts | 9 +- apps/tradinggoose/tools/index.ts | 22 +- apps/tradinggoose/tools/utils.test.ts | 3 +- apps/tradinggoose/tools/utils.ts | 30 +- .../components/custom-tool-list-item.tsx | 18 +- .../components/custom-tool-dropdown.tsx | 11 +- .../components/markdown-renderer.tsx | 14 +- .../components/copilot/copilot.test.tsx | 4 + .../copilot/components/copilot/copilot.tsx | 11 +- .../user-input/components/mention-menu.tsx | 195 ++++----- .../components/user-input/constants.ts | 14 +- .../hooks/use-user-input-mention-sources.ts | 102 +++-- .../hooks/use-user-input-mentions.ts | 389 ++++++++---------- .../components/user-input/mention-copy.ts | 141 +++++++ .../user-input/mention-editor-dom.test.ts | 1 + .../user-input/mention-utils.test.ts | 172 +++++--- .../components/user-input/mention-utils.ts | 237 ++++++++--- .../copilot/components/user-input/types.ts | 23 +- .../user-input/workspace-entity-mentions.ts | 94 ++--- .../widgets/copilot/live-contexts.test.ts | 17 +- .../widgets/widgets/copilot/live-contexts.ts | 4 + .../copilot/workspace-entities.test.ts | 1 + .../widgets/copilot/workspace-entities.ts | 62 +-- .../custom-tool-editor.test.tsx | 4 - .../editor_custom_tool/custom-tool-editor.tsx | 112 ++--- .../components/tool-input/tool-input.tsx | 3 +- .../widgets/list_custom_tool/index.test.tsx | 2 - .../widgets/list_custom_tool/index.tsx | 15 +- changelog/June-23-2026.md | 85 ++++ 82 files changed, 1719 insertions(+), 1642 deletions(-) create mode 100644 apps/tradinggoose/lib/workflows/validation.test.ts create mode 100644 apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-copy.ts create mode 100644 changelog/June-23-2026.md diff --git a/apps/tradinggoose/app/api/function/e2b-execution.ts b/apps/tradinggoose/app/api/function/e2b-execution.ts index 038954502..91f3f9f9c 100644 --- a/apps/tradinggoose/app/api/function/e2b-execution.ts +++ b/apps/tradinggoose/app/api/function/e2b-execution.ts @@ -1,9 +1,11 @@ import { executeInE2B, isE2BWarmSandboxLimitError } from '@/lib/execution/e2b' import { CodeLanguage } from '@/lib/execution/languages' import { resolveExecutionRuntimeConfig } from '@/lib/execution/runtime-config' -import { DEFAULT_INDICATOR_RUNTIME_MANIFEST } from '@/lib/indicators/default/runtime' import { buildPineTSFunctionIndicatorRuntimePrologue } from '@/lib/indicators/execution/e2b-script-builder' -import { FUNCTION_INDICATOR_USAGE_HINT } from '@/lib/indicators/execution/function-indicator-runtime' +import { + type FunctionIndicatorRuntimeManifest, + FUNCTION_INDICATOR_USAGE_HINT, +} from '@/lib/indicators/execution/function-indicator-runtime' import { formatE2BError } from './error-formatting' import { executeFunctionInLocalVm } from './local-execution' import { extractJavaScriptImports } from './typescript-utils' @@ -16,6 +18,7 @@ type ExecuteFunctionInE2BArgs = { executionParams: Record envVars: Record contextVariables: Record + indicatorRuntimeManifest: FunctionIndicatorRuntimeManifest isCustomTool: boolean timeout: number e2bTemplate?: string @@ -31,6 +34,7 @@ export const executeFunctionInE2B = async ({ executionParams, envVars, contextVariables, + indicatorRuntimeManifest, isCustomTool, timeout, e2bTemplate, @@ -70,7 +74,7 @@ export const executeFunctionInE2B = async ({ } const indicatorRuntimePrologue = buildPineTSFunctionIndicatorRuntimePrologue({ - manifest: DEFAULT_INDICATOR_RUNTIME_MANIFEST, + manifest: indicatorRuntimeManifest, usageHint: FUNCTION_INDICATOR_USAGE_HINT, }) prologue += `${indicatorRuntimePrologue}\n` @@ -145,6 +149,7 @@ type ExecuteFunctionWithRuntimeGateArgs = { executionParams: Record envVars: Record contextVariables: Record + indicatorRuntimeManifest: FunctionIndicatorRuntimeManifest timeout: number isCustomTool: boolean e2bUserScope?: string @@ -199,6 +204,7 @@ export const executeFunctionWithRuntimeGate = async ({ executionParams, envVars, contextVariables, + indicatorRuntimeManifest, timeout, isCustomTool, e2bUserScope, @@ -219,6 +225,7 @@ export const executeFunctionWithRuntimeGate = async ({ executionParams, envVars, contextVariables, + indicatorRuntimeManifest, isCustomTool, timeout, e2bTemplate: runtimeConfig.e2bTemplate ?? undefined, @@ -270,6 +277,7 @@ export const executeFunctionWithRuntimeGate = async ({ executionParams, envVars, contextVariables, + indicatorRuntimeManifest, isCustomTool, ownerKey: e2bUserScope ? `scope:${e2bUserScope}` : undefined, onStdout, diff --git a/apps/tradinggoose/app/api/function/execute/route.test.ts b/apps/tradinggoose/app/api/function/execute/route.test.ts index 94a7a8505..f297e7284 100644 --- a/apps/tradinggoose/app/api/function/execute/route.test.ts +++ b/apps/tradinggoose/app/api/function/execute/route.test.ts @@ -5,10 +5,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' 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 readWorkflowByIdMock = vi.fn() const loggerMock = { info: vi.fn(), error: vi.fn(), @@ -19,8 +22,6 @@ const loggerMock = { const workflowFunctionBody = (body: Record = {}) => ({ code: 'return "ok"', workflowId: 'workflow-1', - workspaceId: 'workspace-1', - usesParentExecutionConcurrencySlot: true, ...body, }) @@ -41,6 +42,7 @@ describe('Function Execute API Route', () => { currentUsage: 0, limit: 100, }) + checkWorkspaceAccessMock.mockResolvedValue({ hasAccess: true, canWrite: true }) executeFunctionWithRuntimeGateMock.mockResolvedValue({ engine: 'local_vm', success: true, @@ -49,6 +51,9 @@ describe('Function Execute API Route', () => { executionTime: 2400, userCodeStartLine: 3, }) + listCustomIndicatorRuntimeEntriesMock.mockResolvedValue([ + { id: 'indicator-1', pineCode: 'indicator("Custom Indicator")' }, + ]) isBillingEnabledForRuntimeMock.mockResolvedValue(false) accrueUserUsageCostMock.mockResolvedValue(true) @@ -107,6 +112,18 @@ describe('Function Execute API Route', () => { 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/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) => @@ -131,24 +148,35 @@ describe('Function Execute API Route', () => { expect(payload.error).toBe('Unauthorized') }) - it('rejects requests without parent workflow execution context', async () => { + it('accepts exactly one execution scope', async () => { const { POST } = await import('@/app/api/function/execute/route') - const response = await POST( + const workspaceResponse = await POST( + createMockRequest('POST', { + code: 'return "ok"', + workspaceId: 'workspace-1', + }) + ) + + expect(workspaceResponse.status).toBe(200) + expect(readWorkflowByIdMock).not.toHaveBeenCalled() + + const mixedScopeResponse = await POST( createMockRequest('POST', { code: 'return "ok"', workflowId: 'workflow-1', workspaceId: 'workspace-1', }) ) - const payload = await response.json() + const mixedScopePayload = await mixedScopeResponse.json() - expect(response.status).toBe(400) - expect(payload.success).toBe(false) - expect(payload.error).toBe('Function execution requires parent workflow execution context') - expect(executeFunctionWithRuntimeGateMock).not.toHaveBeenCalled() + expect(mixedScopeResponse.status).toBe(400) + expect(mixedScopePayload.error).toBe( + 'Function execution accepts either workflow or workspace context, not both' + ) + expect(executeFunctionWithRuntimeGateMock).toHaveBeenCalledOnce() }) - it('executes under workflow parent context without acquiring a standalone billing gate', async () => { + it('executes under workflow context', async () => { const { POST } = await import('@/app/api/function/execute/route') const response = await POST(createFunctionRequest()) const payload = await response.json() @@ -161,9 +189,49 @@ describe('Function Execute API Route', () => { workspaceId: 'workspace-1', workflowId: 'workflow-1', }) + expect(checkWorkspaceAccessMock).toHaveBeenCalledWith('workspace-1', 'user-1') + expect(listCustomIndicatorRuntimeEntriesMock).toHaveBeenCalledWith('workspace-1') + expect(executeFunctionWithRuntimeGateMock).toHaveBeenCalledWith( + expect.objectContaining({ + indicatorRuntimeManifest: expect.objectContaining({ + indicators: expect.arrayContaining([expect.objectContaining({ id: 'indicator-1' })]), + }), + }) + ) expect(executeFunctionWithRuntimeGateMock).toHaveBeenCalledOnce() }) + it('rejects workflow requests when workspace access is denied', async () => { + checkWorkspaceAccessMock.mockResolvedValueOnce({ hasAccess: false, canWrite: false }) + + const { POST } = await import('@/app/api/function/execute/route') + const response = await POST(createFunctionRequest()) + const payload = await response.json() + + expect(response.status).toBe(403) + expect(payload.success).toBe(false) + expect(payload.error).toBe('Access denied') + expect(executeFunctionWithRuntimeGateMock).not.toHaveBeenCalled() + }) + + it('rejects workspace-scoped function execution for read-only workspace members', async () => { + checkWorkspaceAccessMock.mockResolvedValueOnce({ hasAccess: true, canWrite: false }) + + const { POST } = await import('@/app/api/function/execute/route') + const response = await POST( + createMockRequest('POST', { + code: 'return "ok"', + workspaceId: 'workspace-1', + }) + ) + const payload = await response.json() + + expect(response.status).toBe(403) + expect(payload.success).toBe(false) + expect(payload.error).toBe('Access denied') + expect(executeFunctionWithRuntimeGateMock).not.toHaveBeenCalled() + }) + it('blocks before runtime when workflow usage is exceeded', async () => { checkServerSideUsageLimitsMock.mockResolvedValueOnce({ isExceeded: true, diff --git a/apps/tradinggoose/app/api/function/execute/route.ts b/apps/tradinggoose/app/api/function/execute/route.ts index bb9d1cca2..644719caa 100644 --- a/apps/tradinggoose/app/api/function/execute/route.ts +++ b/apps/tradinggoose/app/api/function/execute/route.ts @@ -2,7 +2,9 @@ import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { executeFunctionRequest } from '@/lib/function/execution' import { createLogger } from '@/lib/logs/console/logger' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' +import { readWorkflowById } from '@/lib/workflows/utils' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -42,22 +44,39 @@ export async function POST(req: NextRequest) { } const body = await req.json() - const { workflowId, workspaceId } = body + const workflowId = typeof body.workflowId === 'string' ? body.workflowId.trim() : '' + const workspaceId = typeof body.workspaceId === 'string' ? body.workspaceId.trim() : '' - if ( - body.usesParentExecutionConcurrencySlot !== true || - typeof workflowId !== 'string' || - typeof workspaceId !== 'string' - ) { + if (!workflowId && !workspaceId) { return respondFailure( - 'Function execution requires parent workflow execution context', + 'Function execution requires workflow or workspace context', Date.now() - startTime, 400 ) } + if (workflowId && workspaceId) { + return respondFailure( + 'Function execution accepts either workflow or workspace context, not both', + Date.now() - startTime, + 400 + ) + } + + const workflow = workflowId ? await readWorkflowById(workflowId) : null + if (workflowId && !workflow?.workspaceId) { + return respondFailure('Workflow not found', Date.now() - startTime, 404) + } + + const executionWorkspaceId = workflow?.workspaceId ?? workspaceId + const access = await checkWorkspaceAccess(executionWorkspaceId, auth.userId) + if (!access.canWrite) { + return respondFailure('Access denied', Date.now() - startTime, 403) + } const result = await executeFunctionRequest({ ...body, + workflowId: workflow?.id, + workspaceId: executionWorkspaceId, userId: auth.userId, requestId, }) diff --git a/apps/tradinggoose/app/api/function/local-execution.ts b/apps/tradinggoose/app/api/function/local-execution.ts index 51d24ff57..facda6301 100644 --- a/apps/tradinggoose/app/api/function/local-execution.ts +++ b/apps/tradinggoose/app/api/function/local-execution.ts @@ -1,6 +1,9 @@ import { createContext, Script } from 'vm' import { withLocalVmSaturationLimit } from '@/lib/execution/local-saturation-limit' -import { createFunctionIndicatorRuntime } from '@/lib/indicators/execution/function-indicator-runtime' +import { + createFunctionIndicatorRuntime, + type FunctionIndicatorRuntimeManifest, +} from '@/lib/indicators/execution/function-indicator-runtime' import { validateProxyUrl } from '@/lib/security/input-validation' type LocalExecutionArgs = { @@ -10,6 +13,7 @@ type LocalExecutionArgs = { executionParams: Record envVars: Record contextVariables: Record + indicatorRuntimeManifest: FunctionIndicatorRuntimeManifest isCustomTool: boolean ownerKey?: string onStdout: (chunk: string) => void @@ -50,6 +54,7 @@ export const executeFunctionInLocalVm = async ({ executionParams, envVars, contextVariables, + indicatorRuntimeManifest, isCustomTool, ownerKey, onStdout, @@ -57,6 +62,7 @@ export const executeFunctionInLocalVm = async ({ onError, }: LocalExecutionArgs): Promise<{ result: unknown; userCodeStartLine: number }> => { const indicator = createFunctionIndicatorRuntime({ + manifest: indicatorRuntimeManifest, requestId, onWarn, }) diff --git a/apps/tradinggoose/app/api/indicators/options/route.ts b/apps/tradinggoose/app/api/indicators/options/route.ts index 56263cfd1..abbb380ce 100644 --- a/apps/tradinggoose/app/api/indicators/options/route.ts +++ b/apps/tradinggoose/app/api/indicators/options/route.ts @@ -111,10 +111,11 @@ export async function GET(request: NextRequest) { source: 'custom', color: row.color?.trim() || '#3972F6', editable: true, - callableInFunctionBlock: false, + callableInFunctionBlock: true, inputTitles, ...(inputMeta && inputTitles.length > 0 ? { inputMeta } : {}), entityId: row.id, + runtimeId: row.id, } }) diff --git a/apps/tradinggoose/blocks/blocks/agent.test.ts b/apps/tradinggoose/blocks/blocks/agent.test.ts index 18eb0e3fa..fb4e559be 100644 --- a/apps/tradinggoose/blocks/blocks/agent.test.ts +++ b/apps/tradinggoose/blocks/blocks/agent.test.ts @@ -114,9 +114,9 @@ describe('AgentBlock', () => { { type: 'custom-tool', title: 'Custom Tool', + toolId: 'custom_custom-tool-1', schema: { function: { - name: 'custom_function', description: 'A custom function', parameters: { type: 'object', properties: {} }, }, @@ -169,9 +169,9 @@ describe('AgentBlock', () => { { type: 'custom-tool', title: 'Custom Tool', + toolId: 'custom_custom-tool-1', schema: { function: { - name: 'custom_function', description: 'A custom function description', parameters: { type: 'object', @@ -190,7 +190,7 @@ describe('AgentBlock', () => { // Verify custom tool transformation expect(result.tools[0]).toEqual({ - id: 'custom_function', + id: 'custom_custom-tool-1', name: 'Custom Tool', description: 'A custom function description', params: {}, diff --git a/apps/tradinggoose/blocks/blocks/agent.ts b/apps/tradinggoose/blocks/blocks/agent.ts index 82d61c4ae..04163329f 100644 --- a/apps/tradinggoose/blocks/blocks/agent.ts +++ b/apps/tradinggoose/blocks/blocks/agent.ts @@ -1,4 +1,5 @@ import { AgentIcon } from '@/components/icons/icons' +import { getCustomToolEntityIdFromRuntimeId } from '@/lib/custom-tools/schema' import { isHosted } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' import type { BlockConfig, SubBlockOption, SubBlockOptionGroup } from '@/blocks/types' @@ -480,15 +481,17 @@ Example 3 (Array Input): return usageControl !== 'none' }) .map((tool: any) => { + const isCustomTool = tool.type === 'custom-tool' + const toolId = isCustomTool + ? tool.toolId + : tool.operation || getToolIdFromBlock(tool.type) + if (isCustomTool) getCustomToolEntityIdFromRuntimeId(toolId) const toolConfig = { - id: - tool.type === 'custom-tool' - ? tool.schema?.function?.name - : tool.operation || getToolIdFromBlock(tool.type), - name: tool.title, - description: tool.type === 'custom-tool' ? tool.schema?.function?.description : '', + id: toolId, + name: isCustomTool ? tool.title?.trim() : tool.title, + description: isCustomTool ? tool.schema?.function?.description : '', params: tool.params || {}, - parameters: tool.type === 'custom-tool' ? tool.schema?.function?.parameters : {}, + parameters: isCustomTool ? tool.schema?.function?.parameters : {}, usageControl: tool.usageControl || 'auto', type: tool.type, } diff --git a/apps/tradinggoose/blocks/blocks/function.ts b/apps/tradinggoose/blocks/blocks/function.ts index 808b9959b..e9eaa2c8d 100644 --- a/apps/tradinggoose/blocks/blocks/function.ts +++ b/apps/tradinggoose/blocks/blocks/function.ts @@ -7,14 +7,14 @@ export const FunctionBlock: BlockConfig = { name: 'Function', description: 'Run custom logic', longDescription: - 'This is a core workflow block. Execute custom TypeScript code within your workflow. Code transpiles to JavaScript at runtime and executes on E2B when enabled, otherwise local VM. Available indicators are executed through indicator.(marketSeries) with full Historical Data block output.', + 'This is a core workflow block. Execute custom TypeScript code within your workflow. Code transpiles to JavaScript at runtime and executes on E2B when enabled, otherwise local VM. Available indicators are executed through indicator.(marketSeries) or indicator[""](marketSeries) with full Historical Data block output.', bestPractices: ` - Write TypeScript statements only (no function wrapper). - If you need external imports, enable E2B at the environment level. - Do not define Pine indicators directly in this block (no indicator(...), PineTS, or pinets imports). - - To execute available indicators, call indicator.(marketSeries) with the full Historical Data output, not . Example: await indicator.RSI(). + - To execute available indicators, call indicator.(marketSeries) or indicator[""](marketSeries) with the full Historical Data output, not . Example: await indicator.RSI(). - Indicator params must be passed as an object. Use saved input titles as keys, for example: await indicator.RSI(, { Length: 7 }) or await indicator.RSI(, { inputs: { Length: 7 } }). - - Use indicator.list() if you need to inspect supported available indicator IDs before calling one. + - Use indicator.list() if you need to inspect available indicator IDs before calling one. - Reference upstream outputs by copying exact TradingGoose tags like , workflow variables like , and environment variables with {{ENV_VAR_NAME}}. These references are valid Function code and resolve before execution; avoid arbitrary XML/HTML tags. `, docsLink: 'https://docs.tradinggoose.ai/blocks/function', @@ -47,7 +47,7 @@ IMPORTANT FORMATTING RULES: 6. Output: Ensure the code returns a value if the function is expected to produce output. Use 'return'. 7. Clarity: Write clean, readable code. 8. No Explanations: Do NOT include markdown formatting, comments explaining the rules, or any text other than the raw TypeScript code for the function body. -9. Available indicators only: Do NOT define indicators directly with indicator(...) or pinets imports. Use indicator.(marketSeries) with the full Historical Data output, not . The optional second argument must be an object, e.g. await indicator.RSI(, { Length: 7 }). Use indicator.list() if the available ID is unknown. +9. Available indicators only: Do NOT define indicators directly with indicator(...) or pinets imports. Use indicator.(marketSeries) or indicator[""](marketSeries) with the full Historical Data output, not . The optional second argument must be an object, e.g. await indicator.RSI(, { Length: 7 }). Use indicator.list() if the available ID is unknown. Example Scenario: User Prompt: "Fetch user data from an API. Use the User ID passed in as 'userId' and an API Key stored as the 'SERVICE_API_KEY' environment variable." diff --git a/apps/tradinggoose/contexts/socket-context.tsx b/apps/tradinggoose/contexts/socket-context.tsx index ecd5ea07a..a8573ccba 100644 --- a/apps/tradinggoose/contexts/socket-context.tsx +++ b/apps/tradinggoose/contexts/socket-context.tsx @@ -2,6 +2,7 @@ import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from 'react' import { io, type Socket } from 'socket.io-client' +import { handleAuthError } from '@/lib/auth/auth-error-handler' import { getEnv } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { usePathname } from '@/i18n/navigation' @@ -22,6 +23,7 @@ const logSocketIssue = ( ) => { if (isSocketAuthError(details.message)) { logger.warn(event, { ...details, callbackPathname }) + void handleAuthError('socket-auth', callbackPathname) } else { logger.error(event, details) } diff --git a/apps/tradinggoose/executor/handlers/agent/agent-handler.test.ts b/apps/tradinggoose/executor/handlers/agent/agent-handler.test.ts index 41adcd375..3416f8c43 100644 --- a/apps/tradinggoose/executor/handlers/agent/agent-handler.test.ts +++ b/apps/tradinggoose/executor/handlers/agent/agent-handler.test.ts @@ -210,6 +210,7 @@ describe('AgentBlockHandler', () => { mockContext.workspaceId = 'workspace-123' mockResolveSkillMetadata.mockResolvedValueOnce([ { + id: 'skill-1', name: 'market-research', description: 'Research the market before acting', }, @@ -341,11 +342,11 @@ describe('AgentBlockHandler', () => { tokens: { prompt: 10, completion: 20, total: 30 }, toolCalls: [ { - name: 'auto_tool', + name: 'custom_auto_tool', arguments: { input: 'test input for auto tool' }, }, { - name: 'force_tool', + name: 'custom_force_tool', arguments: { input: 'test input for force tool' }, }, ], @@ -362,11 +363,11 @@ describe('AgentBlockHandler', () => { { type: 'custom-tool', title: 'Auto Tool', + toolId: 'custom_auto_tool', code: 'return { result: "auto tool executed", input }', timeout: 1000, schema: { function: { - name: 'auto_tool', description: 'Custom tool with auto usage control', parameters: { type: 'object', @@ -381,11 +382,11 @@ describe('AgentBlockHandler', () => { { type: 'custom-tool', title: 'Force Tool', + toolId: 'custom_force_tool', code: 'return { result: "force tool executed", input }', timeout: 1000, schema: { function: { - name: 'force_tool', description: 'Custom tool with forced usage control', parameters: { type: 'object', @@ -400,11 +401,11 @@ describe('AgentBlockHandler', () => { { type: 'custom-tool', title: 'None Tool', + toolId: 'custom_none_tool', code: 'return { result: "none tool executed", input }', timeout: 1000, schema: { function: { - name: 'none_tool', description: 'Custom tool that should be filtered out', parameters: { type: 'object', @@ -421,15 +422,17 @@ describe('AgentBlockHandler', () => { mockGetProviderFromModel.mockReturnValue('openai') - await handler.execute(mockBlock, inputs, mockContext) + const output = (await handler.execute(mockBlock, inputs, mockContext)) as { + toolCalls: { list: Array<{ id?: string; name: string }> } + } expect(Promise.all).toHaveBeenCalled() expect(capturedTools.length).toBe(2) - const autoTool = capturedTools.find((t) => t.name === 'auto_tool') - const forceTool = capturedTools.find((t) => t.name === 'force_tool') - const noneTool = capturedTools.find((t) => t.name === 'none_tool') + const autoTool = capturedTools.find((t) => t.id === 'custom_auto_tool') + const forceTool = capturedTools.find((t) => t.id === 'custom_force_tool') + const noneTool = capturedTools.find((t) => t.id === 'custom_none_tool') expect(autoTool).toBeDefined() expect(forceTool).toBeDefined() @@ -468,6 +471,10 @@ describe('AgentBlockHandler', () => { const requestBody = JSON.parse(fetchCall[1].body) expect(requestBody.tools.length).toBe(2) + expect(output.toolCalls.list).toMatchObject([ + { id: 'custom_auto_tool', name: 'Auto Tool' }, + { id: 'custom_force_tool', name: 'Force Tool' }, + ]) }) it('should filter out tools with usageControl set to "none"', async () => { @@ -565,9 +572,9 @@ describe('AgentBlockHandler', () => { { type: 'custom-tool', title: 'Custom Tool - Auto', + toolId: 'custom_tool_auto', schema: { function: { - name: 'custom_tool_auto', description: 'A custom tool with auto usage control', parameters: { type: 'object', @@ -580,9 +587,9 @@ describe('AgentBlockHandler', () => { { type: 'custom-tool', title: 'Custom Tool - Force', + toolId: 'custom_tool_force', schema: { function: { - name: 'custom_tool_force', description: 'A custom tool with forced usage', parameters: { type: 'object', @@ -595,9 +602,9 @@ describe('AgentBlockHandler', () => { { type: 'custom-tool', title: 'Custom Tool - None', + toolId: 'custom_tool_none', schema: { function: { - name: 'custom_tool_none', description: 'A custom tool that should not be used', parameters: { type: 'object', @@ -619,14 +626,16 @@ describe('AgentBlockHandler', () => { expect(requestBody.tools.length).toBe(2) - const toolNames = requestBody.tools.map((t: any) => t.name) - expect(toolNames).toContain('custom_tool_auto') - expect(toolNames).toContain('custom_tool_force') - expect(toolNames).not.toContain('custom_tool_none') + const toolIds = requestBody.tools.map((t: any) => t.id) + expect(toolIds).toContain('custom_tool_auto') + expect(toolIds).toContain('custom_tool_force') + expect(toolIds).not.toContain('custom_tool_none') - const autoTool = requestBody.tools.find((t: any) => t.name === 'custom_tool_auto') - const forceTool = requestBody.tools.find((t: any) => t.name === 'custom_tool_force') + const autoTool = requestBody.tools.find((t: any) => t.id === 'custom_tool_auto') + const forceTool = requestBody.tools.find((t: any) => t.id === 'custom_tool_force') + expect(autoTool.name).toBe('Custom Tool - Auto') + expect(forceTool.name).toBe('Custom Tool - Force') expect(autoTool.usageControl).toBe('auto') expect(forceTool.usageControl).toBe('force') }) @@ -702,9 +711,9 @@ describe('AgentBlockHandler', () => { { type: 'custom-tool', title: 'Custom Schema Tool', + toolId: 'custom_schema_tool', schema: { function: { - name: 'custom_schema_tool', description: 'A tool defined only by schema', parameters: { type: 'object', @@ -718,11 +727,11 @@ describe('AgentBlockHandler', () => { { type: 'custom-tool', title: 'Custom Code Tool', + toolId: 'custom_code_tool', code: 'return { result: input * 2 }', timeout: 1000, schema: { function: { - name: 'custom_code_tool', description: 'A tool with code execution', parameters: { type: 'object', @@ -1466,341 +1475,5 @@ describe('AgentBlockHandler', () => { expect(requestBody.provider).toBe('openai') expect(requestBody.model).toBe('gpt-5') }) - - it('should handle MCP tools in agent execution', async () => { - mockExecuteTool.mockImplementation((toolId, params, skipPostProcess, context) => { - if (toolId.startsWith('mcp-')) { - return Promise.resolve({ - success: true, - output: { - content: [ - { - type: 'text', - text: `MCP tool ${toolId} executed with params: ${JSON.stringify(params)}`, - }, - ], - }, - }) - } - return Promise.resolve({ success: false, error: 'Unknown tool' }) - }) - - mockFetch.mockImplementationOnce(() => { - return Promise.resolve({ - ok: true, - headers: { - get: (name: string) => { - if (name === 'Content-Type') return 'application/json' - if (name === 'X-Execution-Data') return null - return null - }, - }, - json: () => - Promise.resolve({ - content: 'I will use MCP tools to help you.', - model: 'gpt-4o', - tokens: { prompt: 15, completion: 25, total: 40 }, - toolCalls: [ - { - name: 'mcp-server1-list_files', - arguments: { path: '/tmp' }, - result: { - success: true, - output: { content: [{ type: 'text', text: 'Files listed' }] }, - }, - }, - { - name: 'mcp-server2-search', - arguments: { query: 'test', limit: 5 }, - result: { - success: true, - output: { content: [{ type: 'text', text: 'Search results' }] }, - }, - }, - ], - timing: { total: 150 }, - }), - }) - }) - - const inputs = { - model: 'gpt-4o', - userPrompt: 'List files and search for test data', - apiKey: 'test-api-key', - tools: [ - { - type: 'mcp', - title: 'List Files', - schema: { - function: { - name: 'mcp-server1-list_files', - description: 'List files in directory', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: 'Directory path' }, - }, - }, - }, - }, - usageControl: 'auto' as const, - }, - { - type: 'mcp', - title: 'Search', - schema: { - function: { - name: 'mcp-server2-search', - description: 'Search for data', - parameters: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query' }, - limit: { type: 'number', description: 'Result limit' }, - }, - }, - }, - }, - usageControl: 'auto' as const, - }, - ], - } - - const mcpContext = { - ...mockContext, - workspaceId: 'test-workspace-123', - } - - mockGetProviderFromModel.mockReturnValue('openai') - - const result = await handler.execute(mockBlock, inputs, mcpContext) - - expect((result as any).content).toBe('I will use MCP tools to help you.') - expect((result as any).toolCalls.count).toBe(2) - expect((result as any).toolCalls.list).toHaveLength(2) - - expect((result as any).toolCalls.list[0].name).toBe('mcp-server1-list_files') - expect((result as any).toolCalls.list[0].result.success).toBe(true) - expect((result as any).toolCalls.list[1].name).toBe('mcp-server2-search') - expect((result as any).toolCalls.list[1].result.success).toBe(true) - }) - - it('should handle MCP tool execution errors', async () => { - mockExecuteTool.mockImplementation((toolId, params) => { - if (toolId === 'mcp-server1-failing_tool') { - return Promise.resolve({ - success: false, - error: 'MCP server connection failed', - }) - } - return Promise.resolve({ success: false, error: 'Unknown tool' }) - }) - - mockFetch.mockImplementationOnce(() => { - return Promise.resolve({ - ok: true, - headers: { - get: (name: string) => { - if (name === 'Content-Type') return 'application/json' - if (name === 'X-Execution-Data') return null - return null - }, - }, - json: () => - Promise.resolve({ - content: 'Let me try to use this tool.', - model: 'gpt-4o', - tokens: { prompt: 10, completion: 15, total: 25 }, - toolCalls: [ - { - name: 'mcp-server1-failing_tool', - arguments: { param: 'value' }, - result: { - success: false, - error: 'MCP server connection failed', - }, - }, - ], - timing: { total: 100 }, - }), - }) - }) - - const inputs = { - model: 'gpt-4o', - userPrompt: 'Try to use the failing tool', - apiKey: 'test-api-key', - tools: [ - { - type: 'mcp', - title: 'Failing Tool', - schema: { - function: { - name: 'mcp-server1-failing_tool', - description: 'A tool that will fail', - parameters: { - type: 'object', - properties: { - param: { type: 'string' }, - }, - }, - }, - }, - usageControl: 'auto' as const, - }, - ], - } - - const mcpContext = { - ...mockContext, - workspaceId: 'test-workspace-123', - } - - mockGetProviderFromModel.mockReturnValue('openai') - - const result = await handler.execute(mockBlock, inputs, mcpContext) - - expect((result as any).content).toBe('Let me try to use this tool.') - expect((result as any).toolCalls.count).toBe(1) - expect((result as any).toolCalls.list[0].result.success).toBe(false) - expect((result as any).toolCalls.list[0].result.error).toBe('MCP server connection failed') - }) - - it('should transform MCP tools correctly for agent execution', async () => { - const inputs = { - model: 'gpt-4o', - userPrompt: 'Use MCP tools to help me', - apiKey: 'test-api-key', - tools: [ - { - type: 'mcp', - title: 'Read File', - schema: { - function: { - name: 'mcp-filesystem-read_file', - description: 'Read file from filesystem', - parameters: { type: 'object', properties: {} }, - }, - }, - usageControl: 'auto' as const, - }, - { - type: 'mcp', - title: 'Web Search', - schema: { - function: { - name: 'mcp-web-search', - description: 'Search the web', - parameters: { type: 'object', properties: {} }, - }, - }, - usageControl: 'force' as const, - }, - ], - } - - mockGetProviderFromModel.mockReturnValue('openai') - - mockFetch.mockImplementationOnce(() => { - return Promise.resolve({ - ok: true, - headers: { - get: (name: string) => { - if (name === 'Content-Type') return 'application/json' - if (name === 'X-Execution-Data') return null - return null - }, - }, - json: () => - Promise.resolve({ - content: 'Used MCP tools successfully', - model: 'gpt-4o', - tokens: { prompt: 20, completion: 30, total: 50 }, - toolCalls: [], - timing: { total: 200 }, - }), - }) - }) - - mockTransformBlockTool.mockImplementation((tool: any) => ({ - id: tool.schema?.function?.name || `mcp-${tool.title.toLowerCase().replace(' ', '-')}`, - name: tool.schema?.function?.name || tool.title, - description: tool.schema?.function?.description || `MCP tool: ${tool.title}`, - parameters: tool.schema?.function?.parameters || { type: 'object', properties: {} }, - usageControl: tool.usageControl, - })) - - const result = await handler.execute(mockBlock, inputs, mockContext) - - // Verify that the agent executed successfully with MCP tools - expect(result).toBeDefined() - expect(mockFetch).toHaveBeenCalled() - - // Verify the agent returns the expected response format - expect((result as any).content).toBe('Used MCP tools successfully') - expect((result as any).model).toBe('gpt-4o') - }) - - it('should provide workspaceId context for MCP tool execution', async () => { - let capturedContext: any - mockExecuteTool.mockImplementation((toolId, params, skipPostProcess, context) => { - capturedContext = context - if (toolId.startsWith('mcp-')) { - return Promise.resolve({ - success: true, - output: { content: [{ type: 'text', text: 'Success' }] }, - }) - } - return Promise.resolve({ success: false, error: 'Unknown tool' }) - }) - - mockFetch.mockImplementationOnce(() => { - return Promise.resolve({ - ok: true, - headers: { - get: (name: string) => (name === 'Content-Type' ? 'application/json' : null), - }, - json: () => - Promise.resolve({ - content: 'Using MCP tool', - model: 'gpt-4o', - tokens: { prompt: 10, completion: 10, total: 20 }, - toolCalls: [{ name: 'mcp-test-tool', arguments: {} }], - timing: { total: 50 }, - }), - }) - }) - - const inputs = { - model: 'gpt-4o', - userPrompt: 'Test MCP context', - apiKey: 'test-api-key', - tools: [ - { - type: 'mcp', - title: 'Test Tool', - schema: { - function: { - name: 'mcp-test-tool', - description: 'Test MCP tool', - parameters: { type: 'object', properties: {} }, - }, - }, - usageControl: 'auto' as const, - }, - ], - } - - const contextWithWorkspace = { - ...mockContext, - workspaceId: 'test-workspace-456', - } - - mockGetProviderFromModel.mockReturnValue('openai') - - await handler.execute(mockBlock, inputs, contextWithWorkspace) - - expect(contextWithWorkspace.workspaceId).toBe('test-workspace-456') - }) }) }) diff --git a/apps/tradinggoose/executor/handlers/agent/agent-handler.ts b/apps/tradinggoose/executor/handlers/agent/agent-handler.ts index bf1e4781d..44dd3c8a0 100644 --- a/apps/tradinggoose/executor/handlers/agent/agent-handler.ts +++ b/apps/tradinggoose/executor/handlers/agent/agent-handler.ts @@ -1,3 +1,7 @@ +import { + buildCustomToolModelDescription, + getCustomToolEntityIdFromRuntimeId, +} from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { createMcpToolId } from '@/lib/mcp/utils' import { getBaseUrl } from '@/lib/urls/utils' @@ -29,7 +33,6 @@ const logger = createLogger('AgentBlockHandler') const DEFAULT_MODEL = 'gpt-4o' const DEFAULT_FUNCTION_TIMEOUT = 600000 const REQUEST_TIMEOUT = 120000 -const CUSTOM_TOOL_PREFIX = 'custom_' /** * Helper function to collect runtime block outputs and name mappings @@ -94,12 +97,7 @@ export class AgentBlockHandler implements BlockHandler { : null if (skillMetadata.length > 0 && skillLoaderToolId) { - formattedTools.push( - buildLoadSkillTool( - skillLoaderToolId, - skillMetadata.map((skill) => skill.name) - ) - ) + formattedTools.push(buildLoadSkillTool(skillLoaderToolId, skillMetadata)) } const streamingConfig = this.getStreamingConfig(block, context) @@ -221,11 +219,15 @@ export class AgentBlockHandler implements BlockHandler { const filteredSchema = filterSchemaForLLM(tool.schema.function.parameters, userProvidedParams) - const toolId = `${CUSTOM_TOOL_PREFIX}${tool.title}` + const toolId = tool.toolId + getCustomToolEntityIdFromRuntimeId(toolId) const base: any = { id: toolId, - name: tool.schema.function.name, - description: tool.schema.function.description || '', + name: tool.title?.trim(), + description: buildCustomToolModelDescription({ + title: tool.title, + description: tool.schema.function.description, + }), params: userProvidedParams, parameters: { ...filteredSchema, @@ -436,7 +438,7 @@ export class AgentBlockHandler implements BlockHandler { private buildMessages( inputs: AgentInputs, - skillMetadata: Array<{ name: string; description: string }> = [], + skillMetadata: Array<{ id: string; name: string; description: string }> = [], skillLoaderToolId?: string | null ): Message[] | undefined { if ( @@ -695,7 +697,11 @@ export class AgentBlockHandler implements BlockHandler { result ) - return this.processProviderResponse(result, block, responseFormat) + return this.processProviderResponse( + this.withToolCallMetadata(result, providerRequest.tools), + block, + responseFormat + ) } private async executeBrowserSide( @@ -755,17 +761,22 @@ export class AgentBlockHandler implements BlockHandler { if (contentType?.includes('text/event-stream')) { // Handle streaming response logger.info('Received streaming response') - return this.handleStreamingResponse(response, block) + return this.handleStreamingResponse(response, block, providerRequest.tools) } // Handle regular JSON response const result = await response.json() - return this.processProviderResponse(result, block, responseFormat) + return this.processProviderResponse( + this.withToolCallMetadata(result, providerRequest.tools), + block, + responseFormat + ) } private async handleStreamingResponse( response: Response, - block: SerializedBlock + block: SerializedBlock, + tools: any[] ): Promise { // Check if we have execution data in headers (from StreamingExecution) const executionDataHeader = response.headers.get('X-Execution-Data') @@ -780,7 +791,7 @@ export class AgentBlockHandler implements BlockHandler { stream: response.body!, execution: { success: executionData.success, - output: executionData.output || {}, + output: this.withToolCallMetadata({ output: executionData.output }, tools).output || {}, error: executionData.error, logs: [], // Logs are stripped from headers, will be populated by executor metadata: executionData.metadata || { @@ -985,6 +996,28 @@ export class AgentBlockHandler implements BlockHandler { } } + private withToolCallMetadata(result: any, tools: any[] = []) { + const toolNames = new Map() + for (const tool of tools) { + if (typeof tool?.id === 'string' && typeof tool.name === 'string') { + toolNames.set(tool.id, tool.name.trim()) + } + } + + const apply = (toolCalls?: any[]) => + toolCalls?.forEach((toolCall) => { + if (typeof toolCall?.name !== 'string') return + const id = toolCall.name + const name = toolNames.get(id) + if (name) Object.assign(toolCall, { id, name }) + }) + + apply(result?.toolCalls) + apply(result?.execution?.output?.toolCalls?.list) + apply(result?.output?.toolCalls?.list) + return result + } + private createResponseMetadata(result: any) { return { tokens: result.tokens || { prompt: 0, completion: 0, total: 0 }, @@ -999,11 +1032,8 @@ export class AgentBlockHandler implements BlockHandler { } private formatToolCall(tc: any) { - const toolName = this.stripCustomToolPrefix(tc.name) - return { ...tc, - name: toolName, startTime: tc.startTime, endTime: tc.endTime, duration: tc.duration, @@ -1011,8 +1041,4 @@ export class AgentBlockHandler implements BlockHandler { result: tc.result || tc.output, } } - - private stripCustomToolPrefix(name: string): string { - return name.startsWith('custom_') ? name.replace('custom_', '') : name - } } diff --git a/apps/tradinggoose/executor/handlers/agent/skill-loader.ts b/apps/tradinggoose/executor/handlers/agent/skill-loader.ts index d8d56f9a2..a1cfdf030 100644 --- a/apps/tradinggoose/executor/handlers/agent/skill-loader.ts +++ b/apps/tradinggoose/executor/handlers/agent/skill-loader.ts @@ -2,6 +2,7 @@ const SKILL_LOADER_MARKER = '__tradinggooseSkillLoader' export const SKILL_LOADER_TOOL_PREFIX = 'tradinggoose_internal_load_skill' export interface SkillMetadata { + id: string name: string description: string } @@ -55,13 +56,13 @@ export function buildSkillsSystemPromptSection( const skillEntries = skills .map( (skillMetadata) => - ` \n ${escapeXml(skillMetadata.description)}\n ` + ` \n ${escapeXml(skillMetadata.description)}\n ` ) .join('\n') return [ '', - `You have access to the following skills. Use the ${skillLoaderToolId} tool to activate a skill when relevant.`, + `You have access to the following skills. Use the ${skillLoaderToolId} tool to activate a skill when relevant. Pass the exact skill_id from the id attribute; skill names are display metadata.`, '', '', skillEntries, @@ -69,24 +70,26 @@ export function buildSkillsSystemPromptSection( ].join('\n') } -export function buildLoadSkillTool(skillLoaderToolId: string, skillNames: string[]) { +export function buildLoadSkillTool(skillLoaderToolId: string, skills: SkillMetadata[]) { + const skillSummaries = skills.map((skill) => `${skill.id} (${skill.name})`).join(', ') + return { id: skillLoaderToolId, name: skillLoaderToolId, - description: `Load a skill to get specialized instructions. Available skills: ${skillNames.join(', ')}`, + description: `Load a skill by exact skill_id to get specialized instructions. Available skill ids: ${skillSummaries}`, params: { [SKILL_LOADER_MARKER]: true, }, parameters: { type: 'object', properties: { - skill_name: { + skill_id: { type: 'string', - enum: skillNames, - description: 'Name of the skill to load', + enum: skills.map((skill) => skill.id), + description: 'Exact skill id from available_skills; do not pass the display name', }, }, - required: ['skill_name'], + required: ['skill_id'], }, } } diff --git a/apps/tradinggoose/executor/handlers/agent/skills-resolver.ts b/apps/tradinggoose/executor/handlers/agent/skills-resolver.ts index 2fcce4fca..4367a3dc7 100644 --- a/apps/tradinggoose/executor/handlers/agent/skills-resolver.ts +++ b/apps/tradinggoose/executor/handlers/agent/skills-resolver.ts @@ -22,7 +22,7 @@ export async function resolveSkillMetadata( const selectedSkillIds = new Set(skillIds) return skills .filter((skill) => selectedSkillIds.has(skill.id)) - .map((skill) => ({ name: skill.name, description: skill.description })) + .map((skill) => ({ id: skill.id, name: skill.name, description: skill.description })) } catch (error) { logger.error('Failed to resolve skill metadata', { error, skillIds, workspaceId }) return [] @@ -30,25 +30,25 @@ export async function resolveSkillMetadata( } export async function resolveSkillContent( - skillName: string, + skillId: string, workspaceId: string ): Promise { - if (!skillName || !workspaceId) { + if (!skillId || !workspaceId) { return null } try { const rows = await listSkills({ workspaceId }) - const skill = rows.find((row) => row.name === skillName) + const skill = rows.find((row) => row.id === skillId) if (!skill) { - logger.warn('Skill not found', { skillName, workspaceId }) + logger.warn('Skill not found', { skillId, workspaceId }) return null } return skill.content } catch (error) { - logger.error('Failed to resolve skill content', { error, skillName, workspaceId }) + logger.error('Failed to resolve skill content', { error, skillId, workspaceId }) return null } } diff --git a/apps/tradinggoose/executor/handlers/agent/types.ts b/apps/tradinggoose/executor/handlers/agent/types.ts index 7c1981943..5437ac927 100644 --- a/apps/tradinggoose/executor/handlers/agent/types.ts +++ b/apps/tradinggoose/executor/handlers/agent/types.ts @@ -25,6 +25,7 @@ export interface ToolInput { type?: string schema?: any title?: string + toolId?: string code?: string params?: Record timeout?: number diff --git a/apps/tradinggoose/hooks/queries/custom-tools.ts b/apps/tradinggoose/hooks/queries/custom-tools.ts index 214f38bc3..dbc729eab 100644 --- a/apps/tradinggoose/hooks/queries/custom-tools.ts +++ b/apps/tradinggoose/hooks/queries/custom-tools.ts @@ -33,7 +33,11 @@ type ApiCustomTool = Partial & { } function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomToolDefinition { - const functionName = tool.schema.function?.name || tool.id + const title = tool.title.trim() + if (!title) { + throw new Error('Custom tool title is required') + } + const parameters = tool.schema.function?.parameters ?? { type: 'object', properties: {}, @@ -41,7 +45,7 @@ function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomTo return { id: tool.id, - title: tool.title, + title, code: typeof tool.code === 'string' ? tool.code : '', workspaceId: tool.workspaceId ?? workspaceId, userId: tool.userId ?? null, @@ -55,7 +59,6 @@ function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomTo schema: { type: tool.schema.type ?? 'function', function: { - name: functionName, description: tool.schema.function?.description, parameters: { type: parameters.type ?? 'object', @@ -99,7 +102,7 @@ async function fetchCustomTools(workspaceId: string): Promise { const updatedAt = typeof tool.updatedAt === 'string' ? tool.updatedAt : (tool.createdAt ?? '') - return `${tool.id}:${updatedAt}:${tool.title}:${tool.schema?.function?.name ?? ''}:${tool.code ?? ''}` + return `${tool.id}:${updatedAt}:${tool.title}:${JSON.stringify(tool.schema?.function ?? {})}:${tool.code ?? ''}` }) .join('|') diff --git a/apps/tradinggoose/i18n/messages/en.json b/apps/tradinggoose/i18n/messages/en.json index 2e9540e51..2feb70333 100644 --- a/apps/tradinggoose/i18n/messages/en.json +++ b/apps/tradinggoose/i18n/messages/en.json @@ -370,16 +370,8 @@ "waitlist": "Honk! Introducing TradingGoose-Studio", "open": "Honk! TradingGoose-Studio is here!" }, - "leadWords": [ - "Build", - "Test", - "Run" - ], - "highlightWords": [ - "Trading Analysis", - "Signal Detection", - "Risk Assessment" - ], + "leadWords": ["Build", "Test", "Run"], + "highlightWords": ["Trading Analysis", "Signal Detection", "Risk Assessment"], "titleConnector": "your", "suffix": "with TradingGoose", "description": "Connect your own data providers, write custom indicators to monitor market prices, and wire them into workflows that trigger trade, sell, buy, or any action you define.", @@ -713,13 +705,7 @@ "experience": { "label": "Years of Experience *", "placeholder": "Select experience level", - "options": [ - "0-1 years", - "1-3 years", - "3-5 years", - "5-10 years", - "10+ years" - ] + "options": ["0-1 years", "1-3 years", "3-5 years", "5-10 years", "10+ years"] }, "location": { "label": "Location *", @@ -3682,10 +3668,7 @@ }, "headers": { "title": "Response Headers", - "columns": [ - "Key", - "Value" - ], + "columns": ["Key", "Value"], "description": "Additional HTTP headers to include in the response" } }, @@ -17931,10 +17914,7 @@ }, "variables": { "title": "Variables", - "columns": [ - "Key", - "Value" - ] + "columns": ["Key", "Value"] }, "apiKey": { "title": "Anthropic API Key", @@ -20954,7 +20934,6 @@ "validation": { "failedToLoadToolData": "Failed to load tool data. Please try again.", "missingTypeFunction": "Missing \"type\": \"function\"", - "missingFunctionName": "Missing function.name field", "missingFunctionParameters": "Missing function.parameters object", "missingParametersType": "Missing parameters.type field", "missingParametersProperties": "Missing parameters.properties field", @@ -20962,9 +20941,7 @@ "invalidJsonFormat": "Invalid JSON format", "schemaCannotBeEmpty": "Schema cannot be empty", "schemaMustBeFunctionType": "Schema must have a \"type\" field set to \"function\"", - "schemaMustHaveFunctionName": "Schema must have a \"function\" object with a \"name\" field", "failedToValidateSchema": "Failed to validate custom tool schema. Please check your inputs and try again.", - "duplicateName": "A tool with the name \"{name}\" already exists", "failedToSave": "Failed to save custom tool. Please check your inputs and try again." }, "form": { @@ -21443,6 +21420,15 @@ "older": "Older" } }, + "mentions": { + "workflowBlocks": "Workflow Blocks", + "matches": "Matches", + "noMatches": "No matches", + "noPastChats": "No past chats", + "noBlocksFound": "No blocks found", + "noBlocksInWorkflow": "No blocks in this workflow", + "noExecutionsFound": "No executions found" + }, "accessLevel": { "limited": { "label": "Limited", diff --git a/apps/tradinggoose/i18n/messages/es.json b/apps/tradinggoose/i18n/messages/es.json index a2a377a81..77a91d41d 100644 --- a/apps/tradinggoose/i18n/messages/es.json +++ b/apps/tradinggoose/i18n/messages/es.json @@ -20873,7 +20873,7 @@ "noSkillsAvailableYet": "Aún no hay skills disponibles.", "noSkillsFound": "No se encontraron skills.", "retry": "Reintentar", - "untitledSkill": "Skill sin título" + "untitledSkill": "Habilidad sin título" }, "customToolDropdown": { "placeholder": "Seleccionar herramientas personalizadas", @@ -20934,7 +20934,6 @@ "validation": { "failedToLoadToolData": "No se pudieron cargar los datos de la herramienta. Inténtalo de nuevo.", "missingTypeFunction": "Falta \"type\": \"function\"", - "missingFunctionName": "Falta el campo function.name", "missingFunctionParameters": "Falta el objeto function.parameters", "missingParametersType": "Falta el campo parameters.type", "missingParametersProperties": "Falta el campo parameters.properties", @@ -20942,9 +20941,7 @@ "invalidJsonFormat": "Formato JSON inválido", "schemaCannotBeEmpty": "El esquema no puede estar vacío", "schemaMustBeFunctionType": "El esquema debe tener un campo \"type\" con valor \"function\"", - "schemaMustHaveFunctionName": "El esquema debe tener un objeto \"function\" con un campo \"name\"", "failedToValidateSchema": "No se pudo validar el esquema de la herramienta personalizada. Revisa tus datos e inténtalo de nuevo.", - "duplicateName": "Ya existe una herramienta llamada \"{name}\"", "failedToSave": "No se pudo guardar la herramienta personalizada. Revisa tus datos e inténtalo de nuevo." }, "form": { @@ -21423,6 +21420,15 @@ "older": "Anteriores" } }, + "mentions": { + "workflowBlocks": "Bloques del flujo de trabajo", + "matches": "Coincidencias", + "noMatches": "No hay coincidencias", + "noPastChats": "No hay chats anteriores", + "noBlocksFound": "No se encontraron bloques", + "noBlocksInWorkflow": "No hay bloques en este flujo de trabajo", + "noExecutionsFound": "No se encontraron ejecuciones" + }, "accessLevel": { "limited": { "label": "Limitado", diff --git a/apps/tradinggoose/i18n/messages/zh.json b/apps/tradinggoose/i18n/messages/zh.json index 932bee00c..36ea2520b 100644 --- a/apps/tradinggoose/i18n/messages/zh.json +++ b/apps/tradinggoose/i18n/messages/zh.json @@ -20921,7 +20921,6 @@ "validation": { "failedToLoadToolData": "无法加载工具数据,请重试。", "missingTypeFunction": "缺少 \"type\": \"function\"", - "missingFunctionName": "缺少 function.name 字段", "missingFunctionParameters": "缺少 function.parameters 对象", "missingParametersType": "缺少 parameters.type 字段", "missingParametersProperties": "缺少 parameters.properties 字段", @@ -20929,9 +20928,7 @@ "invalidJsonFormat": "JSON 格式无效", "schemaCannotBeEmpty": "Schema 不能为空", "schemaMustBeFunctionType": "Schema 必须有值为 \"function\" 的 \"type\" 字段", - "schemaMustHaveFunctionName": "Schema 必须有一个带 \"name\" 字段的 \"function\" 对象", "failedToValidateSchema": "无法验证自定义工具 schema,请检查输入后重试。", - "duplicateName": "已存在名为 \"{name}\" 的工具", "failedToSave": "无法保存自定义工具,请检查输入后重试。" }, "form": { @@ -21410,6 +21407,15 @@ "older": "更早" } }, + "mentions": { + "workflowBlocks": "工作流模块", + "matches": "匹配项", + "noMatches": "未找到匹配项", + "noPastChats": "没有历史聊天", + "noBlocksFound": "未找到模块", + "noBlocksInWorkflow": "此工作流中没有模块", + "noExecutionsFound": "未找到执行记录" + }, "accessLevel": { "limited": { "label": "受限", diff --git a/apps/tradinggoose/i18n/workspace-widget-hooks.ts b/apps/tradinggoose/i18n/workspace-widget-hooks.ts index e777c036a..f465dc6d9 100644 --- a/apps/tradinggoose/i18n/workspace-widget-hooks.ts +++ b/apps/tradinggoose/i18n/workspace-widget-hooks.ts @@ -1,7 +1,7 @@ 'use client' -import { useMessages } from 'next-intl' import type { Messages } from 'next-intl' +import { useMessages } from 'next-intl' type WorkspaceWidgetsMessages = Messages['workspace']['widgets'] @@ -12,7 +12,6 @@ export type WorkflowInspectorMessages = Pick< export type WorkflowToolbarMessages = WorkspaceWidgetsMessages['workflowToolbar'] export type WorkflowEditorMessages = WorkspaceWidgetsMessages['workflowEditor'] export type WorkspaceBlockEditorMessages = WorkspaceWidgetsMessages['blockEditor'] -export type WorkflowLabelMessages = WorkspaceWidgetsMessages['workflowLabels'] export type BlockEditorMessages = WorkspaceWidgetsMessages['blockEditor'] export type WorkflowDropdownMessages = WorkspaceWidgetsMessages['workflowDropdown'] export type SelectorMessages = WorkspaceWidgetsMessages['selector'] @@ -54,10 +53,6 @@ export function useWorkspaceBlockEditorMessages(): WorkspaceBlockEditorMessages return useWorkspaceWidgetsMessages().blockEditor } -export function useWorkflowLabelMessages(): WorkflowLabelMessages { - return useWorkspaceWidgetsMessages().workflowLabels -} - export function useBlockEditorMessages(): BlockEditorMessages { return useWorkspaceWidgetsMessages().blockEditor } diff --git a/apps/tradinggoose/lib/auth/auth-error-handler.test.ts b/apps/tradinggoose/lib/auth/auth-error-handler.test.ts index 0c96bcbbb..e83503798 100644 --- a/apps/tradinggoose/lib/auth/auth-error-handler.test.ts +++ b/apps/tradinggoose/lib/auth/auth-error-handler.test.ts @@ -32,7 +32,7 @@ describe('handleAuthError', () => { const { handleAuthError } = await import('./auth-error-handler') - await handleAuthError('workspace-permissions', '/login') + await handleAuthError('socket-auth', '/login') expect(replaceMock).toHaveBeenCalledWith('/login?callbackUrl=%2Fworkspace&reauth=1#credentials') }) diff --git a/apps/tradinggoose/lib/copilot/chat-contexts.ts b/apps/tradinggoose/lib/copilot/chat-contexts.ts index 86f2a1467..af079b818 100644 --- a/apps/tradinggoose/lib/copilot/chat-contexts.ts +++ b/apps/tradinggoose/lib/copilot/chat-contexts.ts @@ -19,7 +19,7 @@ export const extractExplicitCopilotContexts = ( ): ChatContext[] => Array.isArray(contexts) ? contexts.filter((context) => !isHiddenCopilotContext(context)) : [] -const buildContextIdentityKey = (context: ChatContext): string => { +export const buildCopilotContextIdentityKey = (context: ChatContext): string => { const getContextReviewIdentity = () => ('reviewSessionId' in context ? context.reviewSessionId : undefined) ?? ('draftSessionId' in context ? context.draftSessionId : undefined) ?? @@ -42,7 +42,7 @@ const buildContextIdentityKey = (context: ChatContext): string => { case 'templates': return `templates:${context.templateId ?? context.label}` case 'docs': - return `docs:${context.label}` + return 'docs' case 'logs': return `logs:${context.executionId ?? context.label}` } @@ -53,7 +53,7 @@ const buildContextIdentityKey = (context: ChatContext): string => { const dedupeCopilotContexts = (contexts: ChatContext[]): ChatContext[] => { const seen = new Set() return contexts.filter((context) => { - const key = buildContextIdentityKey(context) + const key = buildCopilotContextIdentityKey(context) if (seen.has(key)) { return false } @@ -70,10 +70,12 @@ export const mergeCopilotContexts = ({ implicitContexts?: ChatContext[] | null }): ChatContext[] => { const explicit = dedupeCopilotContexts(extractExplicitCopilotContexts(explicitContexts)) - const explicitKeys = new Set(explicit.map(buildContextIdentityKey)) + const explicitKeys = new Set(explicit.map(buildCopilotContextIdentityKey)) const implicit = dedupeCopilotContexts( Array.isArray(implicitContexts) - ? implicitContexts.filter((context) => !explicitKeys.has(buildContextIdentityKey(context))) + ? implicitContexts.filter( + (context) => !explicitKeys.has(buildCopilotContextIdentityKey(context)) + ) : [] ) diff --git a/apps/tradinggoose/lib/copilot/entity-documents.ts b/apps/tradinggoose/lib/copilot/entity-documents.ts index a4042fcc3..d93412ddb 100644 --- a/apps/tradinggoose/lib/copilot/entity-documents.ts +++ b/apps/tradinggoose/lib/copilot/entity-documents.ts @@ -24,7 +24,7 @@ const CustomToolDocumentSchema = z.object({ schemaText: z .string() .describe( - 'JSON text for an OpenAI function tool schema: {"type":"function","function":{"name":"camelCaseName","description":"What the tool does","parameters":{"type":"object","properties":{},"required":[]}}}. This field is a string containing JSON, not an object.' + 'JSON text for an OpenAI function tool schema: {"type":"function","function":{"description":"What the tool does","parameters":{"type":"object","properties":{},"required":[]}}}. This field is a string containing JSON, not an object. Do not include a `name` property inside `function`; the document `title` is the canonical custom-tool name.' ), codeText: z .string() diff --git a/apps/tradinggoose/lib/copilot/registry.ts b/apps/tradinggoose/lib/copilot/registry.ts index 4fde5f7d3..2ce13c9b7 100644 --- a/apps/tradinggoose/lib/copilot/registry.ts +++ b/apps/tradinggoose/lib/copilot/registry.ts @@ -117,7 +117,7 @@ const BooleanOptional = z.boolean().optional() const NumberOptional = z.number().optional() const RequiredId = z.string().trim().min(1) const CUSTOM_TOOL_DOCUMENT_ARGUMENT_DESCRIPTION = - 'Full `tg-custom-tool-document-v1` JSON document with exactly `title`, `schemaText`, and `codeText`. `schemaText` is a JSON-encoded string, not an object, for an OpenAI function tool schema: {"type":"function","function":{"name":"camelCaseName","description":"What the tool does","parameters":{"type":"object","properties":{},"required":[]}}}. `codeText` is raw async JavaScript function body only; use for inputs and {{ENV_VAR_NAME}} for environment variables.' + 'Full `tg-custom-tool-document-v1` JSON document with exactly `title`, `schemaText`, and `codeText`. `title` is the canonical custom-tool name. `schemaText` is a JSON-encoded string, not an object, for an OpenAI function tool schema: {"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 OptionalEntityTargetArgs = z.object({ entityId: z.string().optional(), }) @@ -234,7 +234,7 @@ const GetIndicatorArgs = z .min(1) .optional() .describe( - 'Built-in default indicator runtime id from `list_indicators`, such as `RSI`. Use this for read-only built-in inspection.' + 'Indicator runtime id from `list_indicators`. Built-in ids inspect read-only defaults; custom ids resolve the saved custom indicator.' ), }) .strict() @@ -656,7 +656,6 @@ const GenericEntityListEntry = z.object({ workspaceId: z.string().optional(), entityDescription: z.string().optional(), entityTitle: z.string().optional(), - entityFunctionName: z.string().optional(), entityTransport: z.string().optional(), entityUrl: z.string().optional(), entityEnabled: z.boolean().optional(), diff --git a/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts b/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts index 2cb512612..4f3eac525 100644 --- a/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts +++ b/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts @@ -123,13 +123,13 @@ describe('copilot runtime tool manifest', () => { }), expect.objectContaining({ name: 'read_indicator', - description: expect.stringContaining('pass `runtimeId` from `list_indicators`'), + description: expect.stringContaining('Pass `runtimeId` from `list_indicators`'), kind: 'read', entityKind: 'indicator', parameters: expect.objectContaining({ properties: expect.objectContaining({ runtimeId: expect.objectContaining({ - description: expect.stringContaining('Built-in default indicator runtime id'), + description: expect.stringContaining('Indicator runtime id'), }), }), }), diff --git a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts index c121c6f64..efad9e3bd 100644 --- a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts +++ b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts @@ -8,7 +8,7 @@ export interface ToolPromptMetadata { } const CUSTOM_TOOL_DOCUMENT_GUIDANCE = - 'Use full `tg-custom-tool-document-v1` JSON with exactly `title`, `schemaText`, and `codeText`. `schemaText` is a JSON-encoded string, not an object, containing {"type":"function","function":{"name":"camelCaseName","description":"What the tool does","parameters":{"type":"object","properties":{},"required":[]}}}. `codeText` is raw async JavaScript function body only; use for inputs and {{ENV_VAR_NAME}} for environment variables.' + '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.' export const TOOL_PROMPT_METADATA: Record = { plan: { @@ -191,7 +191,7 @@ export const TOOL_PROMPT_METADATA: Record = { entityKind: 'custom_tool', }, rename_custom_tool: { - description: `Rename the target custom tool by sending a full custom tool document with the updated title or function name, then return the resulting document. ${CUSTOM_TOOL_DOCUMENT_GUIDANCE}`, + description: `Rename the target custom tool by sending a full custom tool document with the updated title, then return the resulting document. ${CUSTOM_TOOL_DOCUMENT_GUIDANCE}`, kind: 'rename', entityKind: 'custom_tool', }, @@ -215,13 +215,13 @@ export const TOOL_PROMPT_METADATA: Record = { }, [CopilotTool.list_indicators]: { description: - 'List both built-in default indicators and workspace custom indicators. Each result includes `source`, `editable`, `callableInFunctionBlock`, optional `entityId` for editable custom indicators, optional `runtimeId` for built-in Function-block calls, and optional `inputTitles` showing saved override keys. Use `read_indicator` next to inspect the full indicator document, Pine code, and input metadata for a candidate built-in or custom indicator.', + 'List both built-in default indicators and workspace custom indicators. Each result includes `source`, `editable`, `callableInFunctionBlock`, optional `entityId` for editable custom indicators, `runtimeId` for Function-block calls, and optional `inputTitles` showing saved override keys. Use `read_indicator` next to inspect the full indicator document, Pine code, and input metadata for a candidate built-in or custom indicator.', kind: 'list', entityKind: 'indicator', }, [CopilotTool.read_indicator]: { description: - 'Return one indicator as a document payload with `entityDocument` and `documentFormat`. For built-in default indicators, pass `runtimeId` from `list_indicators` to inspect the read-only default indicator document, Pine code, and input metadata. For custom indicators, pass `entityId` from `list_indicators` entries where `editable` is true. Built-in default indicators are read-only.', + 'Return one indicator as a document payload with `entityDocument` and `documentFormat`. Pass `runtimeId` from `list_indicators` for the callable indicator identity; built-in default indicators are read-only, while custom indicator runtime ids resolve the saved custom indicator document.', kind: 'read', entityKind: 'indicator', }, 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 index e3f20b34c..462a28cfb 100644 --- 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 @@ -1,7 +1,7 @@ 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 { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' +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' @@ -17,7 +17,6 @@ type EntityListEntry = { entityName: string entityDescription?: string entityTitle?: string - entityFunctionName?: string entityTransport?: string entityUrl?: string entityEnabled?: boolean @@ -85,7 +84,7 @@ const ENTITY_API_CONFIG: Record = { title: item?.title ?? '', schemaText: item?.schema && typeof item.schema === 'object' - ? JSON.stringify(item.schema, null, 2) + ? JSON.stringify(CustomToolOpenAiSchema.parse(item.schema), null, 2) : typeof item?.schemaText === 'string' ? item.schemaText : '', @@ -93,10 +92,8 @@ const ENTITY_API_CONFIG: Record = { }), toListEntry: (item) => ({ entityId: String(item?.id ?? ''), - entityName: String(item?.title ?? item?.schema?.function?.name ?? ''), + entityName: String(item?.title ?? ''), entityTitle: typeof item?.title === 'string' ? item.title : '', - entityFunctionName: - typeof item?.schema?.function?.name === 'string' ? item.schema.function.name : undefined, entityDescription: typeof item?.schema?.function?.description === 'string' ? item.schema.function.description @@ -404,7 +401,7 @@ export async function readEntityFieldsFromContext( entityName: string fields: Record }> { - const resolvedEntityId = resolveOptionalCopilotEntityId(target) + let resolvedEntityId = resolveOptionalCopilotEntityId(target) const resolvedRuntimeId = kind === 'indicator' ? target?.runtimeId?.trim() || undefined : undefined @@ -414,18 +411,18 @@ export async function readEntityFieldsFromContext( } const indicator = getDefaultIndicator(resolvedRuntimeId) - if (!indicator) { - throw new Error(`Built-in indicator ${resolvedRuntimeId} was not found`) + if (indicator) { + return { + entityName: indicator.name, + fields: { + name: indicator.name, + pineCode: indicator.pineCode, + inputMeta: indicator.inputMeta ?? null, + }, + } } - return { - entityName: indicator.name, - fields: { - name: indicator.name, - pineCode: indicator.pineCode, - inputMeta: indicator.inputMeta ?? null, - }, - } + resolvedEntityId = resolvedRuntimeId } if (!resolvedEntityId) { 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 index a17bf5217..b4700a485 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/entities/entity-tools.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/entities/entity-tools.test.ts @@ -186,7 +186,6 @@ describe('entity document tools', () => { { type: 'function', function: { - name: 'marketTool', description: 'Fetch market data', parameters: { type: 'object', properties: {} }, }, @@ -344,9 +343,10 @@ describe('entity document tools', () => { name: 'My Custom Indicator', source: 'custom', editable: true, - callableInFunctionBlock: false, + callableInFunctionBlock: true, inputTitles: ['Fast Length'], entityId: 'indicator-1', + runtimeId: 'indicator-1', }, ], }), @@ -401,9 +401,10 @@ describe('entity document tools', () => { name: 'My Custom Indicator', source: 'custom', editable: true, - callableInFunctionBlock: false, + callableInFunctionBlock: true, inputTitles: ['Fast Length'], entityId: 'indicator-1', + runtimeId: 'indicator-1', }, ]) }) @@ -455,6 +456,60 @@ describe('entity document tools', () => { 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 { 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 a8ff5b5b1..7df58dd2b 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 @@ -10,6 +10,7 @@ import type { GetAgentAccessoryCatalogResultType, } from '@/lib/copilot/tools/shared/schemas' 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 { listSkills } from '@/lib/skills/operations' @@ -104,7 +105,7 @@ export const getAgentAccessoryCatalogServerTool: BaseServerTool< value: { type: 'custom-tool', title: tool.title, - toolId: `custom_${tool.id}`, + toolId: createCustomToolRuntimeId(tool.id), params: {}, isExpanded: true, schema: tool.schema, 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 cd493106e..8b6e3eee5 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 @@ -284,12 +284,13 @@ function buildInputReferenceGrammar( ? { blockSpecificRules: [ { - title: 'Use built-in indicators with full Historical Data output', + title: 'Use available indicators with full Historical Data output', summary: - 'Call built-in indicators with `indicator.(marketSeries)` and pass the full Historical Data output object, not ``. The optional second argument must be an object. Use saved indicator input titles as keys, or pass them under `inputs`. Use `indicator.list()` if the built-in ID is unknown.', + 'Call available indicators with `indicator.(marketSeries)` or `indicator[""](marketSeries)` and pass the full Historical Data output object, not ``. The optional second argument must be an object. Use saved indicator input titles as keys, or pass them under `inputs`. Use `indicator.list()` if the available ID is unknown.', examples: [ 'await indicator.RSI()', 'await indicator.RSI(, { Length: 7 })', + 'await indicator["custom-indicator-id"]()', "await indicator.MACD(, { 'Fast Length': 12, 'Slow Length': 26, 'Signal Length': 9 })", ], }, 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 4e52b9a47..36665ade4 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 @@ -234,9 +234,10 @@ describe('getBlocksMetadataServerTool', () => { expect(result.metadata.function?.inputReferenceGrammar?.blockSpecificRules).toEqual( expect.arrayContaining([ expect.objectContaining({ - title: 'Use built-in indicators with full Historical Data output', + title: 'Use available indicators with full Historical Data output', examples: expect.arrayContaining([ 'await indicator.RSI(, { Length: 7 })', + 'await indicator["custom-indicator-id"]()', ]), }), expect.objectContaining({ diff --git a/apps/tradinggoose/lib/custom-tools/import-export.test.ts b/apps/tradinggoose/lib/custom-tools/import-export.test.ts index 75519aea1..2ea87fc7a 100644 --- a/apps/tradinggoose/lib/custom-tools/import-export.test.ts +++ b/apps/tradinggoose/lib/custom-tools/import-export.test.ts @@ -5,30 +5,36 @@ import { parseImportedCustomToolsFile, resolveImportedCustomTools, } from '@/lib/custom-tools/import-export' +import { + createCustomToolRuntimeId, + getCustomToolEntityIdFromRuntimeId, + parseCustomToolSchemaText, +} from '@/lib/custom-tools/schema' + +const toolSchema = { + type: 'function' as const, + function: { + description: 'Fetch top moving symbols.', + parameters: { + type: 'object' as const, + properties: { + session: { + type: 'string', + }, + }, + required: ['session'], + }, + }, +} describe('custom tools import/export helpers', () => { - it('exports a unified custom-tool file with dense resource arrays', () => { + it('exports a unified custom-tool file with title-only custom-tool identity', () => { const payload = createCustomToolsExportFile({ exportedFrom: 'customToolEditor', customTools: [ { title: 'Fetch Top Movers', - schema: { - type: 'function', - function: { - name: 'fetchTopMovers', - description: 'Fetch top moving symbols.', - parameters: { - type: 'object', - properties: { - session: { - type: 'string', - }, - }, - required: ['session'], - }, - }, - }, + schema: toolSchema, code: 'return { movers: [] }', }, ], @@ -45,22 +51,7 @@ describe('custom tools import/export helpers', () => { customTools: [ { title: 'Fetch Top Movers', - schema: { - type: 'function', - function: { - name: 'fetchTopMovers', - description: 'Fetch top moving symbols.', - parameters: { - type: 'object', - properties: { - session: { - type: 'string', - }, - }, - required: ['session'], - }, - }, - }, + schema: toolSchema, code: 'return { movers: [] }', }, ], @@ -69,6 +60,47 @@ describe('custom tools import/export helpers', () => { }) }) + it('rejects blank custom-tool titles in transfer files', () => { + expect(() => + createCustomToolsExportFile({ + exportedFrom: 'customToolEditor', + customTools: [ + { + title: ' ', + schema: toolSchema, + code: '', + }, + ], + }) + ).toThrow('Tool title is required') + }) + + it('drops function names because custom-tool title is canonical', () => { + expect( + parseCustomToolSchemaText( + JSON.stringify({ + type: 'function', + function: { + name: 'fetchTopMovers', + parameters: { type: 'object', properties: {} }, + }, + }) + ) + ).toEqual({ + type: 'function', + function: { + parameters: { type: 'object', properties: {} }, + }, + }) + }) + + it('round-trips custom-tool runtime IDs from canonical entity IDs', () => { + const runtimeId = createCustomToolRuntimeId('custom_fetch_top_movers') + + expect(runtimeId).toBe('custom_custom_fetch_top_movers') + expect(getCustomToolEntityIdFromRuntimeId(runtimeId)).toBe('custom_fetch_top_movers') + }) + it('serializes unified custom-tool export files as JSON', () => { const payload = exportCustomToolsAsJson({ exportedFrom: 'customToolEditor', @@ -78,8 +110,6 @@ describe('custom tools import/export helpers', () => { schema: { type: 'function', function: { - name: 'fetchTopMovers', - description: 'Fetch top moving symbols.', parameters: { type: 'object', properties: {}, @@ -104,7 +134,10 @@ describe('custom tools import/export helpers', () => { schema: { type: 'function', function: { - name: 'fetchTopMovers', + parameters: { + type: 'object', + properties: {}, + }, }, }, code: 'return { movers: [] }', @@ -131,17 +164,7 @@ describe('custom tools import/export helpers', () => { customTools: [ { title: 'Fetch Top Movers', - schema: { - type: 'function', - function: { - name: 'fetchTopMovers', - description: 'Fetch top moving symbols.', - parameters: { - type: 'object', - properties: {}, - }, - }, - }, + schema: toolSchema, code: 'return { movers: [] }', }, ], @@ -152,17 +175,7 @@ describe('custom tools import/export helpers', () => { expect(parsed.customTools).toEqual([ { title: 'Fetch Top Movers', - schema: { - type: 'function', - function: { - name: 'fetchTopMovers', - description: 'Fetch top moving symbols.', - parameters: { - type: 'object', - properties: {}, - }, - }, - }, + schema: toolSchema, code: 'return { movers: [] }', }, ]) @@ -179,16 +192,7 @@ describe('custom tools import/export helpers', () => { customTools: [ { title: 'Fetch Top Movers', - schema: { - type: 'function', - function: { - name: 'fetchTopMovers', - parameters: { - type: 'object', - properties: {}, - }, - }, - }, + schema: toolSchema, code: 'return { movers: [] }', }, ], @@ -196,34 +200,26 @@ describe('custom tools import/export helpers', () => { ).toThrow() }) - it('renames imported titles when they collide with existing tools', () => { - const result = resolveImportedCustomTools({ - customTools: [ - { - title: 'My Tool', - schema: { - type: 'function', - function: { - name: 'myTool', - parameters: { - type: 'object', - properties: {}, - }, - }, + it('rejects import files with blank custom-tool titles', () => { + expect(() => + parseImportedCustomToolsFile({ + version: '1', + fileType: 'tradingGooseExport', + exportedAt: '2026-04-08T15:30:00.000Z', + exportedFrom: 'customToolEditor', + resourceTypes: ['customTools'], + customTools: [ + { + title: ' ', + schema: toolSchema, + code: '', }, - code: '', - }, - ], - usedTitles: ['My Tool'], - usedFunctionNames: [], - }) - - expect(result.renamedCount).toBe(1) - expect(result.tools[0]?.title).toBe('My Tool (imported) 1') - expect(result.tools[0]?.schema.function.name).toBe('myTool') + ], + }) + ).toThrow('Tool title is required') }) - it('renames imported function names when they collide with existing tools', () => { + it('renames imported titles when they collide with existing tools', () => { const result = resolveImportedCustomTools({ customTools: [ { @@ -231,7 +227,6 @@ describe('custom tools import/export helpers', () => { schema: { type: 'function', function: { - name: 'myTool', parameters: { type: 'object', properties: {}, @@ -241,16 +236,14 @@ describe('custom tools import/export helpers', () => { code: '', }, ], - usedTitles: [], - usedFunctionNames: ['myTool'], + usedTitles: ['My Tool'], }) expect(result.renamedCount).toBe(1) - expect(result.tools[0]?.title).toBe('My Tool') - expect(result.tools[0]?.schema.function.name).toBe('myTool_imported_1') + expect(result.tools[0]?.title).toBe('My Tool (imported) 1') }) - it('renames colliding titles and function names within the imported batch', () => { + it('renames colliding titles within the imported batch', () => { const result = resolveImportedCustomTools({ customTools: [ { @@ -258,7 +251,6 @@ describe('custom tools import/export helpers', () => { schema: { type: 'function', function: { - name: 'myTool', parameters: { type: 'object', properties: {}, @@ -272,7 +264,6 @@ describe('custom tools import/export helpers', () => { schema: { type: 'function', function: { - name: 'myTool', parameters: { type: 'object', properties: {}, @@ -283,46 +274,10 @@ describe('custom tools import/export helpers', () => { }, ], usedTitles: [], - usedFunctionNames: [], }) expect(result.renamedCount).toBe(1) expect(result.tools[0]?.title).toBe('My Tool') - expect(result.tools[0]?.schema.function.name).toBe('myTool') expect(result.tools[1]?.title).toBe('My Tool (imported) 1') - expect(result.tools[1]?.schema.function.name).toBe('myTool_imported_1') - }) - - it('renames both title and function name when a single imported tool collides on both', () => { - const result = resolveImportedCustomTools({ - customTools: [ - { - title: 'My Tool', - schema: { - type: 'function', - function: { - name: 'myTool', - parameters: { - type: 'object', - properties: {}, - }, - }, - }, - code: '', - }, - ], - usedTitles: ['My Tool'], - usedFunctionNames: ['myTool'], - }) - - expect(result.renamedCount).toBe(1) - expect(result.tools[0]).toMatchObject({ - title: 'My Tool (imported) 1', - schema: { - function: { - name: 'myTool_imported_1', - }, - }, - }) }) }) diff --git a/apps/tradinggoose/lib/custom-tools/import-export.ts b/apps/tradinggoose/lib/custom-tools/import-export.ts index 26247bdda..d36b541da 100644 --- a/apps/tradinggoose/lib/custom-tools/import-export.ts +++ b/apps/tradinggoose/lib/custom-tools/import-export.ts @@ -12,7 +12,6 @@ import type { CustomToolDefinition } from '@/stores/custom-tools/types' export type { CustomToolTransferRecord } from '@/lib/custom-tools/schema' const normalizeInlineWhitespace = (value: string) => value.trim().replace(/\s+/g, ' ') -const normalizeFunctionName = (value: string) => value.trim() export const CustomToolsTransferListSchema = z .array(CustomToolTransferSchema) @@ -39,7 +38,6 @@ function normalizeToolForTransfer( schema: { type: 'function', function: { - name: normalizeFunctionName(tool.schema.function.name), description: tool.schema.function.description, parameters: { type: 'object', @@ -63,13 +61,15 @@ export function createCustomToolsExportFile({ customTools: Array> exportedFrom: string }): CustomToolsImportFile { - return createTradingGooseExportFile({ - exportedFrom, - resourceTypes: ['customTools'], - resources: { - customTools: customTools.map(normalizeToolForTransfer), - }, - }) as CustomToolsImportFile + return CustomToolsImportFileSchema.parse( + createTradingGooseExportFile({ + exportedFrom, + resourceTypes: ['customTools'], + resources: { + customTools: customTools.map(normalizeToolForTransfer), + }, + }) + ) as CustomToolsImportFile } export function exportCustomToolsAsJson({ @@ -87,6 +87,10 @@ export function resolveImportedCustomToolTitle( usedTitles: Iterable ): string { const normalizedTitle = normalizeInlineWhitespace(title) + if (!normalizedTitle) { + throw new Error('Custom tool title is required') + } + const usedTitlesSet = new Set(Array.from(usedTitles)) if (!usedTitlesSet.has(normalizedTitle)) { @@ -104,65 +108,28 @@ export function resolveImportedCustomToolTitle( return candidate } -export function resolveImportedCustomToolFunctionName( - functionName: string, - usedFunctionNames: Iterable -): string { - const normalizedName = normalizeFunctionName(functionName) - const usedFunctionNamesSet = new Set(Array.from(usedFunctionNames)) - - if (!usedFunctionNamesSet.has(normalizedName)) { - return normalizedName - } - - let nextNumber = 1 - let candidate = `${normalizedName}_imported_${nextNumber}` - - while (usedFunctionNamesSet.has(candidate)) { - nextNumber += 1 - candidate = `${normalizedName}_imported_${nextNumber}` - } - - return candidate -} - export function resolveImportedCustomTools({ customTools, usedTitles, - usedFunctionNames, }: { customTools: CustomToolTransferRecordType[] usedTitles: Iterable - usedFunctionNames: Iterable }) { const reservedTitles = new Set(Array.from(usedTitles)) - const reservedFunctionNames = new Set(Array.from(usedFunctionNames)) let renamedCount = 0 const resolvedTools = customTools.map((tool) => { const resolvedTitle = resolveImportedCustomToolTitle(tool.title, reservedTitles) - const resolvedFunctionName = resolveImportedCustomToolFunctionName( - tool.schema.function.name, - reservedFunctionNames - ) reservedTitles.add(resolvedTitle) - reservedFunctionNames.add(resolvedFunctionName) - if (resolvedTitle !== tool.title || resolvedFunctionName !== tool.schema.function.name) { + if (resolvedTitle !== tool.title) { renamedCount += 1 } return { ...tool, title: resolvedTitle, - schema: { - ...tool.schema, - function: { - ...tool.schema.function, - name: resolvedFunctionName, - }, - }, } }) diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index ff14da58c..cc65cc8d0 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -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, - savedEntityRowToFields, -} from '@/lib/yjs/entity-state' +import { applySavedEntityYjsStateToRows, savedEntityRowToFields } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' const logger = createLogger('CustomToolsOperations') @@ -58,6 +55,15 @@ export async function upsertCustomTools({ const result = await db.transaction(async (tx) => { 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) + + if (duplicateTitle[0] && duplicateTitle[0].id !== tool.id) { + throw new Error(`A tool with the title "${tool.title}" already exists in this workspace`) + } if (tool.id) { const existingTool = await tx @@ -83,16 +89,6 @@ export async function upsertCustomTools({ } } - const duplicateTitle = await tx - .select() - .from(customTools) - .where(and(eq(customTools.workspaceId, workspaceId), eq(customTools.title, tool.title))) - .limit(1) - - if (duplicateTitle.length > 0) { - throw new Error(`A tool with the title "${tool.title}" already exists in this workspace`) - } - const toolId = tool.id || nanoid() await tx.insert(customTools).values({ id: toolId, @@ -137,32 +133,15 @@ export async function importCustomTools({ const existingTools = await tx .select({ title: customTools.title, - schema: customTools.schema, }) .from(customTools) .where(eq(customTools.workspaceId, workspaceId)) const usedTitles = new Set(existingTools.map((tool) => tool.title)) - const usedFunctionNames = new Set( - existingTools - .map((tool) => - tool.schema && - typeof tool.schema === 'object' && - 'function' in tool.schema && - tool.schema.function && - typeof tool.schema.function === 'object' && - 'name' in tool.schema.function && - typeof tool.schema.function.name === 'string' - ? tool.schema.function.name - : '' - ) - .filter((name): name is string => name.length > 0) - ) const { tools: resolvedTools, renamedCount } = resolveImportedCustomTools({ customTools: tools, usedTitles, - usedFunctionNames, }) const nowTime = new Date() diff --git a/apps/tradinggoose/lib/custom-tools/schema.ts b/apps/tradinggoose/lib/custom-tools/schema.ts index 5021b5c82..2e8be0383 100644 --- a/apps/tradinggoose/lib/custom-tools/schema.ts +++ b/apps/tradinggoose/lib/custom-tools/schema.ts @@ -1,7 +1,48 @@ import { z } from 'zod' +export const CUSTOM_TOOL_RUNTIME_ID_PREFIX = 'custom_' const normalizeInlineWhitespace = (value: string) => value.trim().replace(/\s+/g, ' ') -const normalizeFunctionName = (value: string) => value.trim() +const normalizeOptionalInlineWhitespace = (value: unknown) => + typeof value === 'string' ? normalizeInlineWhitespace(value) : '' + +export function createCustomToolRuntimeId(entityId: string): string { + const normalizedEntityId = entityId.trim() + if (!normalizedEntityId) throw new Error('Custom tool entity id is required') + return `${CUSTOM_TOOL_RUNTIME_ID_PREFIX}${normalizedEntityId}` +} + +export const isCustomToolRuntimeId = (toolId: string | null | undefined): toolId is string => + typeof toolId === 'string' && toolId.startsWith(CUSTOM_TOOL_RUNTIME_ID_PREFIX) + +export function getCustomToolEntityIdFromRuntimeId(runtimeId: string | null | undefined): string { + if ( + typeof runtimeId !== 'string' || + runtimeId !== runtimeId.trim() || + !isCustomToolRuntimeId(runtimeId) + ) { + throw new Error('Custom tool runtime id is required') + } + + const entityId = runtimeId.slice(CUSTOM_TOOL_RUNTIME_ID_PREFIX.length) + if (!entityId) throw new Error('Custom tool entity id is required') + return entityId +} + +export function buildCustomToolModelDescription({ + title, + description, +}: { + title?: string | null + description?: string | null +}): string { + const normalizedTitle = normalizeOptionalInlineWhitespace(title) + return [ + normalizedTitle ? `Custom tool title: ${normalizedTitle}` : '', + normalizeOptionalInlineWhitespace(description), + ] + .filter(Boolean) + .join('. ') +} export const CustomToolParametersSchema = z.object({ type: z.literal('object'), @@ -10,10 +51,6 @@ export const CustomToolParametersSchema = z.object({ }) export const CustomToolFunctionSchema = z.object({ - name: z - .string() - .transform(normalizeFunctionName) - .pipe(z.string().min(1, 'Function name is required')), description: z.string().optional(), parameters: CustomToolParametersSchema, }) @@ -41,7 +78,10 @@ export const CustomToolUpsertRequestSchema = z.object({ tools: z.array( z.object({ id: z.string().optional(), - title: z.string().min(1, 'Tool title is required'), + title: z + .string() + .transform(normalizeInlineWhitespace) + .pipe(z.string().min(1, 'Tool title is required')), schema: CustomToolOpenAiSchema, code: z.string(), }) diff --git a/apps/tradinggoose/lib/function/execution.ts b/apps/tradinggoose/lib/function/execution.ts index 1ddb9593a..b197a51c1 100644 --- a/apps/tradinggoose/lib/function/execution.ts +++ b/apps/tradinggoose/lib/function/execution.ts @@ -10,6 +10,8 @@ import { getLocalVmSaturationLimitMessage, isLocalVmSaturationLimitError, } from '@/lib/execution/local-saturation-limit' +import { listCustomIndicatorRuntimeEntries } from '@/lib/indicators/custom/operations' +import { DEFAULT_INDICATOR_RUNTIME_ENTRIES } from '@/lib/indicators/default/runtime' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { resolveCodeVariables } from '@/app/api/function/code-resolution' @@ -35,8 +37,8 @@ export type FunctionExecutionPayload = { blockData?: Record blockNameMapping?: Record workflowVariables?: Record - workflowId?: string - workspaceId?: string + workflowId?: string | null + workspaceId: string isCustomTool?: boolean } @@ -146,6 +148,16 @@ export async function executeFunctionRequest( const executionParams = { ...params } executionParams._context = undefined + const indicatorRuntimeManifest = { + indicators: [ + ...DEFAULT_INDICATOR_RUNTIME_ENTRIES.map(({ id, pineCode, inputMeta }) => ({ + id, + pineCode, + inputMeta, + })), + ...(await listCustomIndicatorRuntimeEntries(workspaceId)), + ], + } logger.info(`[${requestId}] Function execution request`, { hasCode: !!code, @@ -182,6 +194,7 @@ export async function executeFunctionRequest( executionParams, envVars, contextVariables, + indicatorRuntimeManifest, onImportExtractionError: (error) => { logger.error('Failed to extract JavaScript imports', { error }) }, diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index a0e811c07..a6ac38913 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -6,16 +6,28 @@ import { type IndicatorTransferRecord, resolveImportedIndicatorName, } from '@/lib/indicators/import-export' +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 { applySavedEntityYjsStateToRows, savedEntityRowToFields } from '@/lib/yjs/entity-state' import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' const logger = createLogger('IndicatorsOperations') +export async function listCustomIndicatorRuntimeEntries(workspaceId: string) { + const rows = await db + .select() + .from(pineIndicators) + .where(eq(pineIndicators.workspaceId, workspaceId)) + .then((indicatorRows) => applySavedEntityYjsStateToRows('indicator', indicatorRows)) + + return rows.map(({ id, pineCode, inputMeta }) => ({ + id, + pineCode, + inputMeta: normalizeInputMetaMap(inputMeta), + })) +} + interface UpsertIndicatorsParams { indicators: Array<{ id?: string 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 -} diff --git a/apps/tradinggoose/lib/indicators/execution/e2b-script-builder.ts b/apps/tradinggoose/lib/indicators/execution/e2b-script-builder.ts index 014d2dad4..b03b427ca 100644 --- a/apps/tradinggoose/lib/indicators/execution/e2b-script-builder.ts +++ b/apps/tradinggoose/lib/indicators/execution/e2b-script-builder.ts @@ -1,15 +1,11 @@ -import type { DefaultIndicatorRuntimeEntry } from '@/lib/indicators/default/runtime' import type { BarMs } from '@/lib/indicators/types' import type { ListingIdentity } from '@/lib/listing/identity' import { FUNCTION_INDICATOR_INVALID_OPTIONS_MESSAGE, FUNCTION_INDICATOR_MARKET_SERIES_ERROR_PREFIX, + type FunctionIndicatorRuntimeManifest, } from './function-indicator-runtime' -type IndicatorRuntimeManifest = { - indicators: DefaultIndicatorRuntimeEntry[] -} - const encodeJsonParse = (value: unknown) => JSON.stringify(JSON.stringify(value)) const buildPineTSE2BExecutorCoreSource = () => @@ -291,7 +287,7 @@ export const buildPineTSFunctionIndicatorRuntimePrologue = ({ manifest, usageHint, }: { - manifest: IndicatorRuntimeManifest + manifest: FunctionIndicatorRuntimeManifest usageHint: string }) => { const manifestPayload = encodeJsonParse(manifest) @@ -308,7 +304,6 @@ ${buildPineTSE2BExecutorCoreSource()} const indicator = (() => { const indicators = Array.isArray(__tg_indicator_manifest?.indicators) ? __tg_indicator_manifest.indicators : []; const indicatorById = new Map(indicators.map((entry) => [entry.id, entry])); - const indicatorIds = indicators.map((entry) => entry.id); const toTrimmedString = (value) => (typeof value === 'string' ? value.trim() : ''); const resolveEntry = (alias) => { const key = toTrimmedString(alias); @@ -398,7 +393,6 @@ const indicator = (() => { const indicatorState = context?.indicator && typeof context.indicator === 'object' ? context.indicator : {}; return { indicatorId: indicatorEntry.id, - indicatorName: indicatorEntry.name, plots, indicator: indicatorState, }; @@ -407,7 +401,7 @@ const indicator = (() => { throw new Error('indicator.' + indicatorEntry.id + ' failed: ' + message); } }; - const api = { list: () => [...indicatorIds] }; + const api = { list: () => indicators.map((entry) => entry.id) }; return new Proxy(api, { get(target, prop) { if (prop === 'list') return target.list; diff --git a/apps/tradinggoose/lib/indicators/execution/function-indicator-runtime.ts b/apps/tradinggoose/lib/indicators/execution/function-indicator-runtime.ts index dac7328df..748b24d89 100644 --- a/apps/tradinggoose/lib/indicators/execution/function-indicator-runtime.ts +++ b/apps/tradinggoose/lib/indicators/execution/function-indicator-runtime.ts @@ -1,17 +1,24 @@ -import { - DEFAULT_INDICATOR_RUNTIME_IDS, - resolveDefaultIndicatorRuntimeEntry, -} from '@/lib/indicators/default/runtime' import { buildInputsMapFromMeta } from '@/lib/indicators/input-meta' import { mapMarketSeriesToBarsMs } from '@/lib/indicators/series-data' +import type { InputMetaMap } from '@/lib/indicators/types' import type { MarketSeries } from '@/providers/market/types' import { executeIndicatorInLocalVm } from './local-executor' +export type FunctionIndicatorRuntimeEntry = { + id: string + pineCode: string + inputMeta?: InputMetaMap +} + +export type FunctionIndicatorRuntimeManifest = { + indicators: FunctionIndicatorRuntimeEntry[] +} + export const FUNCTION_INDICATOR_USAGE_HINT = - 'Use indicator.(marketSeries) with MarketSeries output from the Historical Data block (e.g. indicator.RSI()).' + 'Use indicator.(marketSeries) or indicator[""](marketSeries) with MarketSeries output from the Historical Data block (e.g. indicator.RSI()).' export const FUNCTION_INDICATOR_INVALID_OPTIONS_MESSAGE = - 'Indicator options must be an object. Use indicator.(marketSeries, { Length: 7 }) or indicator.(marketSeries, { inputs: { ... } }).' + 'Indicator options must be an object. Use indicator.(marketSeries, { Length: 7 }) or indicator[""](marketSeries, { inputs: { ... } }).' export const FUNCTION_INDICATOR_MARKET_SERIES_ERROR_PREFIX = 'Indicator runtime expects MarketSeries data from Historical Data block.' @@ -53,19 +60,21 @@ const parseMarketSeriesForIndicator = (input: unknown): MarketSeries => { const executeFunctionIndicator = async ({ alias, + manifest, marketSeriesInput, rawOptions, requestId, onWarn, }: { alias: string + manifest: FunctionIndicatorRuntimeManifest marketSeriesInput: unknown rawOptions?: unknown requestId: string onWarn: (message: string, meta: Record) => void }) => { const aliasKey = alias.trim() - const entry = resolveDefaultIndicatorRuntimeEntry(aliasKey) + const entry = manifest.indicators.find((indicatorEntry) => indicatorEntry.id === aliasKey) if (!entry) { throw new Error(`Unknown indicator "${aliasKey || alias}".`) } @@ -93,7 +102,6 @@ const executeFunctionIndicator = async ({ return { indicatorId: entry.id, - indicatorName: entry.name, plots, indicator: indicatorMeta, } @@ -105,14 +113,16 @@ const executeFunctionIndicator = async ({ } export const createFunctionIndicatorRuntime = ({ + manifest, requestId, onWarn, }: { + manifest: FunctionIndicatorRuntimeManifest requestId: string onWarn: (message: string, meta: Record) => void }) => { const runtime: Record = { - list: () => [...DEFAULT_INDICATOR_RUNTIME_IDS], + list: () => manifest.indicators.map((entry) => entry.id), } return new Proxy(runtime, { @@ -122,6 +132,7 @@ export const createFunctionIndicatorRuntime = ({ return (marketSeriesInput: unknown, rawOptions?: unknown) => executeFunctionIndicator({ alias: prop, + manifest, marketSeriesInput, rawOptions, requestId, diff --git a/apps/tradinggoose/lib/logs/execution/trace-spans/trace-spans.test.ts b/apps/tradinggoose/lib/logs/execution/trace-spans/trace-spans.test.ts index 6e26ad6d2..68763f5ec 100644 --- a/apps/tradinggoose/lib/logs/execution/trace-spans/trace-spans.test.ts +++ b/apps/tradinggoose/lib/logs/execution/trace-spans/trace-spans.test.ts @@ -1,8 +1,5 @@ import { describe, expect, test } from 'vitest' -import { - buildTraceSpans, - stripCustomToolPrefix, -} from '@/lib/logs/execution/trace-spans/trace-spans' +import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import type { ExecutionResult } from '@/executor/types' describe('buildTraceSpans', () => { @@ -62,7 +59,8 @@ describe('buildTraceSpans', () => { toolCalls: { list: [ { - name: 'custom_test_tool', + id: 'custom_test_tool', + name: 'Test Tool', arguments: { input: 'test input' }, result: { output: 'test output' }, duration: 2000, @@ -99,7 +97,7 @@ describe('buildTraceSpans', () => { expect(segments[0].status).toBe('success') // Second segment: First tool call - expect(segments[1].name).toBe('test_tool') // custom_ prefix should be stripped + expect(segments[1].name).toBe('Test Tool') expect(segments[1].type).toBe('tool') expect(segments[1].duration).toBe(2000) expect(segments[1].status).toBe('success') @@ -180,7 +178,7 @@ describe('buildTraceSpans', () => { // Check first tool call const firstToolCall = agentSpan.toolCalls![0] - expect(firstToolCall.name).toBe('test_tool') // custom_ prefix should be stripped + expect(firstToolCall.name).toBe('custom_test_tool') expect(firstToolCall.duration).toBe(1000) expect(firstToolCall.status).toBe('success') expect(firstToolCall.input).toEqual({ input: 'test input' }) @@ -383,7 +381,8 @@ describe('buildTraceSpans', () => { duration: 2500, }, { - name: 'custom_analysis_tool', + id: 'custom_analysis_tool', + name: 'Analysis Tool', arguments: { data: 'tennis data', mode: 'comprehensive' }, result: { analysis: 'Detailed tennis analysis', confidence: 0.95 }, duration: 4000, @@ -436,8 +435,8 @@ describe('buildTraceSpans', () => { results: [{ title: 'Tennis News 1' }, { title: 'Tennis News 2' }], }) - // 3. Second tool call - analysis_tool (custom_ prefix stripped) - expect(segments[2].name).toBe('analysis_tool') + // 3. Second tool call - custom tool display name from toolCalls metadata + expect(segments[2].name).toBe('Analysis Tool') expect(segments[2].type).toBe('tool') expect(segments[2].duration).toBe(4000) expect(segments[2].status).toBe('success') @@ -664,16 +663,3 @@ describe('buildTraceSpans', () => { expect((functionSpan?.output as { error?: string })?.error).toContain('Syntax Error') }) }) - -describe('stripCustomToolPrefix', () => { - test('should strip custom_ prefix from tool names', () => { - expect(stripCustomToolPrefix('custom_test_tool')).toBe('test_tool') - expect(stripCustomToolPrefix('custom_analysis')).toBe('analysis') - }) - - test('should leave non-custom tool names unchanged', () => { - expect(stripCustomToolPrefix('http_request')).toBe('http_request') - expect(stripCustomToolPrefix('serper_search')).toBe('serper_search') - expect(stripCustomToolPrefix('regular_tool')).toBe('regular_tool') - }) -}) diff --git a/apps/tradinggoose/lib/logs/execution/trace-spans/trace-spans.ts b/apps/tradinggoose/lib/logs/execution/trace-spans/trace-spans.ts index b3c5f9645..fbf370eef 100644 --- a/apps/tradinggoose/lib/logs/execution/trace-spans/trace-spans.ts +++ b/apps/tradinggoose/lib/logs/execution/trace-spans/trace-spans.ts @@ -211,13 +211,13 @@ export function buildTraceSpans(result: ExecutionResult): { if (segment.type === 'tool') { const matchingToolCall = toolCallsData.find( - (tc: { name?: string; [key: string]: unknown }) => - tc.name === segment.name || stripCustomToolPrefix(tc.name || '') === segment.name + (tc: { id?: string; name?: string; [key: string]: unknown }) => + tc.id === segment.name || tc.name === segment.name ) return { id: `${span.id}-segment-${index}`, - name: stripCustomToolPrefix(segment.name || ''), + name: matchingToolCall?.name || segment.name, type: 'tool', duration: segment.duration, startTime: segmentStartTime, @@ -267,6 +267,7 @@ export function buildTraceSpans(result: ExecutionResult): { const processedToolCalls: ToolCall[] = [] for (const tc of toolCallsList as Array<{ + id?: string name?: string duration?: number startTime?: string @@ -281,7 +282,8 @@ export function buildTraceSpans(result: ExecutionResult): { try { const toolCall: ToolCall = { - name: stripCustomToolPrefix(tc.name || 'unnamed-tool'), + id: typeof tc.id === 'string' ? tc.id : undefined, + name: tc.name || 'unnamed-tool', duration: tc.duration || 0, startTime: tc.startTime || log.startedAt, endTime: tc.endTime || log.endedAt, @@ -694,7 +696,3 @@ function ensureNestedWorkflowsProcessed(span: TraceSpan): TraceSpan { return processedSpan } - -export function stripCustomToolPrefix(name: string) { - return name.startsWith('custom_') ? name.replace('custom_', '') : name -} diff --git a/apps/tradinggoose/lib/logs/types.ts b/apps/tradinggoose/lib/logs/types.ts index 0c041b0bd..0beaf28ba 100644 --- a/apps/tradinggoose/lib/logs/types.ts +++ b/apps/tradinggoose/lib/logs/types.ts @@ -30,6 +30,7 @@ export interface CostBreakdown { } export interface ToolCall { + id?: string name: string duration: number startTime: string diff --git a/apps/tradinggoose/lib/workflows/custom-tools-persistence.ts b/apps/tradinggoose/lib/workflows/custom-tools-persistence.ts index 062fa428a..b3229b0d1 100644 --- a/apps/tradinggoose/lib/workflows/custom-tools-persistence.ts +++ b/apps/tradinggoose/lib/workflows/custom-tools-persistence.ts @@ -1,4 +1,5 @@ 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') @@ -7,10 +8,9 @@ interface CustomTool { id?: string type: 'custom-tool' title: string - toolId?: string + toolId: string schema: { function: { - name?: string description: string parameters: Record } @@ -68,11 +68,11 @@ export function extractCustomToolsFromWorkflowState(workflowState: any): CustomT typeof tool === 'object' && tool.type === 'custom-tool' && tool.title && + tool.toolId && tool.schema?.function && tool.code ) { - // Use toolId if available, otherwise generate one from title - const toolKey = tool.toolId || tool.title + const toolKey = tool.toolId // Deduplicate by toolKey (if same tool appears in multiple blocks) if (!customToolsMap.has(toolKey)) { @@ -107,21 +107,9 @@ export async function persistCustomToolsToDatabase( } const errors: string[] = [] - const validTools = customToolsList.filter((tool) => { - if (!tool.schema?.function?.name) { - logger.warn(`Skipping custom tool without function name: ${tool.title}`) - return false - } - return true - }) - - if (validTools.length === 0) { - return { saved: 0, errors: [] } - } - try { await upsertCustomTools({ - tools: validTools.map((tool) => ({ + tools: customToolsList.map((tool) => ({ id: normalizeToolId(tool), title: tool.title, schema: tool.schema, @@ -131,8 +119,8 @@ export async function persistCustomToolsToDatabase( userId, }) - logger.info(`Persisted ${validTools.length} custom tool(s)`, { workspaceId }) - return { saved: validTools.length, errors } + 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 }) @@ -142,10 +130,7 @@ export async function persistCustomToolsToDatabase( } function normalizeToolId(tool: CustomTool): string { - if (tool.toolId) { - return tool.toolId.startsWith('custom_') ? tool.toolId.replace('custom_', '') : tool.toolId - } - return tool.title + return getCustomToolEntityIdFromRuntimeId(tool.toolId) } /** diff --git a/apps/tradinggoose/lib/workflows/utils.ts b/apps/tradinggoose/lib/workflows/utils.ts index 1cb6f6005..493816cae 100644 --- a/apps/tradinggoose/lib/workflows/utils.ts +++ b/apps/tradinggoose/lib/workflows/utils.ts @@ -510,10 +510,6 @@ export function hasWorkflowChanged( return false } -export function stripCustomToolPrefix(name: string) { - return name.startsWith('custom_') ? name.replace('custom_', '') : name -} - export const workflowHasResponseBlock = (executionResult: ExecutionResult): boolean => { const hasResponseBlockLog = executionResult.logs?.some((log) => log.success && log.blockType === 'response') === true diff --git a/apps/tradinggoose/lib/workflows/validation.test.ts b/apps/tradinggoose/lib/workflows/validation.test.ts new file mode 100644 index 000000000..3828ce59a --- /dev/null +++ b/apps/tradinggoose/lib/workflows/validation.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { sanitizeAgentToolsInBlocks } from './validation' + +const tool = (toolId?: string) => ({ + type: 'custom-tool', + ...(toolId ? { toolId } : {}), + schema: { function: { parameters: { type: 'object', properties: {} } } }, + code: '', +}) + +describe('sanitizeAgentToolsInBlocks', () => { + it('removes agent custom tools without canonical runtime tool ids', () => { + const { blocks, warnings } = sanitizeAgentToolsInBlocks({ + agent_1: { + type: 'agent', + name: 'Agent', + subBlocks: { + tools: { + value: [tool('custom_tool-1'), tool(), tool('tool-2')], + }, + }, + }, + }) + + expect(warnings).toEqual(['Block Agent: removed 2 invalid tool(s)']) + expect(blocks.agent_1.subBlocks.tools.value).toEqual([ + { ...tool('custom_tool-1'), usageControl: 'auto' }, + ]) + }) +}) diff --git a/apps/tradinggoose/lib/workflows/validation.ts b/apps/tradinggoose/lib/workflows/validation.ts index 0e0c21271..98b7714fe 100644 --- a/apps/tradinggoose/lib/workflows/validation.ts +++ b/apps/tradinggoose/lib/workflows/validation.ts @@ -1,3 +1,7 @@ +import { + getCustomToolEntityIdFromRuntimeId, + isCustomToolRuntimeId, +} from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { getBlock } from '@/blocks/registry' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -5,16 +9,16 @@ import { getTool } from '@/tools/utils' const logger = createLogger('WorkflowValidation') -function isValidCustomToolSchema(tool: any): boolean { +function isValidAgentCustomTool(tool: any): boolean { try { if (!tool || typeof tool !== 'object') return false - if (tool.type !== 'custom-tool') return true // non-custom tools are validated elsewhere + if (tool.type !== 'custom-tool') return false + getCustomToolEntityIdFromRuntimeId(tool.toolId) const schema = tool.schema if (!schema || typeof schema !== 'object') return false const fn = schema.function if (!fn || typeof fn !== 'object') return false - if (!fn.name || typeof fn.name !== 'string') return false const params = fn.parameters if (!params || typeof params !== 'object') return false @@ -57,7 +61,7 @@ export function sanitizeAgentToolsInBlocks(blocks: Record): { // Allow non-custom tools to pass through as-is if (!tool || typeof tool !== 'object') return false if (tool.type !== 'custom-tool') return true - const ok = isValidCustomToolSchema(tool) + const ok = isValidAgentCustomTool(tool) if (!ok) { logger.warn('Removing invalid custom tool from workflow', { blockId, @@ -254,7 +258,7 @@ export function validateToolReference( if (!toolId) return null // Check if it's a custom tool or MCP tool - const isCustomTool = toolId.startsWith('custom_') + const isCustomTool = isCustomToolRuntimeId(toolId) const isMcpTool = toolId.startsWith('mcp-') if (!isCustomTool && !isMcpTool) { diff --git a/apps/tradinggoose/providers/ai/types.ts b/apps/tradinggoose/providers/ai/types.ts index b5aa772bf..42c4f5190 100644 --- a/apps/tradinggoose/providers/ai/types.ts +++ b/apps/tradinggoose/providers/ai/types.ts @@ -60,6 +60,7 @@ export interface ProviderConfig { } export interface FunctionCallResponse { + id?: string name: string arguments: Record startTime?: string diff --git a/apps/tradinggoose/providers/ai/utils.test.ts b/apps/tradinggoose/providers/ai/utils.test.ts index 9b8a2f957..187154a53 100644 --- a/apps/tradinggoose/providers/ai/utils.test.ts +++ b/apps/tradinggoose/providers/ai/utils.test.ts @@ -681,9 +681,9 @@ describe('Tool Management', () => { it.concurrent('should transform valid custom tool schema', () => { const customTool = { id: 'test-tool', + title: 'Test Tool', schema: { function: { - name: 'testFunction', description: 'A test function', parameters: { type: 'object', @@ -699,13 +699,31 @@ describe('Tool Management', () => { const result = transformCustomTool(customTool) expect(result.id).toBe('custom_test-tool') - expect(result.name).toBe('testFunction') - expect(result.description).toBe('A test function') + expect(result.name).toBe('Test Tool') + expect(result.description).toBe('Custom tool title: Test Tool. A test function') expect(result.parameters.type).toBe('object') expect(result.parameters.properties).toBeDefined() expect(result.parameters.required).toEqual(['input']) }) + it.concurrent('should preserve entity IDs that already start with the runtime prefix', () => { + const result = transformCustomTool({ + id: 'custom_test-tool', + title: 'Test Tool', + schema: { + function: { + parameters: { + type: 'object', + properties: {}, + }, + }, + }, + }) + + expect(result.id).toBe('custom_custom_test-tool') + expect(result.name).toBe('Test Tool') + }) + it.concurrent('should throw error for invalid schema', () => { const invalidTool = { id: 'test', schema: null } expect(() => transformCustomTool(invalidTool)).toThrow('Invalid custom tool schema') diff --git a/apps/tradinggoose/providers/ai/utils.ts b/apps/tradinggoose/providers/ai/utils.ts index d2cc92c85..aa5268b3e 100644 --- a/apps/tradinggoose/providers/ai/utils.ts +++ b/apps/tradinggoose/providers/ai/utils.ts @@ -1,5 +1,10 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' +import { + buildCustomToolModelDescription, + createCustomToolRuntimeId, + isCustomToolRuntimeId, +} from '@/lib/custom-tools/schema' import { getEnv, isTruthy } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import type { ExecutionSubmissionSource } from '@/executor/types' @@ -331,10 +336,14 @@ export function transformCustomTool(customTool: any): ProviderToolConfig { throw new Error('Invalid custom tool schema') } + const toolId = createCustomToolRuntimeId(customTool.id) return { - id: `custom_${customTool.id}`, // Prefix with 'custom_' to identify custom tools - name: schema.function.name, - description: schema.function.description || '', + id: toolId, + name: customTool.title.trim(), + description: buildCustomToolModelDescription({ + title: customTool.title, + description: schema.function.description, + }), params: {}, // This will be derived from parameters parameters: { type: schema.function.parameters.type, @@ -418,7 +427,7 @@ export async function transformBlockTool( // Get the tool config - check if it's a custom tool that needs async fetching let toolConfig: any - if (toolId.startsWith('custom_') && getToolAsync) { + if (isCustomToolRuntimeId(toolId) && getToolAsync) { // Use the async version for custom tools toolConfig = await getToolAsync(toolId) } else { diff --git a/apps/tradinggoose/stores/copilot/store.ts b/apps/tradinggoose/stores/copilot/store.ts index ae8c0d4e0..e756aff7d 100644 --- a/apps/tradinggoose/stores/copilot/store.ts +++ b/apps/tradinggoose/stores/copilot/store.ts @@ -1127,11 +1127,10 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) set((state) => ({ messages: state.messages.map((msg) => msg.id === assistantMessageId - ? { - ...msg, - content: finalContent, - contentBlocks: context.contentBlocks, - } + ? (normalizeMessagesForUI( + [{ ...msg, content: finalContent, contentBlocks: context.contentBlocks }], + context.latestTurnStatus + )[0] ?? msg) : msg ), })) diff --git a/apps/tradinggoose/stores/custom-tools/types.ts b/apps/tradinggoose/stores/custom-tools/types.ts index 76eb98c5c..1199e9a4d 100644 --- a/apps/tradinggoose/stores/custom-tools/types.ts +++ b/apps/tradinggoose/stores/custom-tools/types.ts @@ -1,7 +1,6 @@ export interface CustomToolSchema { type: string function: { - name: string description?: string parameters: { type: string diff --git a/apps/tradinggoose/tools/function/execute.test.ts b/apps/tradinggoose/tools/function/execute.test.ts index 155b594e9..befaa37cf 100644 --- a/apps/tradinggoose/tools/function/execute.test.ts +++ b/apps/tradinggoose/tools/function/execute.test.ts @@ -45,7 +45,6 @@ describe('Function Execute Tool', () => { envVars: {}, isCustomTool: false, timeout: 5000, - workflowId: undefined, }) expect(body).toEqual({ @@ -57,7 +56,6 @@ describe('Function Execute Tool', () => { blockOutputSchemas: {}, isCustomTool: false, timeout: 5000, - workflowId: undefined, userId: undefined, }) }) @@ -72,7 +70,6 @@ describe('Function Execute Tool', () => { envVars: {}, isCustomTool: false, timeout: 10000, - workflowId: undefined, }) expect(body).toEqual({ @@ -84,7 +81,6 @@ describe('Function Execute Tool', () => { blockNameMapping: {}, blockOutputSchemas: {}, isCustomTool: false, - workflowId: undefined, userId: undefined, }) }) @@ -103,7 +99,6 @@ describe('Function Execute Tool', () => { blockNameMapping: {}, blockOutputSchemas: {}, isCustomTool: false, - workflowId: undefined, userId: undefined, }) }) diff --git a/apps/tradinggoose/tools/function/execute.ts b/apps/tradinggoose/tools/function/execute.ts index b77a19a9b..c5582a659 100644 --- a/apps/tradinggoose/tools/function/execute.ts +++ b/apps/tradinggoose/tools/function/execute.ts @@ -6,7 +6,7 @@ export const functionExecuteTool: ToolConfig(marketSeries) with full Historical Data output and an optional object of input-title overrides. Do not define indicator(...) or import pinets/PineTS in this block.', + 'Execute TypeScript code. fetch() is available. Code runs in async IIFE wrapper automatically after TypeScript transpiles to JavaScript. CRITICAL: Write plain statements with await/return, NOT wrapped in functions. For available indicators, use indicator.(marketSeries) or indicator[""](marketSeries) with full Historical Data output and an optional object of input-title overrides. Do not define indicator(...) or import pinets/PineTS in this block.', version: '1.0.0', params: { @@ -15,7 +15,7 @@ export const functionExecuteTool: ToolConfig. The optional second argument must be an object, for example indicator.RSI(, { Length: 7 }) or indicator.RSI(, { inputs: { Length: 7 } }). Use indicator.list() if the built-in ID is unknown. Do not import pinets or define indicator(...) directly.', + 'Raw TypeScript statements (NOT a function). Code is transpiled to JavaScript and auto-wrapped in async context. MUST use fetch() for HTTP (NOT xhr/axios/request libs). Write like: await fetch(url) then return result. Imports require E2B runtime support. For available indicators, pass the full Historical Data MarketSeries object, not a scalar series like . The optional second argument must be an object, for example indicator.RSI(, { Length: 7 }) or indicator["custom-indicator-id"](, { inputs: { Length: 7 } }). Use indicator.list() if the available ID is unknown. Do not import pinets or define indicator(...) directly.', }, timeout: { type: 'number', @@ -71,6 +71,8 @@ export const functionExecuteTool: ToolConfig c.content).join('\n') : params.code + const workflowId = params._context?.workflowId + const workspaceId = params._context?.workspaceId return { code: codeContent, @@ -80,14 +82,8 @@ export const functionExecuteTool: ToolConfig { it('should load skill content from workspace storage', async () => { const skillLoaderTool = buildLoadSkillTool('tradinggoose_internal_load_skill', [ - 'market-research', + { + id: 'skill-1', + name: 'market-research', + description: 'Research the market before acting', + }, ]) const skillRows = [ { + id: 'skill-1', name: 'market-research', content: 'Investigate the market and summarize the setup.', }, @@ -539,7 +544,7 @@ describe('executeTool Function', () => { skillLoaderTool.id, { ...skillLoaderTool.params, - skill_name: 'market-research', + skill_id: 'skill-1', }, false, createMockExecutionContext() diff --git a/apps/tradinggoose/tools/index.ts b/apps/tradinggoose/tools/index.ts index 0a0b37649..577a57088 100644 --- a/apps/tradinggoose/tools/index.ts +++ b/apps/tradinggoose/tools/index.ts @@ -1,4 +1,5 @@ import { generateInternalToken } from '@/lib/auth/internal' +import { isCustomToolRuntimeId } from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { parseMcpToolId } from '@/lib/mcp/utils' import { validateExternalUrl } from '@/lib/security/input-validation' @@ -261,23 +262,24 @@ export async function executeTool( try { throwIfToolRequestAborted(options?.signal) let tool: ToolConfig | undefined + const isMcpTool = toolId.startsWith('mcp-') if (isSkillLoaderExecution(params)) { - const skillName = typeof params.skill_name === 'string' ? params.skill_name : null - if (!skillName || !scope.workspaceId) { + const skillId = typeof params.skill_id === 'string' ? params.skill_id : null + if (!skillId || !scope.workspaceId) { return { success: false, - output: { error: 'Missing skill_name or workspace context' }, - error: 'Missing skill_name or workspace context', + output: { error: 'Missing skill_id or workspace context' }, + error: 'Missing skill_id or workspace context', } } - const content = await resolveSkillContent(skillName, scope.workspaceId) + const content = await resolveSkillContent(skillId, scope.workspaceId) if (!content) { return { success: false, - output: { error: `Skill "${skillName}" not found` }, - error: `Skill "${skillName}" not found`, + output: { error: `Skill "${skillId}" not found` }, + error: `Skill "${skillId}" not found`, } } @@ -288,12 +290,12 @@ export async function executeTool( } // If it's a custom tool, use the async version with workflowId - if (toolId.startsWith('custom_')) { + if (isCustomToolRuntimeId(toolId)) { tool = await getToolAsync(toolId, scope.workflowId, scope.workspaceId, scope.userId) if (!tool) { logger.error(`[${requestId}] Custom tool not found: ${toolId}`) } - } else if (toolId.startsWith('mcp-')) { + } else if (isMcpTool) { return await executeMcpTool( toolId, params, @@ -675,7 +677,7 @@ async function executeToolRequest( const fullUrl = fullUrlObj.toString() - if (toolId.startsWith('custom_') && tool.request.body) { + if (isCustomToolRuntimeId(toolId) && tool.request.body) { const requestBody = tool.request.body(params) if ( typeof requestBody === 'object' && diff --git a/apps/tradinggoose/tools/utils.test.ts b/apps/tradinggoose/tools/utils.test.ts index 1f17260df..6df949b0b 100644 --- a/apps/tradinggoose/tools/utils.test.ts +++ b/apps/tradinggoose/tools/utils.test.ts @@ -690,7 +690,6 @@ describe('createCustomToolRequestBody', () => { API_KEY: 'mock-api-key', BASE_URL: 'https://example.com', }, - workflowId: undefined, userId: undefined, workflowVariables: {}, blockData: {}, @@ -754,9 +753,9 @@ describe('createCustomToolRequestBody', () => { userId: 'user-1', workflowId: 'workflow-1', workflowLogId: 'log-1', - workspaceId: 'workspace-1', }) ) + expect(result).not.toHaveProperty('workspaceId') }) it('fails closed before fetching custom tools when internal auth generation fails', async () => { diff --git a/apps/tradinggoose/tools/utils.ts b/apps/tradinggoose/tools/utils.ts index 661c6113f..d212ee5a7 100644 --- a/apps/tradinggoose/tools/utils.ts +++ b/apps/tradinggoose/tools/utils.ts @@ -1,3 +1,7 @@ +import { + getCustomToolEntityIdFromRuntimeId, + isCustomToolRuntimeId, +} from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { getBaseUrl } from '@/lib/urls/utils' import { useCustomToolsStore } from '@/stores/custom-tools/store' @@ -271,6 +275,10 @@ export function createCustomToolRequestBody( // Get block data and mapping from params (passed from execution context) const blockData = params.blockData || {} const blockNameMapping = params.blockNameMapping || {} + const scopedWorkflowId = + typeof context.workflowId === 'string' && context.workflowId ? context.workflowId : workflowId + const scopedWorkspaceId = + typeof context.workspaceId === 'string' && context.workspaceId ? context.workspaceId : '' // Include everything needed for execution return { @@ -281,16 +289,12 @@ export function createCustomToolRequestBody( workflowVariables: workflowVariables, // Workflow variables for resolution blockData: blockData, // Runtime block outputs for resolution blockNameMapping: blockNameMapping, // Block name to ID mapping - workflowId: context.workflowId ?? workflowId, // Pass workflowId for server-side context userId: context.userId, // Pass userId for auth context - ...(context.submissionSource === 'workflow' && - typeof context.workflowId === 'string' && - typeof context.executionId === 'string' - ? { usesParentExecutionConcurrencySlot: true } - : {}), - ...(typeof context.workspaceId === 'string' && context.workspaceId - ? { workspaceId: context.workspaceId } - : {}), + ...(scopedWorkflowId + ? { workflowId: scopedWorkflowId } + : scopedWorkspaceId + ? { workspaceId: scopedWorkspaceId } + : {}), ...(typeof context.workflowLogId === 'string' && context.workflowLogId ? { workflowLogId: context.workflowLogId } : {}), @@ -309,10 +313,10 @@ export function getTool(toolId: string): ToolConfig | undefined { if (builtInTool) return builtInTool // Check if it's a custom tool - if (toolId.startsWith('custom_') && typeof window !== 'undefined') { + if (isCustomToolRuntimeId(toolId) && typeof window !== 'undefined') { // Only try to use the sync version on the client const customToolsStore = useCustomToolsStore.getState() - const identifier = toolId.replace('custom_', '') + const identifier = getCustomToolEntityIdFromRuntimeId(toolId) const customTool = customToolsStore.getTool(identifier) @@ -337,7 +341,7 @@ export async function getToolAsync( if (builtInTool) return builtInTool // Check if it's a custom tool - if (toolId.startsWith('custom_')) { + if (isCustomToolRuntimeId(toolId)) { return getCustomTool(toolId, workflowId, workspaceId, userId) } @@ -389,7 +393,7 @@ async function getCustomTool( workspaceId?: string, userId?: string ): Promise { - const identifier = customToolId.replace('custom_', '') + const identifier = getCustomToolEntityIdFromRuntimeId(customToolId) try { const baseUrl = getBaseUrl() diff --git a/apps/tradinggoose/widgets/widgets/_shared/custom_tool/components/custom-tool-list-item.tsx b/apps/tradinggoose/widgets/widgets/_shared/custom_tool/components/custom-tool-list-item.tsx index b51111ea5..1c212c9d6 100644 --- a/apps/tradinggoose/widgets/widgets/_shared/custom_tool/components/custom-tool-list-item.tsx +++ b/apps/tradinggoose/widgets/widgets/_shared/custom_tool/components/custom-tool-list-item.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { Pencil, Trash2, Wrench } from 'lucide-react' -import { useLocale } from 'next-intl' +import { useLocale, useMessages } from 'next-intl' import { AlertDialog, AlertDialogCancel, @@ -14,9 +14,8 @@ 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 type { LocaleCode } from '@/i18n/utils' import { cn } from '@/lib/utils' +import type { LocaleCode } from '@/i18n/utils' import type { CustomToolDefinition } from '@/stores/custom-tools/types' interface CustomToolListItemProps { @@ -29,8 +28,7 @@ interface CustomToolListItemProps { isDeleting?: boolean } -const getCustomToolTitle = (tool: CustomToolDefinition, fallback = '') => - tool.title || tool.schema?.function?.name || fallback +const getCustomToolTitle = (tool: CustomToolDefinition) => tool.title.trim() export function CustomToolListItem({ tool, @@ -45,15 +43,15 @@ export function CustomToolListItem({ const copy = useMessages().workspace.widgets.customToolList.listItem const [isHovered, setIsHovered] = useState(false) const [isEditing, setIsEditing] = useState(false) - const [editValue, setEditValue] = useState(getCustomToolTitle(tool, copy.untitledCustomTool)) + const [editValue, setEditValue] = useState(getCustomToolTitle(tool)) const [isRenaming, setIsRenaming] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const inputRef = useRef(null) - const nameLabel = getCustomToolTitle(tool, copy.untitledCustomTool) + const nameLabel = getCustomToolTitle(tool) useEffect(() => { - setEditValue(getCustomToolTitle(tool, copy.untitledCustomTool)) - }, [copy.untitledCustomTool, tool.title, tool.schema?.function?.name]) + setEditValue(getCustomToolTitle(tool)) + }, [tool.title]) useEffect(() => { if (isEditing && inputRef.current) { @@ -65,7 +63,7 @@ export function CustomToolListItem({ const handleStartEdit = () => { if (!canEdit) return setIsEditing(true) - setEditValue(getCustomToolTitle(tool, copy.untitledCustomTool)) + setEditValue(getCustomToolTitle(tool)) } const handleSaveEdit = async () => { diff --git a/apps/tradinggoose/widgets/widgets/components/custom-tool-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/custom-tool-dropdown.tsx index 7fd9bd92c..d68d93f12 100644 --- a/apps/tradinggoose/widgets/widgets/components/custom-tool-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/custom-tool-dropdown.tsx @@ -2,6 +2,7 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react' import { Check, ChevronDown, Loader2, Search, Wrench } from 'lucide-react' +import { useMessages } from 'next-intl' import { DropdownMenu, DropdownMenuContent, @@ -19,7 +20,6 @@ import { } from '@/components/widget-header-control' import { cn } from '@/lib/utils' import { useCustomTools } from '@/hooks/queries/custom-tools' -import { useMessages } from 'next-intl' import { useCustomToolsStore } from '@/stores/custom-tools/store' import type { CustomToolDefinition } from '@/stores/custom-tools/types' @@ -38,8 +38,7 @@ interface CustomToolDropdownProps { menuClassName?: string } -const getToolTitle = (tool?: CustomToolDefinition | null, fallback = '') => - tool?.title || tool?.schema?.function?.name || fallback +const getToolTitle = (tool?: CustomToolDefinition | null) => tool?.title.trim() ?? '' export function CustomToolDropdown({ workspaceId, @@ -109,7 +108,7 @@ export function CustomToolDropdown({ if (!normalizedQuery) return workspaceTools return workspaceTools.filter((tool) => { - const title = getToolTitle(tool, copy.untitledCustomTool).toLowerCase() + const title = getToolTitle(tool).toLowerCase() const description = tool.schema?.function?.description?.toLowerCase() ?? '' return title.includes(normalizedQuery) || description.includes(normalizedQuery) }) @@ -202,7 +201,7 @@ export function CustomToolDropdown({ /> - {getToolTitle(tool, copy.untitledCustomTool)} + {getToolTitle(tool)}
{isSelected ? : null} @@ -228,7 +227,7 @@ export function CustomToolDropdown({ const labelContent = selectedTool ? ( - {getToolTitle(selectedTool, copy.untitledCustomTool)} + {getToolTitle(selectedTool)} ) : ( diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/markdown-renderer.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/markdown-renderer.tsx index 7b0650379..81af7ee57 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/markdown-renderer.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/copilot-message/components/markdown-renderer.tsx @@ -291,8 +291,13 @@ export default function CopilotMarkdownRenderer({ content }: CopilotMarkdownRend inline, className, children, + node: _node, ...props - }: React.HTMLAttributes & { className?: string; inline?: boolean }) => { + }: React.HTMLAttributes & { + className?: string + inline?: boolean + node?: unknown + }) => { if (inline) { return (
, // Links - a: ({ href, children, ...props }: React.AnchorHTMLAttributes) => ( + a: ({ + href, + children, + node: _node, + ...props + }: React.AnchorHTMLAttributes & { node?: unknown }) => ( {children} diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/copilot/copilot.test.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/copilot/copilot.test.tsx index e97cc03f5..927007d25 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/copilot/copilot.test.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/copilot/copilot.test.tsx @@ -48,6 +48,10 @@ vi.mock('@/lib/logs/console/logger', () => ({ }), })) +vi.mock('@/i18n/workspace-widget-hooks', () => ({ + useWorkspaceWidgetsMessages: () => ({ workflowLabels: {} }), +})) + vi.mock('@/stores/copilot/store', () => ({ useCopilotStore: () => mockStoreState, useCopilotStoreApi: () => ({ diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/copilot/copilot.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/copilot/copilot.tsx index e4bc12493..7f208e624 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/copilot/copilot.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/copilot/copilot.tsx @@ -17,6 +17,7 @@ import type { ReviewTargetDescriptor } from '@/lib/copilot/review-sessions/types import { DEFAULT_COPILOT_RUNTIME_MODEL } from '@/lib/copilot/runtime-models' import { createLogger } from '@/lib/logs/console/logger' import { normalizeOptionalString } from '@/lib/utils' +import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' import { useCopilotStore } from '@/stores/copilot/store' import { hasUiActiveToolCalls } from '@/stores/copilot/store-state' import type { ChatContext, CopilotSendRuntimeContext } from '@/stores/copilot/types' @@ -74,13 +75,21 @@ export const Copilot = forwardRef( const programmaticScrollInFlightRef = useRef(false) const pairContext = usePairColorContext(pairColor) + const entityLabels = useWorkspaceWidgetsMessages().workflowLabels const implicitContexts = useMemo( () => buildImplicitCopilotContexts({ workspaceId, pairContext, + currentLabels: { + workflow: entityLabels.currentWorkflow, + skill: entityLabels.currentSkill, + custom_tool: entityLabels.currentTool, + indicator: entityLabels.currentIndicator, + mcp_server: entityLabels.currentMcpServer, + }, }), - [pairContext, workspaceId] + [entityLabels, pairContext, workspaceId] ) const workflowId = resolveCopilotWorkflowId(pairContext) ?? null const liveContext = useMemo( diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/components/mention-menu.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/components/mention-menu.tsx index ebe3c0f19..7d90ef868 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/components/mention-menu.tsx +++ b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/components/mention-menu.tsx @@ -20,21 +20,25 @@ import { import { createPortal } from 'react-dom' import { getIconTileStyle, sanitizeSolidIconColor } from '@/lib/ui/icon-colors' import { cn } from '@/lib/utils' +import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' import { type CopilotWorkspaceEntityKind, - getCopilotWorkspaceEntityKindFromMentionOption, isCopilotWorkspaceEntityMentionOption, } from '../../../workspace-entities' +import { + type CopilotMentionCopy, + getKnowledgeBaseMentionLabel, + getLogMentionTriggerLabel, + getMentionOptionLabel, + getMentionSubmenuTitle, + getPastChatMentionLabel, + getWorkspaceEntityMentionLabel, + useCopilotMentionCopy, +} from '../mention-copy' import { buildAggregatedMentionItems, - filterBlocks, - filterKnowledgeBases, - filterLogs, + filterMentionItems, filterMentionOptions, - filterPastChats, - filterWorkflowBlocks, - filterWorkspaceEntitiesForOption, - getMentionSubmenuTitle, } from '../mention-utils' import type { AggregatedMentionItem, @@ -50,7 +54,6 @@ import type { WorkflowBlockItem, WorkspaceEntityItem, } from '../types' -import { getWorkspaceEntityMentionEmptyState } from '../workspace-entity-mentions' interface MentionMenuProps { inAggregated: boolean @@ -198,42 +201,36 @@ const renderWorkspaceEntityMainOptionIcon = (entityKind: CopilotWorkspaceEntityK const WORKSPACE_ENTITY_ITEM_RENDERERS: Record< CopilotWorkspaceEntityKind, - (entity: WorkspaceEntityItem) => ReactNode + (entity: WorkspaceEntityItem, label: string) => ReactNode > = { - workflow: (entity) => ( + workflow: (entity, label) => ( <> {renderWorkflowBadge(entity.color)} - {entity.name} + {label} ), - skill: (entity) => ( + skill: (entity, label) => ( <> {renderSkillBadge()} - {entity.name} + {label} ), - indicator: (entity) => ( + indicator: (entity, label) => ( <> {renderIndicatorBadge(entity.color)} - {entity.name} + {label} ), - custom_tool: (entity) => ( + custom_tool: (entity, label) => ( <> {renderCustomToolBadge()} - {entity.name} - {entity.functionName ? ( - <> - · - {entity.functionName} - - ) : null} + {label} ), - mcp_server: (entity) => ( + mcp_server: (entity, label) => ( <> {renderMcpServerBadge(entity.connectionStatus)} - {entity.name} + {label} {entity.transport ? ( <> · @@ -245,68 +242,74 @@ const WORKSPACE_ENTITY_ITEM_RENDERERS: Record< } const renderMainOptionIcon = (option: MentionOption) => { - if (option === 'Chats') { + if (option === 'chats') { return } if (isCopilotWorkspaceEntityMentionOption(option)) { - return renderWorkspaceEntityMainOptionIcon( - getCopilotWorkspaceEntityKindFromMentionOption(option) - ) + return renderWorkspaceEntityMainOptionIcon(option) } - if (option === 'Blocks') { + if (option === 'blocks') { return } - if (option === 'Workflow Blocks') { + if (option === 'workflow_blocks') { return } - if (option === 'Knowledge') { + if (option === 'knowledge') { return } - if (option === 'Docs') { + if (option === 'docs') { return } - if (option === 'Logs') { + if (option === 'logs') { return } return
} -const renderMentionItemContent = (type: MentionSubmenu, item: MentionItem) => { - if (type === 'Chats') { +const renderMentionItemContent = ( + type: MentionSubmenu, + item: MentionItem, + monitorCopy: ReturnType['copy'], + mentionCopy: CopilotMentionCopy +) => { + if (type === 'chats') { const chat = item as PastChatItem return ( <>
- {chat.title || 'Untitled Chat'} + {getPastChatMentionLabel(mentionCopy, chat)} ) } if (isCopilotWorkspaceEntityMentionOption(type)) { const entity = item as WorkspaceEntityItem - return WORKSPACE_ENTITY_ITEM_RENDERERS[entity.entityKind](entity) + return WORKSPACE_ENTITY_ITEM_RENDERERS[entity.entityKind]( + entity, + getWorkspaceEntityMentionLabel(mentionCopy, entity) + ) } - if (type === 'Knowledge') { + if (type === 'knowledge') { const knowledgeBase = item as KnowledgeBaseItem return ( <> - {knowledgeBase.name || 'Untitled'} + {getKnowledgeBaseMentionLabel(knowledgeBase)} ) } - if (type === 'Blocks') { + if (type === 'blocks') { const block = item as BlockItem return ( <> @@ -316,7 +319,7 @@ const renderMentionItemContent = (type: MentionSubmenu, item: MentionItem) => { ) } - if (type === 'Workflow Blocks') { + if (type === 'workflow_blocks') { const block = item as WorkflowBlockItem return ( <> @@ -326,7 +329,7 @@ const renderMentionItemContent = (type: MentionSubmenu, item: MentionItem) => { ) } - if (type === 'Logs') { + if (type === 'logs') { const log = item as LogItem return ( <> @@ -339,7 +342,7 @@ const renderMentionItemContent = (type: MentionSubmenu, item: MentionItem) => { · {formatTimestamp(log.startedAt)} · - {(log.trigger || 'manual').toLowerCase()} + {getLogMentionTriggerLabel(monitorCopy, log)} ) } @@ -347,64 +350,6 @@ const renderMentionItemContent = (type: MentionSubmenu, item: MentionItem) => { return null } -const getSubmenuItems = ( - submenu: MentionSubmenu, - query: string, - sources: MentionSources -): MentionItem[] => { - if (submenu === 'Chats') { - return filterPastChats(sources.pastChats, query) - } - - if (isCopilotWorkspaceEntityMentionOption(submenu)) { - return filterWorkspaceEntitiesForOption(submenu, sources, query) - } - - if (submenu === 'Knowledge') { - return filterKnowledgeBases(sources.knowledgeBases, query) - } - - if (submenu === 'Blocks') { - return filterBlocks(sources.blocksList, query) - } - - if (submenu === 'Workflow Blocks') { - return filterWorkflowBlocks(sources.workflowBlocks, query) - } - - return filterLogs(sources.logsList, query) -} - -const getSubmenuEmptyState = (submenu: MentionSubmenu) => { - if (submenu === 'Chats') { - return 'No past chats' - } - - if (isCopilotWorkspaceEntityMentionOption(submenu)) { - return getWorkspaceEntityMentionEmptyState( - getCopilotWorkspaceEntityKindFromMentionOption(submenu) - ) - } - - if (submenu === 'Knowledge') { - return 'No knowledge bases' - } - - if (submenu === 'Blocks') { - return 'No blocks found' - } - - if (submenu === 'Workflow Blocks') { - return 'No blocks in this workflow' - } - - return 'No executions found' -} - -const isSubmenuLoading = (submenu: MentionSubmenu, loading: MentionMenuProps['loading']) => { - return loading[submenu] -} - const preserveEditorSelection = (event: MouseEvent) => { event.preventDefault() } @@ -430,14 +375,24 @@ export function MentionMenu({ submenuActiveIndex, submenuQuery, }: MentionMenuProps) { + const mentionCopy = useCopilotMentionCopy() + const { copy: monitorCopy } = useMonitorCopy() + if (!showMentionMenu || !mentionPortalStyle) { return null } - const filteredOptions = filterMentionOptions(mentionQuery) - const aggregatedItems = buildAggregatedMentionItems(mentionQuery, sources) + const filteredOptions = filterMentionOptions(mentionQuery, mentionCopy) + const aggregatedItems = buildAggregatedMentionItems( + mentionQuery, + sources, + monitorCopy, + mentionCopy + ) const showAggregatedSearch = mentionQuery.length > 0 && filteredOptions.length === 0 - const submenuItems = openSubmenuFor ? getSubmenuItems(openSubmenuFor, submenuQuery, sources) : [] + const submenuItems = openSubmenuFor + ? filterMentionItems(openSubmenuFor, sources, submenuQuery, monitorCopy, mentionCopy) + : [] return createPortal(
- {getMentionSubmenuTitle(openSubmenuFor)} + {getMentionSubmenuTitle(mentionCopy, openSubmenuFor)}
- {isSubmenuLoading(openSubmenuFor, loading) ? ( -
Loading...
+ {loading[openSubmenuFor] ? ( +
{mentionCopy.loading}
) : submenuItems.length === 0 ? (
- {getSubmenuEmptyState(openSubmenuFor)} + {mentionCopy.emptyStates[openSubmenuFor]}
) : ( submenuItems.map((item, index) => ( @@ -493,7 +448,7 @@ export function MentionMenu({ onMouseEnter={() => onSubmenuItemHover(index)} onClick={() => onSelectSubmenuItem(openSubmenuFor, item)} > - {renderMentionItemContent(openSubmenuFor, item)} + {renderMentionItemContent(openSubmenuFor, item, monitorCopy, mentionCopy)}
)) )} @@ -502,7 +457,7 @@ export function MentionMenu({ ) : showAggregatedSearch ? (
{aggregatedItems.length === 0 ? ( -
No matches
+
{mentionCopy.noMatches}
) : ( aggregatedItems.map((item, index) => (
onAggregatedItemHover(index)} onClick={() => onSelectAggregatedItem(item)} > - {renderMentionItemContent(item.type, item.value)} + {renderMentionItemContent(item.type, item.value, monitorCopy, mentionCopy)}
)) )} @@ -541,13 +496,9 @@ export function MentionMenu({ >
{renderMainOptionIcon(option)} - - {isCopilotWorkspaceEntityMentionOption(option) - ? getMentionSubmenuTitle(option) - : option} - + {getMentionOptionLabel(mentionCopy, option)}
- {option !== 'Docs' && ( + {option !== 'docs' && ( )}
@@ -556,7 +507,9 @@ export function MentionMenu({ {mentionQuery.length > 0 && aggregatedItems.length > 0 && ( <>
-
Matches
+
+ {mentionCopy.matches} +
{aggregatedItems.map((item, index) => (
onAggregatedItemHover(index)} onClick={() => onSelectAggregatedItem(item)} > - {renderMentionItemContent(item.type, item.value)} + {renderMentionItemContent(item.type, item.value, monitorCopy, mentionCopy)}
))} diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/constants.ts b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/constants.ts index 01d6d3b51..c805464bf 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/constants.ts +++ b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/constants.ts @@ -14,17 +14,17 @@ export const ANTHROPIC_MODELS: readonly CopilotRuntimeModel[] = [ export const OPENAI_MODELS: readonly CopilotRuntimeModel[] = ['gpt-5.4', 'gpt-5.4-mini'] export const MENTION_OPTIONS: readonly MentionOption[] = [ - 'Chats', + 'chats', ...COPILOT_WORKSPACE_ENTITY_MENTION_OPTIONS, - 'Workflow Blocks', - 'Blocks', - 'Knowledge', - 'Docs', - 'Logs', + 'workflow_blocks', + 'blocks', + 'knowledge', + 'docs', + 'logs', ] export const MENTION_SUBMENUS: readonly MentionSubmenu[] = MENTION_OPTIONS.filter( - (option): option is MentionSubmenu => option !== 'Docs' + (option): option is MentionSubmenu => option !== 'docs' ) export const MAX_TEXTAREA_HEIGHT = 120 diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/hooks/use-user-input-mention-sources.ts b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/hooks/use-user-input-mention-sources.ts index 207a3c3c1..d94776462 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/hooks/use-user-input-mention-sources.ts +++ b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/hooks/use-user-input-mention-sources.ts @@ -1,15 +1,20 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useLocale } from 'next-intl' import { createLogger } from '@/lib/logs/console/logger' import { sanitizeSolidIconColor } from '@/lib/ui/icon-colors' import { useWorkflowBlocks } from '@/lib/yjs/use-workflow-doc' import { useOptionalWorkflowSession } from '@/lib/yjs/workflow-session-host' import { fetchKnowledgeBases as fetchWorkspaceKnowledgeBases } from '@/hooks/queries/knowledge' +import { + getLocalizedBlockNameWithCopy, + getLocalizedDefaultBlockNameWithCopy, +} from '@/i18n/workflow-inspector-core' +import { useWorkflowInspectorMessages } from '@/i18n/workspace-widget-hooks' import { getSubflowBlockConfig } from '@/widgets/widgets/editor_workflow/components/subflows/config' import { type CopilotWorkspaceEntityKind, - getCopilotWorkspaceEntityKindFromMentionOption, isCopilotWorkspaceEntityMentionOption, } from '../../../workspace-entities' import type { @@ -49,7 +54,10 @@ const createEmptyWorkspaceEntityLoading = (): Record (typeof value === 'string' ? value.trim() : '') + export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionSourcesOptions) { + const locale = useLocale() const [pastChats, setPastChats] = useState([]) const [isLoadingPastChats, setIsLoadingPastChats] = useState(false) const [workspaceEntities, setWorkspaceEntities] = useState(createEmptyWorkspaceEntities) @@ -67,6 +75,12 @@ export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionS const workflowSession = useOptionalWorkflowSession() const workflowId = workflowSession?.workflowId ?? null const workflowStoreBlocks = useWorkflowBlocks() + const workflowInspectorMessages = useWorkflowInspectorMessages() + const workflowInspectorCopy = useMemo(() => workflowInspectorMessages, [locale]) + const compareLocalizedBlockMentionNames = useCallback( + (left: T, right: T) => left.name.localeCompare(right.name, locale), + [locale] + ) const ensurePastChatsLoaded = useCallback(async () => { if (isLoadingPastChats || pastChats.length > 0) { @@ -92,12 +106,19 @@ export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionS const items = Array.isArray(data?.chats) ? data.chats : [] setPastChats( - items.map((item: any) => ({ - reviewSessionId: item.reviewSessionId, - title: item.title ?? null, - workflowId: null, - updatedAt: item.updatedAt, - })) + items.flatMap((item: any) => { + const title = toTrimmedString(item.title) + return item.reviewSessionId + ? [ + { + reviewSessionId: item.reviewSessionId, + title: title || null, + workflowId: null, + updatedAt: item.updatedAt, + }, + ] + : [] + }) ) } catch { } finally { @@ -139,10 +160,10 @@ export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionS }) setKnowledgeBases( - sorted.map((item: any) => ({ - id: item.id, - name: item.name || 'Untitled', - })) + sorted.flatMap((item: any) => { + const name = toTrimmedString(item.name) + return item.id && name ? [{ id: item.id, name }] : [] + }) ) } catch { } finally { @@ -163,28 +184,28 @@ export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionS .filter((block: any) => !block.hideFromToolbar && block.category === 'blocks') .map((block: any) => ({ id: block.type, - name: block.name || block.type, + name: getLocalizedBlockNameWithCopy(workflowInspectorCopy, block), iconComponent: block.icon, bgColor: sanitizeSolidIconColor(block.bgColor), })) - .sort((a: any, b: any) => a.name.localeCompare(b.name)) + .sort(compareLocalizedBlockMentionNames) const toolBlocks = allBlocks .filter((block: any) => !block.hideFromToolbar && block.category === 'tools') .map((block: any) => ({ id: block.type, - name: block.name || block.type, + name: getLocalizedBlockNameWithCopy(workflowInspectorCopy, block), iconComponent: block.icon, bgColor: sanitizeSolidIconColor(block.bgColor), })) - .sort((a: any, b: any) => a.name.localeCompare(b.name)) + .sort(compareLocalizedBlockMentionNames) setBlocksList([...regularBlocks, ...toolBlocks]) } catch { } finally { setIsLoadingBlocks(false) } - }, [blocksList.length, isLoadingBlocks]) + }, [blocksList.length, compareLocalizedBlockMentionNames, isLoadingBlocks, workflowInspectorCopy]) const ensureLogsLoaded = useCallback(async () => { if (isLoadingLogs || logsList.length > 0) { @@ -226,10 +247,6 @@ export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionS }, [isLoadingLogs, logsList.length, workspaceId]) const ensureWorkflowBlocksLoaded = useCallback(async () => { - if (isLoadingWorkflowBlocks) { - return - } - if (!workflowId || Object.keys(workflowStoreBlocks).length === 0) { setWorkflowBlocks([]) return @@ -245,7 +262,11 @@ export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionS return { id: block.id, - name: block.name || presentation?.name || block.id, + name: getLocalizedDefaultBlockNameWithCopy( + workflowInspectorCopy, + block.type, + block.name || presentation?.name + ), type: block.type, iconComponent: presentation?.icon, bgColor: sanitizeSolidIconColor(presentation?.bgColor) || '#6B7280', @@ -258,31 +279,31 @@ export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionS } finally { setIsLoadingWorkflowBlocks(false) } - }, [isLoadingWorkflowBlocks, workflowId, workflowStoreBlocks]) + }, [workflowId, workflowInspectorCopy, workflowStoreBlocks]) const ensureSubmenuLoaded = useCallback( async (submenu: MentionSubmenu) => { - if (submenu === 'Chats') { + if (submenu === 'chats') { await ensurePastChatsLoaded() return } if (isCopilotWorkspaceEntityMentionOption(submenu)) { - await ensureWorkspaceEntityLoaded(getCopilotWorkspaceEntityKindFromMentionOption(submenu)) + await ensureWorkspaceEntityLoaded(submenu) return } - if (submenu === 'Knowledge') { + if (submenu === 'knowledge') { await ensureKnowledgeLoaded() return } - if (submenu === 'Blocks') { + if (submenu === 'blocks') { await ensureBlocksLoaded() return } - if (submenu === 'Workflow Blocks') { + if (submenu === 'workflow_blocks') { await ensureWorkflowBlocksLoaded() return } @@ -304,6 +325,11 @@ export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionS setIsLoadingWorkflowBlocks(false) }, [workflowId]) + useEffect(() => { + setBlocksList([]) + setIsLoadingBlocks(false) + }, [locale]) + useEffect(() => { void ensureWorkflowBlocksLoaded() }, [ensureWorkflowBlocksLoaded]) @@ -335,16 +361,16 @@ export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionS } const mentionLoading: Record = { - Chats: isLoadingPastChats, - Workflows: workspaceEntityLoading.workflow, - Skills: workspaceEntityLoading.skill, - Indicators: workspaceEntityLoading.indicator, - 'Custom Tools': workspaceEntityLoading.custom_tool, - 'MCP Servers': workspaceEntityLoading.mcp_server, - 'Workflow Blocks': isLoadingWorkflowBlocks, - Blocks: isLoadingBlocks, - Knowledge: isLoadingKnowledge, - Logs: isLoadingLogs, + chats: isLoadingPastChats, + workflow: workspaceEntityLoading.workflow, + skill: workspaceEntityLoading.skill, + indicator: workspaceEntityLoading.indicator, + custom_tool: workspaceEntityLoading.custom_tool, + mcp_server: workspaceEntityLoading.mcp_server, + workflow_blocks: isLoadingWorkflowBlocks, + blocks: isLoadingBlocks, + knowledge: isLoadingKnowledge, + logs: isLoadingLogs, } return { 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 db597e027..0c7d64abb 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 @@ -1,23 +1,30 @@ 'use client' import { type KeyboardEvent, type RefObject, useEffect, useRef, useState } from 'react' +import { useLocale } from 'next-intl' +import { buildCopilotContextIdentityKey } from '@/lib/copilot/chat-contexts' import { useOptionalWorkflowSession } from '@/lib/yjs/workflow-session-host' +import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' import type { ChatContext } from '@/stores/copilot/types' import { buildCopilotWorkspaceEntityContext, isCopilotWorkspaceEntityMentionOption, - matchesCopilotWorkspaceEntityContext, } from '../../../workspace-entities' import { MENTION_SUBMENUS } from '../constants' +import { + getKnowledgeBaseMentionLabel, + getMentionOptionLabel, + getPastChatMentionLabel, + getWorkspaceEntityMentionLabel, + useCopilotMentionCopy, +} from '../mention-copy' import { buildAggregatedMentionItems, - filterBlocks, - filterKnowledgeBases, - filterLogs, + buildMentionRanges, + filterMentionItems, filterMentionOptions, - filterPastChats, - filterWorkflowBlocks, - filterWorkspaceEntitiesForOption, + isMentionBoundary, + upsertMentionContextByTextOrder, } from '../mention-utils' import type { AggregatedMentionItem, @@ -26,6 +33,7 @@ import type { MentionRange, MentionSources, MentionSubmenu, + PastChatItem, WorkspaceEntityItem, } from '../types' @@ -43,6 +51,8 @@ interface UseUserInputMentionsOptions { } } +type MentionInsertion = { start: number } + export function useUserInputMentions({ disabled, isLoading, @@ -54,6 +64,9 @@ export function useUserInputMentions({ workspaceId, loaders, }: UseUserInputMentionsOptions) { + const locale = useLocale() + const mentionCopy = useCopilotMentionCopy() + const { copy: monitorCopy } = useMonitorCopy() const [showMentionMenu, setShowMentionMenu] = useState(false) const [mentionActiveIndex, setMentionActiveIndex] = useState(0) const [openSubmenuFor, setOpenSubmenuFor] = useState(null) @@ -64,6 +77,8 @@ export function useUserInputMentions({ const workflowSession = useOptionalWorkflowSession() const currentWorkflowId = workflowSession?.workflowId ?? null const lastSelectionRef = useRef<{ start: number; end: number }>({ start: 0, end: 0 }) + const pendingDeletedContextKeysRef = useRef>(new Set()) + const ensureSubmenuLoaded = loaders.ensureSubmenuLoaded const getEditorTextLength = () => textareaRef.current?.value.length ?? message.length const normalizeSelection = (selection: { start: number; end: number }) => { @@ -138,46 +153,7 @@ export function useUserInputMentions({ } const computeMentionRanges = (text: string = message) => { - const ranges: MentionRange[] = [] - - if (!text || selectedContexts.length === 0) { - return ranges - } - - const labels = Array.from( - new Set(selectedContexts.map((context) => context.label).filter(Boolean) as string[]) - ) - - if (labels.length === 0) { - return ranges - } - - for (const label of labels) { - const token = `@${label}` - let fromIndex = 0 - - while (fromIndex <= text.length) { - const index = text.indexOf(token, fromIndex) - - if (index === -1) { - break - } - - const beforeChar = index === 0 ? ' ' : text[index - 1] - const afterChar = text[index + token.length] ?? '' - const hasLeadingBoundary = index === 0 || /\s/.test(beforeChar) - const hasTrailingBoundary = index + token.length >= text.length || /\s/.test(afterChar) - - if (hasLeadingBoundary && hasTrailingBoundary) { - ranges.push({ start: index, end: index + token.length, label }) - } - - fromIndex = index + token.length - } - } - - ranges.sort((a, b) => a.start - b.start) - return ranges + return buildMentionRanges(text, selectedContexts) } const mentionRanges = computeMentionRanges() @@ -199,12 +175,12 @@ export function useUserInputMentions({ return null } - if (atIndex > 0 && !/\s/.test(before.charAt(atIndex - 1))) { + if (atIndex > 0 && !isMentionBoundary(before.charAt(atIndex - 1))) { return null } const ranges = computeMentionRanges(text) - if (ranges.some((range) => atIndex > range.start && atIndex < range.end)) { + if (ranges.some((range) => atIndex >= range.start && atIndex < range.end)) { return null } @@ -228,7 +204,7 @@ export function useUserInputMentions({ showMentionMenu && !openSubmenuFor && mentionQuery.length > 0 && - filterMentionOptions(mentionQuery).length === 0 + filterMentionOptions(mentionQuery, mentionCopy).length === 0 const closeMentionMenu = () => { setShowMentionMenu(false) @@ -243,31 +219,10 @@ export function useUserInputMentions({ setSelectedContexts([]) } - const getFilteredSubmenuItems = (submenu: MentionSubmenu, query: string): MentionItem[] => { - if (submenu === 'Chats') { - return filterPastChats(mentionSources.pastChats, query) - } - - if (isCopilotWorkspaceEntityMentionOption(submenu)) { - return filterWorkspaceEntitiesForOption(submenu, mentionSources, query) - } - - if (submenu === 'Knowledge') { - return filterKnowledgeBases(mentionSources.knowledgeBases, query) - } - - if (submenu === 'Blocks') { - return filterBlocks(mentionSources.blocksList, query) - } - - if (submenu === 'Workflow Blocks') { - return filterWorkflowBlocks(mentionSources.workflowBlocks, query) - } + const getFilteredSubmenuItems = (submenu: MentionSubmenu, query: string): MentionItem[] => + filterMentionItems(submenu, mentionSources, query, monitorCopy, mentionCopy) - return filterLogs(mentionSources.logsList, query) - } - - const insertAtCursor = (text: string) => { + const insertAtCursor = (text: string): MentionInsertion => { const selection = getSelection() const start = selection?.start ?? message.length const end = selection?.end ?? message.length @@ -283,16 +238,17 @@ export function useUserInputMentions({ const nextPos = before.length + text.length restoreEditorSelection(nextPos, nextPos) + return { start: before.length } } - const replaceActiveMentionWith = (label: string) => { - if (!textareaRef.current) return false + const replaceActiveMentionWith = (label: string): MentionInsertion | null => { + if (!textareaRef.current) return null const pos = getSelection()?.start ?? message.length const active = getActiveMentionQueryAtPosition(pos) if (!active) { - return false + return null } const before = message.slice(0, active.start) @@ -306,7 +262,16 @@ export function useUserInputMentions({ const cursorPos = before.length + insertion.length restoreEditorSelection(cursorPos, cursorPos) - return true + return { start: before.length } + } + + const insertMentionToken = (label: string) => + replaceActiveMentionWith(label) ?? insertAtCursor(`@${label} `) + + const selectMentionContext = (context: ChatContext, insertion: MentionInsertion) => { + setSelectedContexts((prev) => + upsertMentionContextByTextOrder(prev, context, message, insertion.start) + ) } const resetActiveMentionQuery = () => { @@ -328,90 +293,54 @@ export function useUserInputMentions({ restoreEditorSelection(caretPos, caretPos) } - const insertPastChatMention = (chat: { reviewSessionId: string; title: string | null }) => { - const label = chat.title || 'Untitled Chat' - replaceActiveMentionWith(label) - setSelectedContexts((prev) => { - if ( - prev.some( - (context) => - context.kind === 'past_chat' && - (context as any).reviewSessionId === chat.reviewSessionId - ) - ) { - return prev - } - - return [ - ...prev, - { kind: 'past_chat', reviewSessionId: chat.reviewSessionId, label } as ChatContext, - ] - }) + const insertPastChatMention = (chat: Pick) => { + const label = getPastChatMentionLabel(mentionCopy, chat) + const insertion = insertMentionToken(label) + selectMentionContext( + { + kind: 'past_chat', + reviewSessionId: chat.reviewSessionId, + label, + }, + insertion + ) closeMentionMenu() } const insertWorkspaceEntityMention = (item: WorkspaceEntityItem) => { - const label = item.name || 'Untitled' - const token = `@${label}` - - if (!replaceActiveMentionWith(label)) { - insertAtCursor(`${token} `) - } - - setSelectedContexts((prev) => { - if ( - prev.some((context) => - matchesCopilotWorkspaceEntityContext(context, item.entityKind, item.id) - ) - ) { - return prev - } - - return [ - ...prev, - buildCopilotWorkspaceEntityContext({ - entityKind: item.entityKind, - entityId: item.id, - workspaceId, - label, - }), - ] - }) + const label = getWorkspaceEntityMentionLabel(mentionCopy, item) + const insertion = insertMentionToken(label) + + selectMentionContext( + buildCopilotWorkspaceEntityContext({ + entityKind: item.entityKind, + entityId: item.id, + workspaceId, + label, + }), + insertion + ) closeMentionMenu() } const insertKnowledgeMention = (knowledgeBase: { id: string; name: string }) => { - const label = knowledgeBase.name || 'Untitled' - replaceActiveMentionWith(label) - setSelectedContexts((prev) => { - if ( - prev.some( - (context) => - context.kind === 'knowledge' && (context as any).knowledgeId === knowledgeBase.id - ) - ) { - return prev - } - - return [...prev, { kind: 'knowledge', knowledgeId: knowledgeBase.id, label } as any] - }) + const label = getKnowledgeBaseMentionLabel(knowledgeBase) + const insertion = insertMentionToken(label) + selectMentionContext( + { + kind: 'knowledge', + knowledgeId: knowledgeBase.id, + label, + }, + insertion + ) closeMentionMenu() } const insertBlockMention = (block: { id: string; name: string }) => { const label = block.name || block.id - replaceActiveMentionWith(label) - setSelectedContexts((prev) => { - if ( - prev.some( - (context) => context.kind === 'blocks' && (context.blockTypes ?? []).includes(block.id) - ) - ) { - return prev - } - - return [...prev, { kind: 'blocks', blockTypes: [block.id], label } as ChatContext] - }) + const insertion = insertMentionToken(label) + selectMentionContext({ kind: 'blocks', blockTypes: [block.id], label }, insertion) closeMentionMenu() } @@ -422,50 +351,25 @@ export function useUserInputMentions({ } const label = block.name - const token = `@${label}` - - if (!replaceActiveMentionWith(label)) { - insertAtCursor(`${token} `) - } - - setSelectedContexts((prev) => { - if ( - prev.some( - (context) => - context.kind === 'workflow_block' && - (context as any).workflowId === currentWorkflowId && - (context as any).blockId === block.id - ) - ) { - return prev - } - - return [ - ...prev, - { - kind: 'workflow_block', - workflowId: currentWorkflowId, - blockId: block.id, - label, - } as any, - ] - }) + const insertion = insertMentionToken(label) + + selectMentionContext( + { + kind: 'workflow_block', + workflowId: currentWorkflowId, + blockId: block.id, + label, + }, + insertion + ) closeMentionMenu() } const insertDocsMention = () => { - const label = 'Docs' - if (!replaceActiveMentionWith(label)) { - insertAtCursor(`@${label} `) - } - - setSelectedContexts((prev) => { - if (prev.some((context) => context.kind === 'docs')) { - return prev - } + const label = getMentionOptionLabel(mentionCopy, 'docs') + const insertion = insertMentionToken(label) - return [...prev, { kind: 'docs', label } as any] - }) + selectMentionContext({ kind: 'docs', label }, insertion) closeMentionMenu() } @@ -478,29 +382,30 @@ export function useUserInputMentions({ entityName: string }) => { const label = log.entityName - replaceActiveMentionWith(label) - setSelectedContexts((prev) => { - if (prev.some((context) => context.kind === 'logs' && context.label === label)) { - return prev - } - - return [...prev, { kind: 'logs', executionId: log.executionId, label }] - }) + const insertion = insertMentionToken(label) + selectMentionContext( + { + kind: 'logs', + executionId: log.executionId, + label, + }, + insertion + ) closeMentionMenu() } const handleSubmenuItemSelect = (submenu: MentionSubmenu, item: MentionItem) => { - if (submenu === 'Chats') { + if (submenu === 'chats') { insertPastChatMention(item as any) } else if (isCopilotWorkspaceEntityMentionOption(submenu)) { insertWorkspaceEntityMention(item as WorkspaceEntityItem) - } else if (submenu === 'Knowledge') { + } else if (submenu === 'knowledge') { insertKnowledgeMention(item as any) - } else if (submenu === 'Blocks') { + } else if (submenu === 'blocks') { insertBlockMention(item as any) - } else if (submenu === 'Workflow Blocks') { + } else if (submenu === 'workflow_blocks') { insertWorkflowBlockMention(item as any) - } else if (submenu === 'Logs') { + } else if (submenu === 'logs') { insertLogMention(item as any) } @@ -508,17 +413,17 @@ export function useUserInputMentions({ } const handleAggregatedItemSelect = (item: AggregatedMentionItem) => { - if (item.type === 'Chats') { + if (item.type === 'chats') { insertPastChatMention(item.value as any) } else if (isCopilotWorkspaceEntityMentionOption(item.type)) { insertWorkspaceEntityMention(item.value as WorkspaceEntityItem) - } else if (item.type === 'Knowledge') { + } else if (item.type === 'knowledge') { insertKnowledgeMention(item.value as any) - } else if (item.type === 'Blocks') { + } else if (item.type === 'blocks') { insertBlockMention(item.value as any) - } else if (item.type === 'Workflow Blocks') { + } else if (item.type === 'workflow_blocks') { insertWorkflowBlockMention(item.value as any) - } else if (item.type === 'Logs') { + } else if (item.type === 'logs') { insertLogMention(item.value as any) } } @@ -528,11 +433,11 @@ export function useUserInputMentions({ setOpenSubmenuFor(submenu) setSubmenuActiveIndex(0) setSubmenuQueryStart(getCaretPos()) - void loaders.ensureSubmenuLoaded(submenu) + void ensureSubmenuLoaded(submenu) } const handleMainMentionOptionSelect = (option: MentionOption) => { - if (option === 'Docs') { + if (option === 'docs') { resetActiveMentionQuery() insertDocsMention() return @@ -548,8 +453,19 @@ export function useUserInputMentions({ before.endsWith(' ') && after.startsWith(' ') ? `${before}${after.slice(1)}` : `${before}${after}` + const matchingRanges = computeMentionRanges().filter( + (mentionRange) => mentionRange.contextKey === range.contextKey + ) + const shouldRemoveContext = matchingRanges.length <= 1 + if (shouldRemoveContext) { + pendingDeletedContextKeysRef.current.add(range.contextKey) + } setMessage(next) - setSelectedContexts((prev) => prev.filter((context) => context.label !== range.label)) + if (shouldRemoveContext) { + setSelectedContexts((prev) => + prev.filter((context) => buildCopilotContextIdentityKey(context) !== range.contextKey) + ) + } restoreEditorSelection(range.start, range.start) } @@ -568,7 +484,7 @@ export function useUserInputMentions({ const active = getActiveMentionQueryAtPosition(normalizedSelection.start, newValue) if (active) { - void loaders.ensureSubmenuLoaded('Workflow Blocks') + void ensureSubmenuLoaded('workflow_blocks') setShowMentionMenu(true) setInAggregated(false) @@ -618,7 +534,7 @@ export function useUserInputMentions({ const pos = getSelection()?.start ?? message.length const needsSpaceBefore = pos > 0 && !/\s/.test(message.charAt(pos - 1)) insertAtCursor(needsSpaceBefore ? ' @' : '@') - void loaders.ensureSubmenuLoaded('Workflow Blocks') + void ensureSubmenuLoaded('workflow_blocks') setShowMentionMenu(true) setOpenSubmenuFor(null) setMentionActiveIndex(0) @@ -665,10 +581,10 @@ export function useUserInputMentions({ return currentIndex <= 0 ? itemCount - 1 : currentIndex - 1 } - const filteredMain = openSubmenuFor ? [] : filterMentionOptions(mentionQuery) + const filteredMain = openSubmenuFor ? [] : filterMentionOptions(mentionQuery, mentionCopy) const aggregatedItems = !openSubmenuFor && mentionQuery.length > 0 - ? buildAggregatedMentionItems(mentionQuery, mentionSources) + ? buildAggregatedMentionItems(mentionQuery, mentionSources, monitorCopy, mentionCopy) : [] if (openSubmenuFor) { @@ -774,7 +690,7 @@ export function useUserInputMentions({ return true } - const selected = filterMentionOptions(mentionQuery)[mentionActiveIndex] + const selected = filterMentionOptions(mentionQuery, mentionCopy)[mentionActiveIndex] if (selected) { handleMainMentionOptionSelect(selected) } @@ -877,7 +793,12 @@ export function useUserInputMentions({ event.preventDefault() if (inAggregated || aggregatedActive) { - const aggregatedItems = buildAggregatedMentionItems(mentionQuery, mentionSources) + const aggregatedItems = buildAggregatedMentionItems( + mentionQuery, + mentionSources, + monitorCopy, + mentionCopy + ) const chosen = aggregatedItems[Math.max(0, Math.min(submenuActiveIndex, aggregatedItems.length - 1))] @@ -897,7 +818,7 @@ export function useUserInputMentions({ return true } - const selected = filterMentionOptions(mentionQuery)[mentionActiveIndex] + const selected = filterMentionOptions(mentionQuery, mentionCopy)[mentionActiveIndex] if (selected) { handleMainMentionOptionSelect(selected) } @@ -914,25 +835,39 @@ export function useUserInputMentions({ return } - const presentLabels = new Set() - for (const range of computeMentionRanges()) { - presentLabels.add(range.label) - } - - setSelectedContexts((prev) => - prev.filter((context) => !!context.label && presentLabels.has(context.label)) - ) + setSelectedContexts((prev) => { + const deletedContextKeys = pendingDeletedContextKeysRef.current + const candidates = + deletedContextKeys.size === 0 + ? prev + : prev.filter( + (context) => !deletedContextKeys.has(buildCopilotContextIdentityKey(context)) + ) + pendingDeletedContextKeysRef.current = new Set() + const presentKeys = new Set( + buildMentionRanges(message, candidates).map((range) => range.contextKey) + ) + return candidates.filter((context) => + presentKeys.has(buildCopilotContextIdentityKey(context)) + ) + }) }, [message]) useEffect(() => { - if (!showMentionMenu || openSubmenuFor) { + if (!showMentionMenu) { + setInAggregated(false) + return + } + + if (openSubmenuFor) { setInAggregated(false) + void ensureSubmenuLoaded(openSubmenuFor) return } if (mentionQuery.length > 0) { for (const submenu of MENTION_SUBMENUS) { - void loaders.ensureSubmenuLoaded(submenu) + void ensureSubmenuLoaded(submenu) } } @@ -940,7 +875,7 @@ export function useUserInputMentions({ setSubmenuActiveIndex(0) requestAnimationFrame(() => scrollActiveItemIntoView(0)) } - }, [showMentionMenu, openSubmenuFor, message]) + }, [showMentionMenu, openSubmenuFor, message, locale, ensureSubmenuLoaded]) useEffect(() => { if (openSubmenuFor) { diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-copy.ts b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-copy.ts new file mode 100644 index 000000000..ed5a53740 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-copy.ts @@ -0,0 +1,141 @@ +'use client' + +import { type Messages, useMessages } from 'next-intl' +import { + getMonitorTriggerLabel, + type MonitorCopy, +} from '@/app/workspace/[workspaceId]/monitor/copy' +import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' +import type { CopilotWorkspaceEntityKind } from '../../workspace-entities' +import type { + KnowledgeBaseItem, + LogItem, + MentionOption, + MentionSubmenu, + PastChatItem, + WorkspaceEntityItem, +} from './types' + +type WorkspaceDashboardMessages = Messages['workspace']['dashboard'] +type WorkspaceKnowledgeMessages = Messages['workspace']['knowledge'] +type WorkspaceWidgetsMessages = Messages['workspace']['widgets'] + +export type CopilotMentionCopy = ReturnType + +export function getCopilotMentionCopy({ + dashboard, + knowledge, + nav, + widgets, +}: { + dashboard: WorkspaceDashboardMessages + knowledge: WorkspaceKnowledgeMessages + nav: Messages['nav'] + widgets: WorkspaceWidgetsMessages +}) { + const copilot = widgets.copilot + return { + optionLabels: { + chats: widgets.workflowLabels.chats, + workflow: widgets.workflowLabels.workflows, + skill: widgets.workflowLabels.skills, + custom_tool: widgets.workflowLabels.customTools, + indicator: widgets.workflowLabels.indicators, + mcp_server: widgets.workflowLabels.mcpServers, + workflow_blocks: copilot.mentions.workflowBlocks, + blocks: widgets.workflowToolbar.blocks, + knowledge: dashboard.pages.knowledge, + docs: nav.docs, + logs: dashboard.pages.logs, + } satisfies Record, + submenuTitles: { + chats: widgets.workflowLabels.chats, + workflow: widgets.workflowLabels.allWorkflows, + skill: widgets.workflowLabels.skills, + custom_tool: widgets.workflowLabels.customTools, + indicator: widgets.workflowLabels.indicators, + mcp_server: widgets.workflowLabels.mcpServers, + workflow_blocks: copilot.mentions.workflowBlocks, + blocks: widgets.workflowToolbar.blocks, + knowledge: dashboard.sections.knowledgeBases, + logs: dashboard.pages.logs, + } satisfies Record, + emptyStates: { + chats: copilot.mentions.noPastChats, + workflow: widgets.workflowDropdown.noWorkflowsFound, + skill: widgets.skillDropdown.noSkillsFound, + indicator: widgets.indicatorDropdown.noIndicatorsFound, + custom_tool: widgets.customToolDropdown.noCustomToolsFound, + mcp_server: widgets.mcpDropdown.noServersFound, + knowledge: knowledge.emptyState.noMatches, + blocks: copilot.mentions.noBlocksFound, + workflow_blocks: copilot.mentions.noBlocksInWorkflow, + logs: copilot.mentions.noExecutionsFound, + } satisfies Record, + untitledLabels: { + chats: copilot.history.newChat, + workflow: widgets.workflowDropdown.untitledWorkflow, + skill: widgets.skillDropdown.untitledSkill, + indicator: widgets.indicatorList.listItem.untitledIndicator, + custom_tool: widgets.customToolDropdown.untitledCustomTool, + mcp_server: widgets.mcpDropdown.unnamedServer, + } satisfies Record<'chats' | CopilotWorkspaceEntityKind, string>, + matches: copilot.mentions.matches, + noMatches: copilot.mentions.noMatches, + loading: copilot.history.loading, + } +} + +export function useCopilotMentionCopy(): CopilotMentionCopy { + const messages = useMessages() + const widgets = useWorkspaceWidgetsMessages() + + return getCopilotMentionCopy({ + dashboard: messages.workspace.dashboard, + knowledge: messages.workspace.knowledge, + nav: messages.nav, + widgets, + }) +} + +export function getMentionOptionLabel(copy: CopilotMentionCopy, option: MentionOption): string { + return copy.optionLabels[option] +} + +export function getMentionSubmenuTitle(copy: CopilotMentionCopy, submenu: MentionSubmenu): string { + return copy.submenuTitles[submenu] +} + +export function getWorkspaceEntityMentionLabel( + copy: CopilotMentionCopy, + item: Pick +): string { + return item.name.trim() || copy.untitledLabels[item.entityKind] +} + +export function getPastChatMentionLabel( + copy: CopilotMentionCopy, + item: Pick +): string { + return item.title?.trim() || copy.untitledLabels.chats +} + +export function getKnowledgeBaseMentionLabel(item: Pick): string { + return item.name.trim() +} + +export function getLogMentionTriggerLabel( + copy: MonitorCopy, + item: Pick +): string { + return getMonitorTriggerLabel(copy, (item.trigger ?? 'unknown').toLowerCase()) +} + +export function getLogMentionSearchText( + copy: MonitorCopy, + item: Pick +): string { + const trigger = (item.trigger ?? 'unknown').toLowerCase() + + return [item.entityName, trigger, getMonitorTriggerLabel(copy, trigger)].join(' ').trim() +} diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-editor-dom.test.ts b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-editor-dom.test.ts index 275708b97..16b0cb385 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-editor-dom.test.ts +++ b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-editor-dom.test.ts @@ -13,6 +13,7 @@ describe('mention-editor-dom', () => { start: 6, end: 20, label: 'default-agent', + contextKey: 'docs:default-agent', }, ]) ).toEqual([ diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-utils.test.ts b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-utils.test.ts index 56a31a9dd..d9b2782ed 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-utils.test.ts +++ b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-utils.test.ts @@ -1,55 +1,29 @@ import { describe, expect, it } from 'vitest' +import enMessages from '../../../../../i18n/messages/en.json' +import esMessages from '../../../../../i18n/messages/es.json' +import zhMessages from '../../../../../i18n/messages/zh.json' +import { + getCopilotMentionCopy, + getMentionOptionLabel, + getPastChatMentionLabel, + getWorkspaceEntityMentionLabel, +} from './mention-copy' import { buildAggregatedMentionItems, + buildMentionRanges, filterMentionOptions, - filterWorkspaceEntitiesForOption, - getMentionSubmenuTitle, + upsertMentionContextByTextOrder, } from './mention-utils' import type { MentionSources } from './types' const createMentionSources = (): MentionSources => ({ pastChats: [], workspaceEntities: { - workflow: [ - { - entityKind: 'workflow', - id: 'workflow-1', - name: 'Alpha Workflow', - color: '#3972F6', - }, - ], - skill: [ - { - entityKind: 'skill', - id: 'skill-1', - name: 'Risk Filter', - description: 'Filters noisy setups', - }, - ], - indicator: [ - { - entityKind: 'indicator', - id: 'indicator-1', - name: 'Momentum RSI', - color: '#22c55e', - }, - ], - custom_tool: [ - { - entityKind: 'custom_tool', - id: 'tool-1', - name: 'Slack Alerts', - functionName: 'sendSlackAlert', - }, - ], - mcp_server: [ - { - entityKind: 'mcp_server', - id: 'mcp-1', - name: 'Broker MCP', - transport: 'http', - }, - ], + workflow: [{ entityKind: 'workflow', id: 'workflow-1', name: 'Alpha Workflow' }], + skill: [], + indicator: [], + custom_tool: [{ entityKind: 'custom_tool', id: 'tool-1', name: 'Slack Alerts' }], + mcp_server: [], }, knowledgeBases: [], blocksList: [], @@ -57,46 +31,120 @@ const createMentionSources = (): MentionSources => ({ workflowBlocks: [], }) -describe('mention-utils', () => { - it('surfaces centralized workspace entity mention options in option filtering', () => { - expect(filterMentionOptions('tool')).toContain('Custom Tools') - expect(filterMentionOptions('mcp')).toContain('MCP Servers') +const getMentionCopy = (messages: any) => + getCopilotMentionCopy({ + dashboard: messages.workspace.dashboard, + knowledge: messages.workspace.knowledge, + nav: messages.nav, + widgets: messages.workspace.widgets, }) - it('filters workspace entity submenu items by option', () => { - const sources = createMentionSources() - - expect(filterWorkspaceEntitiesForOption('Skills', sources, 'risk')).toEqual([ - sources.workspaceEntities.skill[0], - ]) +describe('mention-utils', () => { + const enMentionCopy = getMentionCopy(enMessages) + const zhMentionCopy = getMentionCopy(zhMessages) + const esMentionCopy = getMentionCopy(esMessages) + const enMonitorCopy = (enMessages as any).workspace.monitor - expect(filterWorkspaceEntitiesForOption('MCP Servers', sources, 'http')).toEqual([ - sources.workspaceEntities.mcp_server[0], - ]) + it('surfaces centralized workspace entity mention options in option filtering', () => { + expect(filterMentionOptions('tool', enMentionCopy)).toContain('custom_tool') + expect(filterMentionOptions('工具', zhMentionCopy)).toContain('custom_tool') }) it('includes workspace entity matches in aggregated search results', () => { const sources = createMentionSources() - expect(buildAggregatedMentionItems('alpha', sources)).toEqual([ + expect(buildAggregatedMentionItems('alpha', sources, enMonitorCopy, enMentionCopy)).toEqual([ { - type: 'Workflows', + type: 'workflow', id: 'workflow-1', value: sources.workspaceEntities.workflow[0], }, ]) - expect(buildAggregatedMentionItems('slack', sources)).toEqual([ + expect(buildAggregatedMentionItems('slack', sources, enMonitorCopy, enMentionCopy)).toEqual([ { - type: 'Custom Tools', + type: 'custom_tool', id: 'tool-1', value: sources.workspaceEntities.custom_tool[0], }, ]) }) - it('uses centralized submenu titles for workspace entity mention groups', () => { - expect(getMentionSubmenuTitle('Workflows')).toBe('All workflows') - expect(getMentionSubmenuTitle('Indicators')).toBe('Indicators') + it('uses localized untitled labels for empty chat and workspace entity names', () => { + const sources = createMentionSources() + sources.pastChats = [{ reviewSessionId: 'chat-1', title: null, workflowId: null }] + sources.workspaceEntities.custom_tool = [ + { entityKind: 'custom_tool', id: 'tool-empty', name: '', description: '' }, + ] + const chatLabel = (esMessages as any).workspace.widgets.copilot.history.newChat + const toolLabel = (esMessages as any).workspace.widgets.customToolDropdown.untitledCustomTool + + expect(getPastChatMentionLabel(esMentionCopy, sources.pastChats[0])).toBe(chatLabel) + expect( + getWorkspaceEntityMentionLabel(esMentionCopy, sources.workspaceEntities.custom_tool[0]) + ).toBe(toolLabel) + expect(buildAggregatedMentionItems(toolLabel, sources, enMonitorCopy, esMentionCopy)).toEqual([ + { type: 'custom_tool', id: 'tool-empty', value: sources.workspaceEntities.custom_tool[0] }, + ]) + expect(buildAggregatedMentionItems(chatLabel, sources, enMonitorCopy, esMentionCopy)).toEqual([ + { type: 'chats', id: 'chat-1', value: sources.pastChats[0] }, + ]) + + sources.workspaceEntities.custom_tool[0].name = 'untitled' + expect( + getWorkspaceEntityMentionLabel(esMentionCopy, sources.workspaceEntities.custom_tool[0]) + ).toBe('untitled') + + sources.pastChats[0].title = 'untitled' + expect(getPastChatMentionLabel(esMentionCopy, sources.pastChats[0])).toBe('untitled') + }) + + it('tracks duplicate mention labels by context identity', () => { + const ranges = buildMentionRanges('@Untitled @Untitled', [ + { kind: 'custom_tool', customToolId: 'tool-1', label: 'Untitled' }, + { kind: 'custom_tool', customToolId: 'tool-2', label: 'Untitled' }, + ]) + + expect(ranges.map((range) => range.contextKey)).toEqual([ + 'custom_tool:tool-1', + 'custom_tool:tool-2', + ]) + }) + + it('orders duplicate mention labels by insertion position', () => { + const contexts = upsertMentionContextByTextOrder( + [{ kind: 'custom_tool', customToolId: 'tool-1', label: 'Untitled' }], + { kind: 'custom_tool', customToolId: 'tool-2', label: 'Untitled' }, + '@Untitled', + 0 + ) + const ranges = buildMentionRanges('@Untitled @Untitled', contexts) + + expect(ranges.map((range) => range.contextKey)).toEqual([ + 'custom_tool:tool-2', + 'custom_tool:tool-1', + ]) + }) + + it('tracks repeated text for the same mention context', () => { + const ranges = buildMentionRanges('@Docs @Docs', [{ kind: 'docs', label: 'Docs' }]) + + expect(ranges.map((range) => range.contextKey)).toEqual(['docs', 'docs']) + }) + + it('tracks the refreshed localized label for the same canonical context', () => { + const docsLabel = getMentionOptionLabel(esMentionCopy, 'docs') + const ranges = buildMentionRanges(`@Docs @${docsLabel}`, [{ kind: 'docs', label: docsLabel }]) + + expect(ranges.map((range) => range.contextKey)).toEqual(['docs']) + }) + + it('keeps mention ranges when punctuation touches the token', () => { + const ranges = buildMentionRanges('(@Docs), @Untitled.', [ + { kind: 'docs', label: 'Docs' }, + { kind: 'custom_tool', customToolId: 'tool-1', label: 'Untitled' }, + ]) + + expect(ranges.map((range) => range.contextKey)).toEqual(['docs', 'custom_tool:tool-1']) }) }) diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-utils.ts b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-utils.ts index 7b3c0096c..ad1c33bfa 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-utils.ts +++ b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-utils.ts @@ -1,18 +1,29 @@ 'use client' +import { buildCopilotContextIdentityKey } from '@/lib/copilot/chat-contexts' +import type { MonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' +import type { ChatContext } from '@/stores/copilot/types' import { COPILOT_WORKSPACE_ENTITY_CONFIGS, - getCopilotWorkspaceEntityConfigForMentionOption, - getCopilotWorkspaceEntityKindFromMentionOption, isCopilotWorkspaceEntityMentionOption, } from '../../workspace-entities' import { MENTION_OPTIONS } from './constants' +import { + type CopilotMentionCopy, + getKnowledgeBaseMentionLabel, + getLogMentionSearchText, + getMentionOptionLabel, + getPastChatMentionLabel, + getWorkspaceEntityMentionLabel, +} from './mention-copy' import type { AggregatedMentionItem, BlockItem, KnowledgeBaseItem, LogItem, + MentionItem, MentionOption, + MentionRange, MentionSources, MentionSubmenu, PastChatItem, @@ -20,68 +31,176 @@ import type { WorkspaceEntityItem, } from './types' -const normalize = (value: string) => value.toLowerCase() +const normalize = (value: string) => + value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() -export function filterMentionOptions(query: string): MentionOption[] { - const normalizedQuery = normalize(query) - return MENTION_OPTIONS.filter((option) => option.toLowerCase().includes(normalizedQuery)) +const includesNormalized = (value: string, query: string) => + normalize(value).includes(normalize(query)) + +export const isMentionBoundary = (char: string | undefined) => + !char || /\s/u.test(char) || (/[\p{P}\p{S}]/u.test(char) && !/[-_@]/u.test(char)) + +export function buildMentionRanges(text: string, contexts: ChatContext[]): MentionRange[] { + if (!text || contexts.length === 0) { + return [] + } + + const contextsByLabel = new Map>() + + for (const context of contexts) { + const label = context.label?.trim() + if (!label) { + continue + } + + const entries = contextsByLabel.get(label) ?? [] + entries.push({ contextKey: buildCopilotContextIdentityKey(context), label }) + contextsByLabel.set(label, entries) + } + + const ranges: MentionRange[] = [] + + for (const [label, entries] of contextsByLabel) { + const token = `@${label}` + let fromIndex = 0 + let entryIndex = 0 + + while (fromIndex <= text.length) { + const index = text.indexOf(token, fromIndex) + + if (index === -1) { + break + } + + const beforeChar = index === 0 ? '' : text[index - 1] + const afterChar = text[index + token.length] ?? '' + const hasLeadingBoundary = isMentionBoundary(beforeChar) + const hasTrailingBoundary = isMentionBoundary(afterChar) + + if (hasLeadingBoundary && hasTrailingBoundary) { + const entry = entries[Math.min(entryIndex, entries.length - 1)] + ranges.push({ + start: index, + end: index + token.length, + label, + contextKey: entry.contextKey, + }) + entryIndex = Math.min(entryIndex + 1, entries.length - 1) + } + + fromIndex = index + token.length + } + } + + ranges.sort((left, right) => left.start - right.start) + return ranges } -export function filterPastChats(items: PastChatItem[], query: string) { - const normalizedQuery = normalize(query) - return items.filter((item) => - (item.title || 'Untitled Chat').toLowerCase().includes(normalizedQuery) +export function upsertMentionContextByTextOrder( + contexts: ChatContext[], + nextContext: ChatContext, + text: string, + mentionStart: number +): ChatContext[] { + const nextContextKey = buildCopilotContextIdentityKey(nextContext) + const existingIndex = contexts.findIndex( + (context) => buildCopilotContextIdentityKey(context) === nextContextKey + ) + + if (existingIndex !== -1) { + return contexts.map((context, index) => (index === existingIndex ? nextContext : context)) + } + + const insertIndex = buildMentionRanges(text, contexts).filter( + (range) => range.start < mentionStart + ).length + + return [...contexts.slice(0, insertIndex), nextContext, ...contexts.slice(insertIndex)] +} + +export function filterMentionOptions(query: string, copy: CopilotMentionCopy): MentionOption[] { + return MENTION_OPTIONS.filter((option) => + includesNormalized(getMentionOptionLabel(copy, option), query) ) } -export function filterWorkspaceEntities(items: WorkspaceEntityItem[], query: string) { - const normalizedQuery = normalize(query) +export function filterPastChats(items: PastChatItem[], query: string, copy: CopilotMentionCopy) { + return items.filter((item) => includesNormalized(getPastChatMentionLabel(copy, item), query)) +} + +export function filterWorkspaceEntities( + items: WorkspaceEntityItem[], + query: string, + copy: CopilotMentionCopy +) { return items.filter((item) => - [item.name, item.description || '', item.functionName || '', item.transport || ''] - .join(' ') - .toLowerCase() - .includes(normalizedQuery) + includesNormalized( + [ + getWorkspaceEntityMentionLabel(copy, item), + item.description || '', + item.transport || '', + ].join(' '), + query + ) ) } export function filterKnowledgeBases(items: KnowledgeBaseItem[], query: string) { - const normalizedQuery = normalize(query) - return items.filter((item) => (item.name || 'Untitled').toLowerCase().includes(normalizedQuery)) + return items.filter((item) => includesNormalized(getKnowledgeBaseMentionLabel(item), query)) } export function filterBlocks(items: BlockItem[], query: string) { - const normalizedQuery = normalize(query) - return items.filter((item) => (item.name || item.id).toLowerCase().includes(normalizedQuery)) + return items.filter((item) => includesNormalized(item.name || item.id, query)) } export function filterWorkflowBlocks(items: WorkflowBlockItem[], query: string) { - const normalizedQuery = normalize(query) - return items.filter((item) => (item.name || item.id).toLowerCase().includes(normalizedQuery)) + return items.filter((item) => includesNormalized(item.name || item.id, query)) +} + +export function filterLogs(items: LogItem[], query: string, monitorCopy: MonitorCopy) { + return items.filter((item) => + includesNormalized(getLogMentionSearchText(monitorCopy, item), query) + ) } -export function filterWorkspaceEntitiesForOption( - option: MentionSubmenu, +export function filterMentionItems( + submenu: MentionSubmenu, sources: MentionSources, - query: string -) { - if (!isCopilotWorkspaceEntityMentionOption(option)) { - return [] + query: string, + monitorCopy: MonitorCopy, + mentionCopy: CopilotMentionCopy +): MentionItem[] { + if (submenu === 'chats') { + return filterPastChats(sources.pastChats, query, mentionCopy) } - const entityKind = getCopilotWorkspaceEntityKindFromMentionOption(option) - return filterWorkspaceEntities(sources.workspaceEntities[entityKind], query) -} + if (isCopilotWorkspaceEntityMentionOption(submenu)) { + return filterWorkspaceEntities(sources.workspaceEntities[submenu], query, mentionCopy) + } -export function filterLogs(items: LogItem[], query: string) { - const normalizedQuery = normalize(query) - return items.filter((item) => - [item.entityName, item.trigger || ''].join(' ').toLowerCase().includes(normalizedQuery) - ) + if (submenu === 'knowledge') { + return filterKnowledgeBases(sources.knowledgeBases, query) + } + + if (submenu === 'blocks') { + return filterBlocks(sources.blocksList, query) + } + + if (submenu === 'workflow_blocks') { + return filterWorkflowBlocks(sources.workflowBlocks, query) + } + + return filterLogs(sources.logsList, query, monitorCopy) } export function buildAggregatedMentionItems( query: string, - sources: MentionSources + sources: MentionSources, + monitorCopy: MonitorCopy, + mentionCopy: CopilotMentionCopy ): AggregatedMentionItem[] { const normalizedQuery = normalize(query) @@ -90,66 +209,54 @@ export function buildAggregatedMentionItems( } return [ - ...filterWorkflowBlocks(sources.workflowBlocks, normalizedQuery).map((value) => ({ - type: 'Workflow Blocks' as const, + ...filterWorkflowBlocks(sources.workflowBlocks, query).map((value) => ({ + type: 'workflow_blocks' as const, id: value.id, value, })), ...COPILOT_WORKSPACE_ENTITY_CONFIGS.flatMap((config) => - filterWorkspaceEntities(sources.workspaceEntities[config.entityKind], normalizedQuery).map( + filterWorkspaceEntities(sources.workspaceEntities[config.entityKind], query, mentionCopy).map( (value) => ({ - type: config.mentionOption, + type: config.entityKind, id: value.id, value, }) ) ), - ...filterBlocks(sources.blocksList, normalizedQuery).map((value) => ({ - type: 'Blocks' as const, + ...filterBlocks(sources.blocksList, query).map((value) => ({ + type: 'blocks' as const, id: value.id, value, })), - ...filterKnowledgeBases(sources.knowledgeBases, normalizedQuery).map((value) => ({ - type: 'Knowledge' as const, + ...filterKnowledgeBases(sources.knowledgeBases, query).map((value) => ({ + type: 'knowledge' as const, id: value.id, value, })), - ...filterPastChats(sources.pastChats, normalizedQuery).map((value) => ({ - type: 'Chats' as const, + ...filterPastChats(sources.pastChats, query, mentionCopy).map((value) => ({ + type: 'chats' as const, id: value.reviewSessionId, value, })), - ...filterLogs(sources.logsList, normalizedQuery).map((value) => ({ - type: 'Logs' as const, + ...filterLogs(sources.logsList, query, monitorCopy).map((value) => ({ + type: 'logs' as const, id: value.id, value, })), ] } -export function getMentionSubmenuTitle(submenu: MentionSubmenu) { - if (isCopilotWorkspaceEntityMentionOption(submenu)) { - return getCopilotWorkspaceEntityConfigForMentionOption(submenu).submenuTitle - } - - if (submenu === 'Knowledge') { - return 'Knowledge Bases' - } - - return submenu -} - export function getPreferredMentionMenuWidth( openSubmenuFor: MentionSubmenu | null, aggregatedActive: boolean, containerWidth: number ) { const preferredWidth = - openSubmenuFor === 'Blocks' + openSubmenuFor === 'blocks' ? 320 - : openSubmenuFor === 'Logs' || - openSubmenuFor === 'Custom Tools' || - openSubmenuFor === 'MCP Servers' || + : openSubmenuFor === 'logs' || + openSubmenuFor === 'custom_tool' || + openSubmenuFor === 'mcp_server' || aggregatedActive ? 384 : 224 diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/types.ts b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/types.ts index 528c7a2a8..e3eaba554 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/types.ts +++ b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/types.ts @@ -3,10 +3,7 @@ import type { ComponentType } from 'react' import type { CopilotAccessLevel } from '@/lib/copilot/access-policy' import type { ChatContext } from '@/stores/copilot/types' -import type { - CopilotWorkspaceEntityKind, - CopilotWorkspaceEntityMentionOption, -} from '../../workspace-entities' +import type { CopilotWorkspaceEntityKind } from '../../workspace-entities' export interface MessageFileAttachment { id: string @@ -54,15 +51,15 @@ export interface UserInputRef { } export type MentionOption = - | 'Chats' - | CopilotWorkspaceEntityMentionOption - | 'Workflow Blocks' - | 'Blocks' - | 'Knowledge' - | 'Docs' - | 'Logs' + | 'chats' + | CopilotWorkspaceEntityKind + | 'workflow_blocks' + | 'blocks' + | 'knowledge' + | 'docs' + | 'logs' -export type MentionSubmenu = Exclude +export type MentionSubmenu = Exclude export interface MentionPortalStyle { top: number @@ -76,6 +73,7 @@ export interface MentionRange { start: number end: number label: string + contextKey: string } export interface PastChatItem { @@ -91,7 +89,6 @@ export interface WorkspaceEntityItem { name: string color?: string description?: string - functionName?: string transport?: string enabled?: boolean connectionStatus?: string 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 37580b38e..e1ac7b49f 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 @@ -8,22 +8,7 @@ const sortByRecent = (item return rightTime - leftTime }) -export function getWorkspaceEntityMentionEmptyState( - entityKind: CopilotWorkspaceEntityKind -): string { - switch (entityKind) { - case 'workflow': - return 'No workflows' - case 'skill': - return 'No skills' - case 'indicator': - return 'No indicators' - case 'custom_tool': - return 'No custom tools' - case 'mcp_server': - return 'No MCP servers' - } -} +const toTrimmedString = (value: unknown) => (typeof value === 'string' ? value.trim() : '') export async function loadWorkspaceEntityMentionItems( entityKind: CopilotWorkspaceEntityKind, @@ -57,51 +42,52 @@ export async function loadWorkspaceEntityMentionItems( switch (entityKind) { case 'workflow': - return sortByRecent(Array.isArray(data?.data) ? data.data : []).flatMap((item: any) => - item.id && item.name - ? [ - { - entityKind, - id: item.id, - name: item.name, - color: item.color, - }, - ] - : [] - ) + return sortByRecent(Array.isArray(data?.data) ? data.data : []) + .filter((item: any) => item.id) + .map((item: any) => ({ + entityKind, + id: item.id, + name: toTrimmedString(item.name), + color: item.color, + })) case 'skill': - return sortByRecent(Array.isArray(data?.data) ? data.data : []).map((item: any) => ({ - entityKind, - id: item.id, - name: item.name || 'Untitled Skill', - description: item.description || '', - })) + return sortByRecent(Array.isArray(data?.data) ? data.data : []) + .filter((item: any) => item.id) + .map((item: any) => ({ + entityKind, + id: item.id, + name: toTrimmedString(item.name), + description: toTrimmedString(item.description), + })) case 'indicator': - return sortByRecent(Array.isArray(data?.data) ? data.data : []).map((item: any) => ({ - entityKind, - id: item.id, - name: item.name || 'Untitled Indicator', - color: item.color, - })) + return sortByRecent(Array.isArray(data?.data) ? data.data : []) + .filter((item: any) => item.id) + .map((item: any) => ({ + entityKind, + id: item.id, + name: toTrimmedString(item.name), + color: item.color, + })) case 'custom_tool': - return sortByRecent(Array.isArray(data?.data) ? data.data : []).map((item: any) => ({ - entityKind, - id: item.id, - name: item.title || item.schema?.function?.name || 'Untitled Tool', - description: item.schema?.function?.description || '', - functionName: item.schema?.function?.name || '', - })) + return sortByRecent(Array.isArray(data?.data) ? data.data : []) + .filter((item: any) => item.id) + .map((item: any) => ({ + entityKind, + id: item.id, + name: toTrimmedString(item.title), + description: toTrimmedString(item.schema?.function?.description), + })) case 'mcp_server': - return sortByRecent(Array.isArray(data?.data?.servers) ? data.data.servers : []).map( - (item: any) => ({ + return sortByRecent(Array.isArray(data?.data?.servers) ? data.data.servers : []) + .filter((item: any) => item.id) + .map((item: any) => ({ entityKind, id: item.id, - name: item.name || 'Untitled MCP Server', - description: item.description || '', - transport: item.transport || 'http', + name: toTrimmedString(item.name), + description: toTrimmedString(item.description), + transport: toTrimmedString(item.transport), enabled: item.enabled, connectionStatus: item.connectionStatus, - }) - ) + })) } } diff --git a/apps/tradinggoose/widgets/widgets/copilot/live-contexts.test.ts b/apps/tradinggoose/widgets/widgets/copilot/live-contexts.test.ts index 908a043f6..34e68ac02 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/live-contexts.test.ts +++ b/apps/tradinggoose/widgets/widgets/copilot/live-contexts.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from 'vitest' import { buildImplicitCopilotContexts, resolveCopilotWorkflowId } from './live-contexts' +const currentLabels = { + workflow: 'Localized Workflow', + skill: 'Localized Skill', + custom_tool: 'Localized Tool', + indicator: 'Localized Indicator', + mcp_server: 'Localized MCP Server', +} + describe('buildImplicitCopilotContexts', () => { it('emits current workflow and active editable entity contexts from pair state', () => { expect( @@ -10,19 +18,20 @@ describe('buildImplicitCopilotContexts', () => { workflowId: 'workflow-pair', skillId: 'skill-1', }, + currentLabels, }) ).toEqual([ { kind: 'current_workflow', workflowId: 'workflow-pair', workspaceId: 'workspace-1', - label: 'Current Workflow', + label: 'Localized Workflow', }, { kind: 'current_skill', skillId: 'skill-1', workspaceId: 'workspace-1', - label: 'Current Skill', + label: 'Localized Skill', }, ]) }) @@ -37,13 +46,14 @@ describe('buildImplicitCopilotContexts', () => { buildImplicitCopilotContexts({ workspaceId: 'workspace-1', pairContext, + currentLabels, }) ).toEqual([ { kind: 'current_workflow', workflowId: 'workflow-pair', workspaceId: 'workspace-1', - label: 'Current Workflow', + label: 'Localized Workflow', }, ]) }) @@ -53,6 +63,7 @@ describe('buildImplicitCopilotContexts', () => { buildImplicitCopilotContexts({ workspaceId: 'workspace-1', pairContext: {}, + currentLabels, }) ).toEqual([]) }) diff --git a/apps/tradinggoose/widgets/widgets/copilot/live-contexts.ts b/apps/tradinggoose/widgets/widgets/copilot/live-contexts.ts index 15c663bf6..ac81cf9b3 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/live-contexts.ts +++ b/apps/tradinggoose/widgets/widgets/copilot/live-contexts.ts @@ -4,12 +4,14 @@ import { normalizePairColorContext, type PairColorContext } from '@/stores/dashb import { buildCopilotWorkspaceEntityContext, COPILOT_WORKSPACE_ENTITY_CONFIGS, + type CopilotWorkspaceEntityKind, getCopilotWorkspaceEntityIdFromPairContext, } from './workspace-entities' type BuildImplicitCopilotContextsOptions = { workspaceId?: string | null pairContext?: PairColorContext | null + currentLabels: Record } export function resolveCopilotWorkflowId( @@ -21,6 +23,7 @@ export function resolveCopilotWorkflowId( export const buildImplicitCopilotContexts = ({ workspaceId, pairContext, + currentLabels, }: BuildImplicitCopilotContextsOptions): ChatContext[] => { // These contexts describe what the user is looking at right now. They are sent // with each turn, but they do not mount or select editable review sessions. @@ -42,6 +45,7 @@ export const buildImplicitCopilotContexts = ({ entityKind: config.entityKind, entityId, workspaceId: resolvedWorkspaceId, + label: currentLabels[config.entityKind], current: true, }) ) diff --git a/apps/tradinggoose/widgets/widgets/copilot/workspace-entities.test.ts b/apps/tradinggoose/widgets/widgets/copilot/workspace-entities.test.ts index 9d3a856be..bdbb669a2 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/workspace-entities.test.ts +++ b/apps/tradinggoose/widgets/widgets/copilot/workspace-entities.test.ts @@ -13,6 +13,7 @@ describe('workspace-entities', () => { buildCopilotWorkspaceEntityContext({ entityKind: 'workflow', entityId: 'workflow-1', + label: 'Current Workflow', current: true, }) ).toEqual({ diff --git a/apps/tradinggoose/widgets/widgets/copilot/workspace-entities.ts b/apps/tradinggoose/widgets/widgets/copilot/workspace-entities.ts index 8546cbc6f..a565cdb1b 100644 --- a/apps/tradinggoose/widgets/widgets/copilot/workspace-entities.ts +++ b/apps/tradinggoose/widgets/widgets/copilot/workspace-entities.ts @@ -12,54 +12,34 @@ import type { PairColorContext } from '@/stores/dashboard/pair-store' type CopilotWorkspaceEntityConfig = { entityKind: ReviewEntityKind - mentionOption: string - submenuTitle: string - currentLabel: string idField: 'workflowId' | 'skillId' | 'indicatorId' | 'customToolId' | 'mcpServerId' } export const COPILOT_WORKSPACE_ENTITY_CONFIGS = [ { entityKind: ENTITY_KIND_WORKFLOW, - mentionOption: 'Workflows', - submenuTitle: 'All workflows', - currentLabel: 'Current Workflow', idField: 'workflowId', }, { entityKind: ENTITY_KIND_SKILL, - mentionOption: 'Skills', - submenuTitle: 'Skills', - currentLabel: 'Current Skill', idField: 'skillId', }, { entityKind: ENTITY_KIND_CUSTOM_TOOL, - mentionOption: 'Custom Tools', - submenuTitle: 'Custom Tools', - currentLabel: 'Current Tool', idField: 'customToolId', }, { entityKind: ENTITY_KIND_INDICATOR, - mentionOption: 'Indicators', - submenuTitle: 'Indicators', - currentLabel: 'Current Indicator', idField: 'indicatorId', }, { entityKind: ENTITY_KIND_MCP_SERVER, - mentionOption: 'MCP Servers', - submenuTitle: 'MCP Servers', - currentLabel: 'Current MCP Server', idField: 'mcpServerId', }, ] as const satisfies readonly CopilotWorkspaceEntityConfig[] export type CopilotWorkspaceEntityKind = (typeof COPILOT_WORKSPACE_ENTITY_CONFIGS)[number]['entityKind'] -export type CopilotWorkspaceEntityMentionOption = - (typeof COPILOT_WORKSPACE_ENTITY_CONFIGS)[number]['mentionOption'] type CopilotWorkspaceEntityContextDetails = { entityKind: CopilotWorkspaceEntityKind entityId: string | null @@ -76,19 +56,9 @@ const COPILOT_WORKSPACE_ENTITY_CONFIG_BY_KIND = new Map< (typeof COPILOT_WORKSPACE_ENTITY_CONFIGS)[number] >(COPILOT_WORKSPACE_ENTITY_CONFIGS.map((config) => [config.entityKind, config])) -const COPILOT_WORKSPACE_ENTITY_CONFIG_BY_MENTION_OPTION = new Map< - CopilotWorkspaceEntityMentionOption, - (typeof COPILOT_WORKSPACE_ENTITY_CONFIGS)[number] ->( - COPILOT_WORKSPACE_ENTITY_CONFIGS.map((config) => [ - config.mentionOption as CopilotWorkspaceEntityMentionOption, - config, - ]) -) - export const COPILOT_WORKSPACE_ENTITY_MENTION_OPTIONS = COPILOT_WORKSPACE_ENTITY_CONFIGS.map( - (config) => config.mentionOption -) as CopilotWorkspaceEntityMentionOption[] + (config) => config.entityKind +) as CopilotWorkspaceEntityKind[] export function getCopilotWorkspaceEntityConfig( entityKind: CopilotWorkspaceEntityKind @@ -102,30 +72,10 @@ export function getCopilotWorkspaceEntityConfig( return config } -export function getCopilotWorkspaceEntityConfigForMentionOption( - mentionOption: CopilotWorkspaceEntityMentionOption -): (typeof COPILOT_WORKSPACE_ENTITY_CONFIGS)[number] { - const config = COPILOT_WORKSPACE_ENTITY_CONFIG_BY_MENTION_OPTION.get(mentionOption) - - if (!config) { - throw new Error(`Unknown copilot workspace entity mention option: ${mentionOption}`) - } - - return config -} - export function isCopilotWorkspaceEntityMentionOption( value: string -): value is CopilotWorkspaceEntityMentionOption { - return COPILOT_WORKSPACE_ENTITY_CONFIG_BY_MENTION_OPTION.has( - value as CopilotWorkspaceEntityMentionOption - ) -} - -export function getCopilotWorkspaceEntityKindFromMentionOption( - mentionOption: CopilotWorkspaceEntityMentionOption -): CopilotWorkspaceEntityKind { - return getCopilotWorkspaceEntityConfigForMentionOption(mentionOption).entityKind +): value is CopilotWorkspaceEntityKind { + return COPILOT_WORKSPACE_ENTITY_KIND_SET.has(value) } export function getCopilotWorkspaceEntityKindFromContext( @@ -216,11 +166,11 @@ export function buildCopilotWorkspaceEntityContext({ entityKind: CopilotWorkspaceEntityKind entityId: string workspaceId?: string | null - label?: string + label: string current?: boolean }): ChatContext { const config = getCopilotWorkspaceEntityConfig(entityKind) - const resolvedLabel = label?.trim() || (current ? config.currentLabel : config.mentionOption) + const resolvedLabel = label.trim() const normalizedWorkspaceId = normalizeOptionalString(workspaceId) const baseContext = { ...(normalizedWorkspaceId ? { workspaceId: normalizedWorkspaceId } : {}), 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 d7404a035..0373f0671 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 @@ -157,7 +157,6 @@ describe('CustomToolEditor export', () => { schema: { type: 'function', function: { - name: 'fetchTopMovers', description: 'Fetch top moving symbols.', parameters: { type: 'object', @@ -198,7 +197,6 @@ describe('CustomToolEditor export', () => { { type: 'function', function: { - name: 'fetchTopMoversCurrent', description: 'Fetch top moving symbols.', parameters: { type: 'object', @@ -273,7 +271,6 @@ describe('CustomToolEditor export', () => { schema: { type: 'function', function: { - name: 'fetchTopMoversCurrent', description: 'Fetch top moving symbols.', parameters: { type: 'object', @@ -306,7 +303,6 @@ describe('CustomToolEditor export', () => { schema: { type: 'function', function: { - name: 'fetchTopMovers', parameters: { type: 'object', properties: {}, 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 49052cc06..b21201fe7 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,6 +1,5 @@ import { type MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AlertTriangle, Code, FileJson } from 'lucide-react' -import { useLocale } from 'next-intl' import { createMonacoFunctionBodyDiagnosticSourceBuilder, type MonacoEditorHandle, @@ -10,13 +9,12 @@ import { Label } from '@/components/ui/label' import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' 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 { formatTemplate } from '@/i18n/utils' -import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' import { useUpdateCustomTool } from '@/hooks/queries/custom-tools' import { useWand } from '@/hooks/workflow/use-wand' -import { useCustomToolsStore } from '@/stores/custom-tools/store' +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' @@ -110,46 +108,50 @@ export function CustomToolEditor({ return } + let parsed: any try { - const parsed = JSON.parse(value) - - if (!parsed.type || parsed.type !== 'function') { - setSchemaError(copy.validation.missingTypeFunction) - return - } + parsed = JSON.parse(value) + } catch { + setSchemaError(copy.validation.invalidJsonFormat) + return + } - if (!parsed.function || !parsed.function.name) { - setSchemaError(copy.validation.missingFunctionName) - return - } + if (!parsed.type || parsed.type !== 'function') { + setSchemaError(copy.validation.missingTypeFunction) + return + } - if (!parsed.function.parameters) { - setSchemaError(copy.validation.missingFunctionParameters) - return - } + if (!parsed.function || !parsed.function.parameters) { + setSchemaError(copy.validation.missingFunctionParameters) + return + } - if (!parsed.function.parameters.type) { - setSchemaError(copy.validation.missingParametersType) - return - } + if (!parsed.function.parameters.type) { + setSchemaError(copy.validation.missingParametersType) + return + } - if (parsed.function.parameters.properties === undefined) { - setSchemaError(copy.validation.missingParametersProperties) - return - } + if (parsed.function.parameters.properties === undefined) { + setSchemaError(copy.validation.missingParametersProperties) + return + } - if ( - typeof parsed.function.parameters.properties !== 'object' || - parsed.function.parameters.properties === null - ) { - setSchemaError(copy.validation.parametersPropertiesMustBeObject) - return - } + if ( + typeof parsed.function.parameters.properties !== 'object' || + parsed.function.parameters.properties === null + ) { + setSchemaError(copy.validation.parametersPropertiesMustBeObject) + return + } - setSchemaError(null) + try { + CustomToolOpenAiSchema.parse(parsed) } catch { - setSchemaError(copy.validation.invalidJsonFormat) + setSchemaError(copy.validation.failedToValidateSchema) + return } + + setSchemaError(null) } const handleFunctionCodeChange = (value: string) => { @@ -169,7 +171,6 @@ The output MUST be a single, valid JSON object, starting with { and ending with The JSON schema MUST follow this specific format: 1. Top-level property "type" must be set to "function" 2. A "function" object containing: - - "name": A concise, camelCase name for the function - "description": A clear description of what the function does - "parameters": A JSON Schema object describing the function's parameters with: - "type": "object" @@ -275,12 +276,7 @@ IMPORTANT FORMATTING RULES: try { const parsed = JSON.parse(jsonSchema) - return Boolean( - parsed.type === 'function' && - parsed.function?.name && - parsed.function?.parameters?.type && - parsed.function?.parameters?.properties !== undefined - ) + return CustomToolOpenAiSchema.safeParse(parsed).success } catch { return false } @@ -313,12 +309,6 @@ IMPORTANT FORMATTING RULES: return null } - if (!schema.function || !schema.function.name) { - setSchemaError(copy.validation.schemaMustHaveFunctionName) - onSectionChange('schema') - return null - } - if (!schema.function.parameters) { setSchemaError(copy.validation.missingFunctionParameters) onSectionChange('schema') @@ -346,7 +336,7 @@ IMPORTANT FORMATTING RULES: return null } - return schema + return CustomToolOpenAiSchema.parse(schema) } catch (error) { logger.error('Error validating custom tool schema:', { error }) setSchemaError(copy.validation.failedToValidateSchema) @@ -364,27 +354,11 @@ IMPORTANT FORMATTING RULES: return } - const nextToolName = schema.function.name - const existingTools = useCustomToolsStore.getState().getAllTools(workspaceId) - const isDuplicate = existingTools.some((tool) => { - if (tool.id === initialValues.id) { - return false - } - - return tool.schema.function.name === nextToolName - }) - - if (isDuplicate) { - setSchemaError(formatTemplate(copy.validation.duplicateName, { name: nextToolName })) - onSectionChange('schema') - return - } - await updateToolMutation.mutateAsync({ workspaceId, toolId: initialValues.id, updates: { - title: nextToolName, + title: initialValues.title, schema, code: functionCode || '', }, @@ -400,6 +374,7 @@ IMPORTANT FORMATTING RULES: parseCurrentSchema, functionCode, initialValues.id, + initialValues.title, onSave, onSectionChange, updateToolMutation, @@ -412,7 +387,7 @@ IMPORTANT FORMATTING RULES: return } - const title = initialValues.title.trim() || schema.function.name + const title = initialValues.title.trim() const fileNameBase = title .trim() @@ -644,7 +619,6 @@ IMPORTANT FORMATTING RULES: placeholder={`{ "type": "function", "function": { - "name": "addItemToOrder", "description": "", "parameters": { "type": "object", @@ -805,7 +779,7 @@ IMPORTANT FORMATTING RULES: }} >
-
+
{copy.form.availableParametersPanel}
diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx index ce18c6091..249c68714 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx @@ -5,6 +5,7 @@ import { Server, WrenchIcon, XIcon } from 'lucide-react' import { useLocale } from 'next-intl' import { Toggle } from '@/components/ui/toggle' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { createCustomToolRuntimeId } from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import type { OAuthProvider } from '@/lib/oauth/oauth' import { sanitizeSolidIconColor } from '@/lib/ui/icon-colors' @@ -369,7 +370,7 @@ export function ToolInput({ blockId, subBlockId, isConnecting, disabled = false const newTool: StoredTool = { type: 'custom-tool', title: customTool.title, - toolId: `custom_${customTool.id}`, + toolId: createCustomToolRuntimeId(customTool.id), params: {}, isExpanded: true, schema: customTool.schema, diff --git a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.test.tsx b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.test.tsx index a30584350..26aac8b71 100644 --- a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.test.tsx +++ b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.test.tsx @@ -145,7 +145,6 @@ describe('Custom Tool List header controls', () => { schema: { type: 'function', function: { - name: 'fetchTopMovers', parameters: { type: 'object', properties: {}, @@ -211,7 +210,6 @@ describe('Custom Tool List header controls', () => { schema: { type: 'function', function: { - name: 'fetchTopMovers', parameters: { type: 'object', properties: {}, diff --git a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx index 3e91b0cc9..c4820d423 100644 --- a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx @@ -61,26 +61,25 @@ const sortCustomTools = (tools: CustomToolDefinition[]) => }) const buildNewCustomToolDraft = (tools: CustomToolDefinition[]) => { - const existingNames = new Set( + const existingTitles = new Set( tools - .map((tool) => tool.schema?.function?.name?.trim()) - .filter((name): name is string => Boolean(name)) + .map((tool) => tool.title.trim()) + .filter((title): title is string => Boolean(title)) ) - let nextName = DEFAULT_CUSTOM_TOOL_NAME + let nextTitle = DEFAULT_CUSTOM_TOOL_NAME let suffix = 2 - while (existingNames.has(nextName)) { - nextName = `${DEFAULT_CUSTOM_TOOL_NAME}${suffix}` + while (existingTitles.has(nextTitle)) { + nextTitle = `${DEFAULT_CUSTOM_TOOL_NAME}${suffix}` suffix += 1 } return { - title: nextName, + title: nextTitle, schema: { type: 'function', function: { - name: nextName, description: '', parameters: { type: 'object', diff --git a/changelog/June-23-2026.md b/changelog/June-23-2026.md new file mode 100644 index 000000000..f390eb08e --- /dev/null +++ b/changelog/June-23-2026.md @@ -0,0 +1,85 @@ +# June-23-2026 + +## feat/copilot_mentions_translations @ 2f67321c vs upstream/staging + +### Summary +- Localizes Copilot mention menu labels, empty states, untitled names, current-context labels, and workflow block names through the existing `next-intl` workspace message surface. +- Expands Copilot mention search to aggregate workflow blocks, workspace entities, custom tools, MCP servers, knowledge bases, logs, blocks, and past chats while preserving duplicate mention identity and punctuation boundaries. +- Makes custom tool titles display-only and `custom_` runtime IDs the canonical execution, workflow persistence, provider-tool, import/export, and validation contract. +- Carries workspace-only execution scope through Function execution, custom tools, MCP tools, provider tool execution, trace spans, indicators, and workflow validation. + +### Branch Scope +- Compared `3c0cca3b721c760d7051be39b7bfec72996c6c58..2f67321c`, where `3c0cca3b721c760d7051be39b7bfec72996c6c58` is the merge base with the available local `origin/staging` ref. +- The user requested `upstream/staging` instead of `upstream/main`, but this checkout has no `upstream` remote or `upstream/staging` ref. `git fetch upstream staging` failed before network access because the sandbox cannot write `.git/FETCH_HEAD`, so this entry used the available staging ref and records that limitation. +- `git status --short --untracked-files=all`, `git diff --stat HEAD`, and `git diff --name-status HEAD` were clean before this changelog file was created; no uncommitted feature work was visible or included. +- Main areas touched: Copilot mention input/menu/source hooks, workspace entity live-context helpers, i18n message bundles, custom tool schemas/import-export/UI/runtime resolution, Agent block tool handling and skill loading, MCP tool IDs, Function execution API/service/runtime helpers, indicator runtime entries, workflow validation, trace span typing, and focused tests. + +### Key Changes +- `apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-copy.ts` centralizes mention menu copy in `getCopilotMentionCopy()` and `useCopilotMentionCopy()`, sourcing option labels, submenu titles, empty states, untitled labels, loading text, and match labels from `apps/tradinggoose/i18n/messages/{en,es,zh}.json`. +- `apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-utils.ts` adds localized filtering helpers, `buildAggregatedMentionItems()`, `isMentionBoundary()`, duplicate-label range handling in `buildMentionRanges()`, and `upsertMentionContextByTextOrder()` so repeated labels map to stable context identity keys in text order. +- `apps/tradinggoose/widgets/widgets/copilot/components/user-input/hooks/use-user-input-mentions.ts` uses the centralized copy and monitor copy, loads all mention submenus for aggregate search, inserts localized labels for docs/past chats/workspace entities/logs, keeps deleted mentions removed, and preserves duplicate mention contexts instead of collapsing them by label. +- `apps/tradinggoose/widgets/widgets/copilot/components/user-input/hooks/use-user-input-mention-sources.ts` localizes block and workflow block names with `getLocalizedBlockNameWithCopy()` and `getLocalizedDefaultBlockNameWithCopy()`, sorts by active locale, and loads workspace entities through `loadWorkspaceEntityMentionItems()`. +- `apps/tradinggoose/widgets/widgets/copilot/components/user-input/components/mention-menu.tsx` renders localized main options, submenu titles, empty states, aggregate search results, custom tool/MCP badges, and localized log trigger labels through `mention-copy.ts`. +- `apps/tradinggoose/widgets/widgets/copilot/workspace-entities.ts` introduces `COPILOT_WORKSPACE_ENTITY_CONFIGS`, `COPILOT_WORKSPACE_ENTITY_MENTION_OPTIONS`, `buildCopilotWorkspaceEntityContext()`, `readCopilotWorkspaceEntityContext()`, and related readers so workflow, skill, custom tool, indicator, and MCP contexts share one kind/id mapping. +- `apps/tradinggoose/widgets/widgets/copilot/live-contexts.ts` now builds implicit current workspace entity contexts from pair state with localized labels supplied by `apps/tradinggoose/widgets/widgets/copilot/components/copilot/copilot.tsx`. +- `apps/tradinggoose/lib/copilot/chat-contexts.ts` dedupes explicit and implicit contexts through `buildCopilotContextIdentityKey()`, hides `current_*` implicit contexts from explicit context chips, and lets `mergeCopilotContexts()` keep explicit selections ahead of ambient current context. +- `apps/tradinggoose/lib/custom-tools/schema.ts` adds `CUSTOM_TOOL_RUNTIME_ID_PREFIX`, `createCustomToolRuntimeId()`, `isCustomToolRuntimeId()`, `getCustomToolEntityIdFromRuntimeId()`, and `buildCustomToolModelDescription()`. `CustomToolOpenAiSchema` no longer accepts `function.name`; `title` is the canonical custom tool name. +- `apps/tradinggoose/lib/custom-tools/import-export.ts` exports/imports custom tools as `{ title, schema, code }` inside the shared TradingGoose export envelope and resolves title collisions with `resolveImportedCustomTools()` instead of treating schema function names as identity. +- `apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/tool-input.tsx` stores selected custom tools with `toolId: createCustomToolRuntimeId(customTool.id)`, while custom tool list/dropdown/editor files display and mutate `title` separately from entity IDs. +- `apps/tradinggoose/providers/ai/utils.ts`, `apps/tradinggoose/tools/utils.ts`, and `apps/tradinggoose/tools/index.ts` resolve custom tools only through `custom_`, keep provider tool `name` as the display title, preserve workspace-only `_context`, and reject workspace-required executions without `workspaceId`. +- `apps/tradinggoose/executor/handlers/agent/agent-handler.ts` requires runtime custom tool IDs through `getCustomToolEntityIdFromRuntimeId()`, emits MCP tools with `createMcpToolId(serverId, toolName)`, passes workspace/workflow scope into MCP discovery/execution, and adds exact-ID skill loading through `skill-loader.ts` and `skills-resolver.ts`. +- `apps/tradinggoose/app/api/function/execute/route.ts` now accepts exactly one of `workflowId` or `workspaceId`, checks write access to the resolved workspace, and delegates runtime work to `executeFunctionRequest()` in `apps/tradinggoose/lib/function/execution.ts`. +- `apps/tradinggoose/lib/function/execution.ts`, `apps/tradinggoose/app/api/function/e2b-execution.ts`, and `apps/tradinggoose/app/api/function/local-execution.ts` share usage-limit checks, billing accrual, custom indicator runtime manifests, E2B/local VM execution, stdout capture, and user-friendly error formatting for both workflow and workspace-scoped Function execution. +- `apps/tradinggoose/lib/indicators/default/runtime.ts`, `apps/tradinggoose/lib/indicators/custom/operations.ts`, `apps/tradinggoose/lib/indicators/execution/function-indicator-runtime.ts`, and `apps/tradinggoose/app/api/indicators/options/route.ts` make indicator runtime calls use `runtimeId`/`id` with normalized input metadata and no separate runtime display-name dependency. +- `apps/tradinggoose/lib/workflows/validation.ts` sanitizes Agent block custom tools by requiring canonical `custom_` tool IDs and removes invalid legacy/title-shaped custom tool entries when sanitization is enabled. +- `apps/tradinggoose/lib/logs/execution/trace-spans/trace-spans.ts` and `apps/tradinggoose/lib/logs/types.ts` allow tool-call IDs in trace output and match provider timing tool segments by either tool call ID or display name. + +### Design Decisions +- Mention text is display copy, not identity. Mentions store `ChatContext` identity through `buildCopilotContextIdentityKey()`, while labels can be localized, duplicated, or refreshed without changing the selected entity. +- Workspace entity context ownership lives in `apps/tradinggoose/widgets/widgets/copilot/workspace-entities.ts`. Future entity kinds should extend `COPILOT_WORKSPACE_ENTITY_CONFIGS` instead of adding separate mention/live-context switch statements. +- The mention menu now prefers one localized copy contract in `mention-copy.ts`; components and hooks receive formatted strings from that helper instead of importing raw JSON paths or hard-coded English labels. +- Custom tool entity IDs and runtime IDs are intentionally separate from display titles. The runtime identifier is `custom_`; the LLM-facing/provider `name` remains the human title. +- Function execution no longer requires a workflow when a workspace is enough. The route enforces exactly one scope and the service normalizes both paths through the same permission, billing, runtime, and indicator setup. +- Skill loading inside Agent blocks is exact-ID based. Skill names are display metadata in the system prompt, and `buildLoadSkillTool()` instructs the model to pass `skill_id` from the listed ids. +- Indicator runtime contracts now use callable IDs instead of duplicated runtime names so Function-block examples, option lists, and Copilot metadata all point at `runtimeId`. + +### Shared Contracts and Helpers to Reuse +- Use `getCopilotMentionCopy()` and `useCopilotMentionCopy()` from `apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-copy.ts` for every mention label, submenu title, empty state, and untitled fallback. +- Use `buildMentionRanges()`, `isMentionBoundary()`, `upsertMentionContextByTextOrder()`, `filterMentionItems()`, and `buildAggregatedMentionItems()` from `apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-utils.ts`; do not recreate mention parsing or duplicate-label matching in UI code. +- Use `COPILOT_WORKSPACE_ENTITY_CONFIGS`, `COPILOT_WORKSPACE_ENTITY_MENTION_OPTIONS`, `buildCopilotWorkspaceEntityContext()`, `readCopilotWorkspaceEntityContext()`, and `matchesCopilotWorkspaceEntityContext()` from `apps/tradinggoose/widgets/widgets/copilot/workspace-entities.ts` when adding Copilot workspace entity context behavior. +- Use `mergeCopilotContexts()` and `buildCopilotContextIdentityKey()` from `apps/tradinggoose/lib/copilot/chat-contexts.ts` to combine explicit mention selections with implicit current workspace context. +- Use `createCustomToolRuntimeId()`, `isCustomToolRuntimeId()`, and `getCustomToolEntityIdFromRuntimeId()` from `apps/tradinggoose/lib/custom-tools/schema.ts` for all custom tool execution IDs. +- Use `CustomToolOpenAiSchema`, `CustomToolTransferSchema`, `createCustomToolsExportFile()`, `parseImportedCustomToolsFile()`, and `resolveImportedCustomTools()` for custom tool documents/imports; the canonical custom tool transfer record is `{ title, schema, code }`. +- Use `executeFunctionRequest()` from `apps/tradinggoose/lib/function/execution.ts` for server-side Function execution behavior instead of duplicating route logic. +- Use `createMcpToolId()` and `parseMcpToolId()` from `apps/tradinggoose/lib/mcp/utils.ts` for MCP execution identifiers. +- Use `buildLoadSkillTool()`, `createSkillLoaderToolId()`, `isSkillLoaderExecution()`, and `resolveSkillContent()` for Agent skill activation; exact skill IDs are required. +- Use `listCustomIndicatorRuntimeEntries()` and `DEFAULT_INDICATOR_RUNTIME_ENTRIES` to build Function indicator runtime manifests, and pass saved input titles through `inputMeta`. + +### Removed or Replaced Items +- Removed hard-coded Copilot mention labels and empty-state text from the mention menu/hook path. Use `mention-copy.ts` plus `apps/tradinggoose/i18n/messages/{en,es,zh}.json`. +- Removed label-only mention identity behavior. Do not match selected contexts by mention text alone; use `buildCopilotContextIdentityKey()`. +- Removed `function.name` from custom tool schemas and import/export identity. Do not reintroduce schema function names for custom tools; use document `title` for display and entity/runtime IDs for identity. +- Removed title-based custom tool execution lookup. Do not call `getTool()` or `getToolAsync()` with `custom_`; store and execute `custom_<entityId>`. +- Replaced ad hoc workspace entity context switch logic with `COPILOT_WORKSPACE_ENTITY_CONFIGS` and `buildCopilotWorkspaceEntityContext()`. +- Replaced route-owned Function execution internals with `executeFunctionRequest()`. Do not add separate workflow-only execution paths back into `apps/tradinggoose/app/api/function/execute/route.ts`. +- Removed the assumption that Function execution must always have `workflowId`; workspace-scoped calls should pass `workspaceId` and no workflow ID. +- Removed custom indicator runtime display names from Function runtime lookup. Use callable indicator IDs and `runtimeId` from indicator option/list surfaces. +- Removed stale custom tool/MCP/agent tests that asserted legacy title or stale MCP resolution behavior; the replacement tests assert runtime IDs, exact skill IDs, workspace scope, and canonical import/export shapes. + +### Future Branch Guardrails +- Do not add new Copilot mention copy directly in React components. Add message keys under `workspace.widgets.copilot.mentions` or existing widget namespaces and consume them through `getCopilotMentionCopy()`. +- Do not introduce new workspace entity context helpers outside `workspace-entities.ts`; extend the config array and reuse the readers/builders. +- Do not persist or execute custom tools by title. Workflow Agent tool selections, provider tool transforms, runtime execution, validation, and persistence must use `custom_<entityId>`. +- Do not put a `name` property inside custom tool `schema.function`; custom tool titles live in `title` and schema text remains an OpenAI function schema without identity. +- Do not restore workflow-only Function execution assumptions. If a tool can run at workspace scope, pass `workspaceId` through `_context` and let `executeFunctionRequest()` enforce access, usage, billing, and runtime setup. +- Do not duplicate MCP tool ID parsing. Keep `mcp-<serverId>-<toolName>` generation/parsing in `apps/tradinggoose/lib/mcp/utils.ts`. +- Do not make Agent skill loading name-based. `skill_id` must be the exact saved skill id, while names/descriptions stay prompt metadata. +- Do not reintroduce separate runtime indicator names. Copilot and Function code should call indicators by returned `runtimeId`/`id` and use `inputTitles` for override keys. + +### Validation Notes +- Used the requested `staging-changelog` workflow and followed `changelog/TEMPLATE.md`, with an explicit requested base 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 branch -r`, `git merge-base origin/staging HEAD`, `git log --oneline 3c0cca3b721c760d7051be39b7bfec72996c6c58..HEAD`, `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`. +- `git fetch upstream staging` failed with `cannot open '.git/FETCH_HEAD': Read-only file system`; no `upstream/staging` ref exists in this clone. The available local staging evidence is `origin/staging` at `3c0cca3b721c760d7051be39b7bfec72996c6c58`. +- Inspected the repository instructions, `changelog/TEMPLATE.md`, recent changelog files, Copilot mention copy/utils/hooks/menu/tests, workspace entity/live-context helpers/tests, i18n messages, Copilot store context merge, custom tool schema/import-export/operations/UI/tests, provider/tool execution helpers/tests, Agent handler/skill loader/tests, Function execution route/service/runtime/tests, indicator runtime/options files, workflow validation/tests, trace span typing, and Copilot entity document helpers. +- Confirmed the branch delta has two added files (`apps/tradinggoose/lib/workflows/validation.test.ts` and `apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-copy.ts`), no deleted files, no renames, and no `*/migration/*` edits. +- 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 before editing, and template conformance. From 550ab035bd55379f1749eb313180af82b7962a41 Mon Sep 17 00:00:00 2001 From: Bruzzz BackUp <149516937+BWJ2310-backup@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:06:42 -0600 Subject: [PATCH 15/18] feat(copilot-mcp): expose TradingGoose tools to trusted agents (#157) * fix(mcp): reject reused device login approvals Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): avoid loading deployed workflow variables Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): authenticate device login API keys Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): apply reviewed tool changes atomically Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): prevent stale server tool review acceptance Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): handle malformed MCP batch entries Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(copilot): stage mutation tools for review Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): prevent sensitive tool payload exposure Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * docs: update project agent testing guidance Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): reject empty JSON-RPC batches Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): redact managed review secrets Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(mcp): use personal API keys for device login Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): issue device login keys during approval polling Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): preserve deployment metadata from saved state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): use shared personal api key auth Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): preserve variables during direct yjs persistence Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): persist workflow names through Yjs state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): read saved entities from bootstrapped Yjs state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(editors): back entity editors with Yjs sessions Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(workflows): export referenced skills from Yjs state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): consume device login approvals when issuing keys Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): fall back to direct state persistence on apply errors Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(custom-tools): reject duplicate titles on update Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): omit generated workflow ids from review staging Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): require workflow variable ids in documents Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * style(copilot): wrap copilot registry assertions Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(copilot): refresh state after server tool mutations Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): serialize server tool review acceptance Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): persist normalized workflow state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(auth): preserve locale on invalid MCP authorization Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): reject stale accepted server tool reviews Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): keep saved entity sessions noncanonical Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): preserve deployment metadata from live state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): separate saved entity draft persistence Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): handle missing normalized saved state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): scope environment variable writes to workspace Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * style(mcp): reorder authorize page imports Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(mcp): issue device login keys from approval state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(api-key): require complete key format for lookup Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): persist accepted reviewed entity updates Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(api-keys): bind generated key material to stored id Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): pass workspace context to environment writes Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * style(copilot): wrap credential prompt metadata Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * docs(mcp): clarify device grant retry window Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): make device login delivery idempotent Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): persist state before applying document updates Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): refresh stale saved target snapshots Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): make saved entity persistence best effort Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(copilot): scope environment variable mutations Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): align contracts after staging rebase * fix(copilot): align indicator runtime contracts * feat(yjs): persist saved entity session edits Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): preserve MCP server secrets in documents Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): apply snapshots before database persistence Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * test(copilot): update test files Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): normalize entity document fields Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * test(copilot): cover entity document normalization Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): require urls for URL transports Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(copilot): support personal scoped credential reads Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(yjs): simplify saved entity state sync Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com>\nCo-authored-by: BWJ2310 <brucewj2310@gmail.com>\nCo-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com>\nCo-authored-by: BWJ2310 <brucewj2310@gmail.com>\nCo-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(copilot): improve workflow editing and tool routing Expire stale server-tool review tokens, re-layout edited workflows when their direction changes, and update Copilot tool routing and cache invalidation around workspace-scoped data. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(workflows): persist workflow state to normalized tables Store newly created and duplicated workflows through normalized tables, avoid loading workflow state for metadata-only updates, and keep the materialized state selection aware of last-synced timestamps. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): stop deleting live sessions during cleanup Remove the internal Yjs session-delete path, keep saved entity and workflow sessions alive when persistence fails, and trim the socket server delete handlers that only supported that cleanup flow. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * test(trading): cache the order route handler in the test Load the order route handler once per test run instead of re-importing it for each assertion. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(workflows): preserve workflow variables during import and revert Keep workflow variables in exported/imported state, remap them on JSON import, and avoid fabricating empty variables on revert when the deployment snapshot does not include them. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): restore saved entity state from db on materialization failure If persisting the applied Yjs snapshot fails, rehydrate the socket server state from the database so the saved entity session stays usable. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(workflow): rename workflow metadata without republishing state Add a rename-only workflow apply path that updates Yjs metadata directly, avoids loading full workflow state for name-only changes, and keeps the socket server and copilot workflow tool in sync. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(indicators): carry color through copilot entity documents Add indicator color to the copilot entity document format, preserve explicit colors on indicator upserts, and stop rewriting color during saved-entity normalization. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * docs(changelog): add June 24 Copilot MCP branch summary Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): deny inaccessible workspaces in server tools Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): limit delivery retries when polling device login Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): restore workflow state after persistence failure Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): load normalized workflow state consistently Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): preserve refresh error cause Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(mcp-auth): rate-limit device login starts per requester Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(copilot-mcp): allowlist external server tools Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * style(yjs): reformat snapshot bridge import Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(api): add shared MCP rate limiting Extract endpoint rate-limit logic into a shared helper, apply it to the v1 middleware and copilot MCP route, and guard oversized JSON-RPC batches. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): use configured base URLs in installer flows Switch MCP login and install routes to the configured public base URL and quote generated shell and PowerShell scripts safely. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(copilot): make MCP tool exposure explicit Replace implicit tool exposure flags with an explicit MCP allow-list and update the router test coverage accordingly. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(ui): clear help modal scroll timer Cancel the delayed scroll effect when the help modal re-renders or unmounts to avoid stale timeouts. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(api-key): hash stored keys and simplify auth Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * build(config): remove API_ENCRYPTION_KEY from deployment samples Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * docs(api-key): remove API_ENCRYPTION_KEY references Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * style(ui): normalize tool call and copilot route formatting Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(auth): bind MCP approval to a user challenge Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(api-key): stop exposing raw API keys in lists Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(operations): prefetch existing workspace entities during upsert Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(auth): generate MCP api keys at approval time Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): scope public MCP login starts to the deployment Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(auth): sign MCP device login codes Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(workspace): split entity writes and clean up Yjs sessions Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(workflows): load workflow state through the Yjs snapshot bridge Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): harden device login approval challenge handling Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(api): scope endpoint rate limits by endpoint type Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): ignore malformed client configs when reading tokens Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): make socket cleanup non-blocking after data mutation Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): preserve cancelled device login state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): run accepted review before consuming token row Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): claim server tool review tokens before execution Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): derive approval tokens from device login code Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): persist deployment status from workflow state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): rate-limit device login starts and scope approvals Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): remove DB refresh fallback after persistence failures Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(indicator): surface save failures in code panel Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(workflow): surface auto-layout failures in control bar Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(auth): rate-limit MCP login starts Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(yjs): bootstrap live sessions from saved snapshots Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(copilot): expose mutation tools to trusted agents Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(workflows): move variable remapping into import export Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * test(yjs): cover bridge failure propagation Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(api-key): reject malformed keys before lookup Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(yjs): centralize state persistence in socket server Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(mcp): rate limit public MCP auth endpoints Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): discard idle documents after failed persistence Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * style(queue): format execution limiter test Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(workflows): sync workflow metadata through yjs state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(api): ignore user agent in rate limit keys Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(workflows): separate editable state from deployment metadata Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): always authenticate installer sessions Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(mcp): acknowledge device login token activation Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(workflows): read editable state from Yjs sessions Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): validate and persist workflow names Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * style(yjs): format workflow session tests Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): acknowledge login before config writes Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(yjs): support partial workflow patches Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(saved-entities): map duplicate names to validation errors Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): load live execution state through Yjs sessions Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): rewrite variable references on replacement Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): export skills from workspace store Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): reuse approved device login api key Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): skip idle persistence for clean documents Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): require explicit workflow variable removal intent Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): retry transient snapshot bridge fetches Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): clear reseed metadata after variable replacement Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(workflows): persist embedded custom tools Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): surface realtime orchestration failures Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(rate-limit): fail closed for public MCP auth limits Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): rate limit public MCP requests before auth Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): standardize realtime-required API responses Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): normalize MCP server header records Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): sanitize agent tools during normalized saves Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): require realtime state for editable workflows Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(knowledge): surface saved entity persistence errors Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): surface realtime orchestration failures Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(indicators): infer input metadata from pine code Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(auth): preserve locale in MCP authorize callbacks Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): surface socket bridge snapshot errors Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): require realtime persistence for saved entities Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(monitor): refresh pages after server tool updates Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * style(monitor): apply formatting cleanup Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(mcp): use dedicated api key type Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(mcp): hydrate server configs from saved entity state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): validate server fields consistently Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): stage apply mutations before persistence Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): scope saved entity materialization by workspace Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(api-key): use personal keys for mcp access Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): bootstrap workspaces for copilot initialize Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): sanitize copilot rpc errors Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(yjs): read saved entity state through canonical sessions Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): skip invalid saved entity list rows Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * test(socket-server): keep connected saved entity drafts on save failure Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * docs(socket-server): clarify explicit-save draft persistence Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): show visible MCP servers in selectors Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): preserve server state across refreshes Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(mcp): propagate enabled state through MCP server lists Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): use bootstrap state for deploy and status checks Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): synchronize server refresh and membership state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): clarify editable realtime requirement Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * test: align mocks with current imports Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(custom-tools): allow workflow-scoped lookup Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(mcp): refresh discovered tools after server mutations Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * test(socket-server): cover failed skill update on connected drafts Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): return 404 when servers are missing Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): handle realtime-required workflow state errors Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): stabilize saved-entity list sync Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(api-key): require encrypted API-key storage Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com>\nCo-authored-by: BWJ2310 <brucewj2310@gmail.com>\nCo-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com>\nCo-authored-by: BWJ2310 <brucewj2310@gmail.com>\nCo-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com>\nCo-authored-by: BWJ2310 <brucewj2310@gmail.com>\nCo-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(mcp): persist server status and refresh metadata Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): clean up sessions before deleting entities Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * test(mcp): cover config writer targets Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(mcp): sync MCP server API with database records Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(auth): gate API key and MCP routes on storage availability Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): base review hashes on current entity state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com>\nCo-authored-by: BWJ2310 <brucewj2310@gmail.com>\nCo-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com>\nCo-authored-by: BWJ2310 <brucewj2310@gmail.com>\nCo-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): surface realtime-required snapshot errors Map SavedEntityRealtimeRequiredError through snapshot bootstrapping, API handlers, and Copilot server tool error responses. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(knowledge): make knowledge base notifications transactional Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(yjs): persist workflow docs on live updates Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(realtime): surface realtime sync errors from saved entity writes Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(mcp): centralize workspace server creation Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): refresh tool cache when server metadata changes Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflow): debounce live Yjs persistence Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): enforce workspace-scoped review targets Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): rollback created members on publish failure Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(mcp): rename server update action Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): keep live entity docs in sync after persistence Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(copilot): harden MCP initialize handling Validate MCP initialize requests, reject batched initialize calls, and sanitize server-tool errors. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com>\nCo-authored-by: BWJ2310 <brucewj2310@gmail.com>\nCo-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * docs(changelog): remove stale June 24 entry Remove the outdated changelog note for the June 24 Copilot MCP work.\n\nCo-authored-by: Codex <codex@openai.com>\nCo-authored-by: BWJ2310 <brucewj2310@gmail.com>\nCo-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(copilot): remove legacy typed server tool error path Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): remove stale MCP tool cache Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com>\nCo-authored-by: BWJ2310 <brucewj2310@gmail.com>\nCo-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): derive base URLs from request origin and acknowledge delivered retries Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): support protocol negotiation and install ack timing Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(realtime): rename editable bootstrap helpers Rename workflow and saved-entity helper functions to reflect realtime behavior. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workspaces): prevent deleting the last workspace Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workspaces): move bootstrap to signup and refresh MCP tools Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workspaces): create default workspace when user has none Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(stores): clear react query cache during user data reset Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workspaces): stop auto-creating workspaces on read Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): remap duplicate block references in sub-blocks Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(api-key): validate encryption key availability Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(query-provider): use browser-scoped query client Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(workspace): create default workspace for root entry Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(workspaces): bootstrap default workspace for MCP users Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(mcp): use configured app URL for auth and install flows Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(mcp): support MCP 2025-06-18 negotiation Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): normalize protocol negotiation and JSON-RPC handling Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): return conflict when editable workflow state is missing Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(mcp): allow disabled server drafts without a url Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): align server mutations with realtime review sessions Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * docs(changelog): add June 28 2026 staging changelog Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(api-key): reject legacy stored api-key rows Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * docs(readme): add Copilot-MCP setup instructions Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(workflows): persist workflow state through Yjs materialization Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): persist auto-layout durably Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * test: stabilize workflow and heatmap assertions Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * 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 <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): compare device login hashes in constant time Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): preserve workflow bootstrap and persistence guards Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> --------- Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> --- .husky/pre-commit | 2 +- AGENTS.md | 3 +- README.md | 38 +- apps/tradinggoose/.env.example | 4 +- apps/tradinggoose/.env.example.docker | 3 + .../(auth)/mcp/authorize/page.test.tsx | 101 +++ .../[locale]/(auth)/mcp/authorize/page.tsx | 121 +++ .../app/[locale]/workspace/page.test.tsx | 15 +- .../app/[locale]/workspace/page.tsx | 15 +- .../app/api/auth/mcp/authorize/route.test.ts | 174 ++++ .../app/api/auth/mcp/authorize/route.ts | 82 ++ .../app/api/auth/mcp/poll/route.test.ts | 130 +++ .../app/api/auth/mcp/poll/route.ts | 42 + .../app/api/auth/mcp/start/route.test.ts | 90 +++ .../app/api/auth/mcp/start/route.ts | 28 + .../app/api/chat/[identifier]/route.test.ts | 10 +- .../app/api/chat/[identifier]/route.ts | 23 +- .../copilot/chat/review-session-post.test.ts | 21 +- .../api/copilot/chat/review-session.test.ts | 53 +- .../execute-copilot-server-tool/route.ts | 89 ++- .../app/api/copilot/mcp/route.test.ts | 504 ++++++++++++ .../tradinggoose/app/api/copilot/mcp/route.ts | 395 ++++++++++ .../copilot/tools/mark-complete/route.test.ts | 23 +- .../api/copilot/tools/mark-complete/route.ts | 20 - .../app/api/function/execute/route.test.ts | 200 ++--- .../indicators/custom/import/route.test.ts | 5 +- .../app/api/indicators/custom/import/route.ts | 4 + .../app/api/indicators/custom/route.ts | 128 +-- .../app/api/indicators/options/route.test.ts | 33 +- .../app/api/indicators/options/route.ts | 24 +- .../app/api/knowledge/[id]/copy/route.ts | 4 + .../app/api/knowledge/[id]/route.test.ts | 16 +- .../app/api/knowledge/[id]/route.ts | 28 +- apps/tradinggoose/app/api/knowledge/route.ts | 11 +- .../app/api/mcp/servers/[id]/refresh/route.ts | 32 +- .../app/api/mcp/servers/[id]/route.ts | 93 +-- .../tradinggoose/app/api/mcp/servers/route.ts | 173 ++-- .../app/api/mcp/servers/schema.ts | 36 +- .../app/api/mcp/tools/discover/route.ts | 4 +- .../app/api/monitors/[id]/route.ts | 264 +------ apps/tradinggoose/app/api/monitors/shared.ts | 3 - .../app/api/monitors/update-service.ts | 282 +++++++ .../api/providers/trading/order/route.test.ts | 10 +- .../app/api/skills/import/route.ts | 4 + .../tradinggoose/app/api/skills/route.test.ts | 11 +- apps/tradinggoose/app/api/skills/route.ts | 55 +- .../app/api/templates/[id]/use/route.ts | 64 +- .../app/api/tools/custom/import/route.test.ts | 7 - .../app/api/tools/custom/import/route.ts | 4 + .../app/api/tools/custom/route.test.ts | 37 +- .../app/api/tools/custom/route.ts | 140 ++-- .../app/api/users/me/api-keys/[id]/route.ts | 2 +- .../app/api/users/me/api-keys/route.ts | 31 +- apps/tradinggoose/app/api/v1/auth.ts | 8 +- apps/tradinggoose/app/api/v1/middleware.ts | 102 +-- .../api/workflows/[id]/autolayout/route.ts | 49 +- .../api/workflows/[id]/deploy/route.test.ts | 3 +- .../app/api/workflows/[id]/deploy/route.ts | 14 +- .../[version]/revert/route.test.ts | 79 +- .../deployments/[version]/revert/route.ts | 56 +- .../workflows/[id]/duplicate/route.test.ts | 80 +- .../app/api/workflows/[id]/duplicate/route.ts | 104 +-- .../app/api/workflows/[id]/route.test.ts | 230 +++--- .../app/api/workflows/[id]/route.ts | 101 ++- .../api/workflows/[id]/state/route.test.ts | 235 ------ .../app/api/workflows/[id]/state/route.ts | 295 ------- .../api/workflows/[id]/status/route.test.ts | 40 +- .../app/api/workflows/[id]/status/route.ts | 18 +- .../app/api/workflows/middleware.ts | 73 +- .../app/api/workflows/route.test.ts | 94 +-- apps/tradinggoose/app/api/workflows/route.ts | 84 +- apps/tradinggoose/app/api/workflows/utils.ts | 13 + .../api/workflows/yaml/export/route.test.ts | 75 +- .../app/api/workflows/yaml/export/route.ts | 30 +- .../app/api/workspaces/[id]/api-keys/route.ts | 31 +- .../app/api/workspaces/[id]/route.test.ts | 4 + .../app/api/workspaces/[id]/route.ts | 6 + .../app/api/workspaces/route.test.ts | 136 +--- apps/tradinggoose/app/api/workspaces/route.ts | 7 +- .../sessions/[sessionId]/snapshot/route.ts | 114 ++- .../app/mcp/[[...command]]/route.test.ts | 183 +++++ .../app/mcp/[[...command]]/route.ts | 71 ++ apps/tradinggoose/app/query-provider.tsx | 33 +- .../api-keys/workspace-api-keys-card.tsx | 260 ++---- .../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 +- .../monitor/components/data/api.ts | 4 +- .../management/indicator-input-fields.tsx | 4 +- .../[workspaceId]/monitor/monitor.test.tsx | 1 + .../[workspaceId]/monitor/monitor.tsx | 87 +- apps/tradinggoose/components/ui/tool-call.tsx | 129 +-- .../components/help/help-modal.tsx | 4 +- apps/tradinggoose/hooks/queries/api-keys.ts | 2 +- .../hooks/queries/custom-tools.ts | 60 +- apps/tradinggoose/hooks/queries/indicators.ts | 49 +- apps/tradinggoose/hooks/queries/skills.ts | 60 +- apps/tradinggoose/hooks/queries/workflows.ts | 16 - apps/tradinggoose/hooks/use-mcp-tools.ts | 152 ++-- .../workflow/use-current-workflow.test.tsx | 4 - .../hooks/workflow/use-current-workflow.ts | 46 +- apps/tradinggoose/i18n/messages/en.json | 39 +- apps/tradinggoose/i18n/messages/es.json | 39 +- apps/tradinggoose/i18n/messages/zh.json | 39 +- apps/tradinggoose/i18n/public-copy.test.ts | 3 + apps/tradinggoose/lib/api-key/auth.ts | 215 ----- apps/tradinggoose/lib/api-key/service.test.ts | 140 ++++ apps/tradinggoose/lib/api-key/service.ts | 328 ++++---- apps/tradinggoose/lib/api/rate-limit.ts | 169 ++++ apps/tradinggoose/lib/auth.ts | 3 + apps/tradinggoose/lib/auth/hybrid.ts | 8 +- .../lib/copilot/chat-replay-safety.test.ts | 2 +- .../lib/copilot/entity-documents.ts | 194 +++-- .../lib/copilot/inline-tool-call.tsx | 115 +-- .../lib/copilot/process-contents.test.ts | 35 +- .../lib/copilot/process-contents.ts | 92 +-- apps/tradinggoose/lib/copilot/registry.ts | 321 +++++--- .../copilot/review-sessions/identity.test.ts | 42 + .../lib/copilot/review-sessions/identity.ts | 126 +-- .../review-sessions/permissions.test.ts | 27 + .../copilot/review-sessions/permissions.ts | 27 +- .../lib/copilot/review-sessions/runtime.ts | 7 +- .../lib/copilot/review-sessions/types.ts | 7 +- .../lib/copilot/runtime-tool-manifest.test.ts | 10 +- .../lib/copilot/server-tool-errors.test.ts | 11 +- .../lib/copilot/server-tool-errors.ts | 29 +- .../lib/copilot/tool-prompt-metadata.ts | 67 +- .../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 ------------------ .../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 | 49 +- .../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 | 72 ++ .../server/blocks/block-mermaid-catalog.ts | 2 +- .../server/blocks/get-blocks-metadata.test.ts | 2 +- .../tools/server/entities/custom-tool.ts | 116 +++ .../copilot/tools/server/entities/index.ts | 37 + .../tools/server/entities/indicator.test.ts | 119 +++ .../tools/server/entities/indicator.ts | 172 ++++ .../tools/server/entities/mcp-server.ts | 195 +++++ .../tools/server/entities/shared.test.ts | 357 +++++++++ .../copilot/tools/server/entities/shared.ts | 311 ++++++++ .../copilot/tools/server/entities/skill.ts | 96 +++ .../server/entities/workflow-variable.test.ts | 206 +++++ .../copilot/tools/server/entities/workflow.ts | 613 ++++++++++++++ .../tools/server/gdrive/list-files.test.ts | 18 +- .../copilot/tools/server/gdrive/list-files.ts | 31 +- .../tools/server/gdrive/read-file.test.ts | 18 +- .../copilot/tools/server/gdrive/read-file.ts | 35 +- .../tools/server/knowledge/knowledge-base.ts | 421 +++++----- .../tools/server/monitor/edit-monitor.ts | 99 +++ .../tools/server/monitor/list-monitors.ts | 48 ++ .../tools/server/monitor/read-monitor.ts | 38 + .../monitor/shared.ts} | 67 +- .../tools/server/other/make-api-request.ts | 3 +- .../copilot/tools/server/review-acceptance.ts | 201 +++++ .../lib/copilot/tools/server/router.test.ts | 309 +++++++- .../lib/copilot/tools/server/router.ts | 231 +++++- .../tools/server/user/read-credentials.ts | 57 +- .../user/read-environment-variables.test.ts | 56 +- .../server/user/read-environment-variables.ts | 79 +- .../server/user/read-oauth-credentials.ts | 45 +- .../server/user/set-environment-variables.ts | 257 ++++-- .../workflow/block-output-tools.test.ts | 150 ++++ .../workflow/check-deployment-status.ts | 88 +++ .../workflow/edit-workflow-block.test.ts | 27 +- .../server/workflow/edit-workflow-block.ts | 31 +- .../server/workflow/edit-workflow.test.ts | 176 +++-- .../tools/server/workflow/edit-workflow.ts | 56 +- .../server/workflow/read-block-outputs.ts | 85 ++ .../read-block-upstream-references.ts | 152 ++++ .../workflow/workflow-mutation-utils.test.ts | 120 +++ .../workflow/workflow-mutation-utils.ts | 206 ++++- .../lib/copilot/tools/shared/schemas.ts | 41 +- .../workflow/block-output-utils.ts | 50 +- .../lib/custom-tools/operations.ts | 169 ++-- apps/tradinggoose/lib/custom-tools/schema.ts | 2 +- apps/tradinggoose/lib/env.ts | 2 +- .../lib/indicators/custom/operations.ts | 174 ++-- .../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 - apps/tradinggoose/lib/knowledge/service.ts | 191 ++--- apps/tradinggoose/lib/knowledge/types.ts | 6 +- apps/tradinggoose/lib/mcp/auth.test.ts | 190 +++++ apps/tradinggoose/lib/mcp/auth.ts | 593 ++++++++++++++ apps/tradinggoose/lib/mcp/client.ts | 2 +- apps/tradinggoose/lib/mcp/install-script.ts | 427 ++++++++++ .../mcp/local-config-writer-script.test.ts | 146 ++++ .../lib/mcp/local-config-writer-script.ts | 128 +++ apps/tradinggoose/lib/mcp/service.ts | 379 +++------ apps/tradinggoose/lib/mcp/types.ts | 1 + apps/tradinggoose/lib/mcp/utils.ts | 4 +- .../lib/skills/operations.test.ts | 19 +- apps/tradinggoose/lib/skills/operations.ts | 200 ++--- .../lib/workflows/custom-tools-persistence.ts | 157 ---- .../lib/workflows/db-helpers.test.ts | 718 +++++------------ apps/tradinggoose/lib/workflows/db-helpers.ts | 570 ++++++-------- .../lib/workflows/execution-runner.test.ts | 77 +- .../lib/workflows/execution-runner.ts | 58 +- .../lib/workflows/import-export.ts | 56 +- .../tradinggoose/lib/workflows/import.test.ts | 112 +-- apps/tradinggoose/lib/workflows/import.ts | 28 +- .../lib/workflows/json-sanitizer.ts | 7 +- .../workflows/studio-workflow-mermaid.test.ts | 53 -- .../lib/workflows/studio-workflow-mermaid.ts | 95 +-- .../lib/workflows/workflow-direction.ts | 38 +- apps/tradinggoose/lib/workspaces/service.ts | 151 +--- apps/tradinggoose/lib/yjs/client.ts | 9 - apps/tradinggoose/lib/yjs/entity-session.ts | 121 ++- apps/tradinggoose/lib/yjs/entity-state.ts | 158 +--- apps/tradinggoose/lib/yjs/provider.ts | 10 +- .../lib/yjs/server/apply-entity-state.test.ts | 216 +++++ .../lib/yjs/server/apply-entity-state.ts | 230 +++++- .../yjs/server/apply-workflow-state.test.ts | 169 ++++ .../lib/yjs/server/apply-workflow-state.ts | 86 +- .../lib/yjs/server/bootstrap-review-target.ts | 449 ++++------- .../lib/yjs/server/entity-loaders.ts | 147 ++-- .../lib/yjs/server/snapshot-bridge.ts | 176 +++-- .../tradinggoose/lib/yjs/use-entity-fields.ts | 167 +++- apps/tradinggoose/lib/yjs/use-workflow-doc.ts | 20 +- .../lib/yjs/workflow-session.test.ts | 21 +- apps/tradinggoose/lib/yjs/workflow-session.ts | 94 ++- .../lib/yjs/workflow-shared-session.test.ts | 10 +- .../lib/yjs/workflow-shared-session.ts | 4 +- .../lib/yjs/workflow-variables.test.ts | 33 + .../lib/yjs/workflow-variables.ts | 41 +- apps/tradinggoose/proxy.test.ts | 80 ++ apps/tradinggoose/proxy.ts | 31 +- .../services/queue/ExecutionLimiter.test.ts | 29 +- .../services/queue/ExecutionLimiter.ts | 28 +- apps/tradinggoose/socket-server/index.test.ts | 593 +++++++++----- .../market/indicator-monitor-runtime.ts | 5 +- .../tradinggoose/socket-server/routes/http.ts | 548 +++++++++---- .../socket-server/yjs/auth.test.ts | 2 +- .../socket-server/yjs/persistence.ts | 221 ------ .../socket-server/yjs/upstream-utils.test.ts | 135 ---- .../socket-server/yjs/upstream-utils.ts | 242 +++--- .../socket-server/yjs/ws-handler.test.ts | 101 +-- .../socket-server/yjs/ws-handler.ts | 94 ++- .../tradinggoose/stores/copilot/store.test.ts | 246 +++--- apps/tradinggoose/stores/copilot/store.ts | 49 +- .../stores/copilot/tool-registry.test.ts | 384 +++++++-- .../stores/copilot/tool-registry.ts | 252 ++++-- apps/tradinggoose/stores/copilot/types.ts | 2 +- .../tradinggoose/stores/custom-tools/types.ts | 2 +- apps/tradinggoose/stores/index.ts | 2 + apps/tradinggoose/stores/indicators/types.ts | 2 +- apps/tradinggoose/stores/knowledge/store.ts | 4 +- apps/tradinggoose/stores/mcp-servers/store.ts | 43 +- apps/tradinggoose/stores/mcp-servers/types.ts | 18 +- apps/tradinggoose/stores/skills/types.ts | 2 +- apps/tradinggoose/stores/workflows/index.ts | 3 - .../stores/workflows/json/importer.ts | 11 +- .../stores/workflows/json/store.test.ts | 30 +- .../stores/workflows/json/store.ts | 34 +- .../stores/workflows/registry/store.ts | 3 + .../stores/workflows/registry/types.ts | 1 + apps/tradinggoose/tools/index.test.ts | 17 +- apps/tradinggoose/tools/utils.test.ts | 39 + apps/tradinggoose/tools/utils.ts | 7 +- 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 | 38 - .../components/custom-tool-dropdown.tsx | 6 +- .../widgets/components/mcp-dropdown.tsx | 20 +- .../components/pine-indicator-dropdown.tsx | 6 +- .../hooks/use-user-input-mentions.ts | 1 + .../user-input/workspace-entity-mentions.ts | 10 +- .../custom-tool-editor.test.tsx | 94 +-- .../editor_custom_tool/custom-tool-editor.tsx | 87 +- .../widgets/editor_custom_tool/index.tsx | 47 +- .../components/indicator-editor-header.tsx | 166 +--- .../components/pine-indicator-code-panel.tsx | 104 ++- .../editor-indicator-body.tsx | 34 +- .../widgets/editor_indicator/index.test.tsx | 155 +--- .../widgets/editor_mcp/editor-mcp-body.tsx | 162 ++-- .../components/skill-editor-header.tsx | 92 +-- .../editor_skill/editor-skill-body.tsx | 63 +- .../widgets/editor_skill/index.test.tsx | 133 +--- .../editor_skill/skill-editor.test.tsx | 59 +- .../widgets/editor_skill/skill-editor.tsx | 147 ++-- .../components/control-bar/auto-layout.ts | 135 +--- .../export-controls/export-controls.tsx | 9 - .../components/control-bar/control-bar.tsx | 26 +- .../knowledge-base-selector.tsx | 13 +- .../mcp-server-modal/mcp-server-selector.tsx | 2 +- .../workflow-editor/workflow-canvas.tsx | 7 +- .../components/heatmap-treemap-chart.test.tsx | 2 +- .../widgets/list_custom_tool/index.tsx | 15 +- .../components/indicator-create-menu.tsx | 5 +- .../components/indicator-list-item.tsx | 5 +- .../indicator-list/indicator-list.tsx | 14 +- .../widgets/list_indicator/index.test.tsx | 10 +- .../widgets/widgets/list_indicator/index.tsx | 8 +- .../widgets/widgets/list_mcp/index.tsx | 22 +- .../components/skill-create-menu.tsx | 5 +- .../components/skill-list/skill-list.tsx | 3 +- .../widgets/widgets/list_skill/index.tsx | 3 +- .../components/workflow-create-menu.tsx | 51 +- changelog/June-28-2026.md | 91 +++ docker-compose.ollama.yml | 2 +- docker-compose.prod.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/templates/NOTES.txt | 4 +- helm/tradinggoose/templates/_helpers.tpl | 3 + helm/tradinggoose/values.schema.json | 5 + helm/tradinggoose/values.yaml | 7 +- packages/db/schema/workspaces.ts | 2 +- 346 files changed, 17421 insertions(+), 15283 deletions(-) create mode 100644 apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx create mode 100644 apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx create mode 100644 apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts create mode 100644 apps/tradinggoose/app/api/auth/mcp/authorize/route.ts 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/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/api/monitors/update-service.ts 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 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/lib/api-key/auth.ts create mode 100644 apps/tradinggoose/lib/api-key/service.test.ts create mode 100644 apps/tradinggoose/lib/api/rate-limit.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 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.test.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.test.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 rename apps/tradinggoose/lib/copilot/tools/{client/monitor/monitor-tool-utils.ts => server/monitor/shared.ts} (69%) 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 rename apps/tradinggoose/lib/copilot/{tools/client => }/workflow/block-output-utils.ts (77%) create mode 100644 apps/tradinggoose/lib/mcp/auth.test.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.test.ts create mode 100644 apps/tradinggoose/lib/mcp/local-config-writer-script.ts delete mode 100644 apps/tradinggoose/lib/workflows/custom-tools-persistence.ts create mode 100644 apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts create mode 100644 apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts delete mode 100644 apps/tradinggoose/socket-server/yjs/persistence.ts delete mode 100644 apps/tradinggoose/socket-server/yjs/upstream-utils.test.ts create mode 100644 changelog/June-28-2026.md 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/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 diff --git a/README.md b/README.md index 1ab1ee07a..dc053ec78 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,33 @@ It is built for analytics, research, charting, monitoring, and workflow automati <img alt="Project Overview" src="apps/tradinggoose/public/static/preview-light.png" width="2559"> </picture> +--- + +### 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 ``` @@ -101,9 +125,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` enables encrypted API-key storage 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 c12be0f53..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" -# Recommended: 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 21191cd1e..50777078d 100644 --- a/apps/tradinggoose/.env.example.docker +++ b/apps/tradinggoose/.env.example.docker @@ -25,6 +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= # 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/[locale]/(auth)/mcp/authorize/page.test.tsx b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx new file mode 100644 index 000000000..cb05b1bf1 --- /dev/null +++ b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx @@ -0,0 +1,101 @@ +import type React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetSession, + mockGetSessionCookie, + mockHeaders, + mockCreateMcpDeviceLoginApprovalChallenge, + 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 + }) => ( + <header> + <p>{eyebrow}</p> + <h1>{title}</h1> + <p>{description}</p> + </header> + ), +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => ( + <button {...props}>{children}</button> + ), +})) + +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 with a user-bound approval challenge', 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({ + code: 'login-code', + userId: 'user-1', + }) + 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 new file mode 100644 index 000000000..0bd3f2437 --- /dev/null +++ b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx @@ -0,0 +1,121 @@ +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 { 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' + +export const dynamic = 'force-dynamic' + +type SearchParams = Promise<{ + code?: string | string[] + status?: string | string[] +}> + +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 = normalizeLocaleCode(routeLocale) + const mcpCopy = getPublicCopy(locale).auth.mcp + 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 }) => ( + <div className='space-y-8 text-center'> + <AuthPageHeader eyebrow={mcpCopy.eyebrow} title={copy.title} description={copy.description} /> + </div> + ) + + if (statusCopy) { + return renderStatus(statusCopy) + } + + if (!code) { + return renderStatus(mcpCopy.invalid) + } + + const session = await getSession(requestHeaders) + if (!session?.user?.id) { + return redirect({ + href: { + pathname: '/login', + query: { + ...(getSessionCookie(requestHeaders) ? { reauth: '1' } : {}), + callbackUrl: `/${locale}/mcp/authorize?code=${encodeURIComponent(code)}`, + }, + }, + locale, + }) + } + + const approvalStatus = await createMcpDeviceLoginApprovalChallenge({ + code, + userId: session.user.id, + }) + + if (approvalStatus.status === 'expired') { + return renderStatus(mcpCopy.expired) + } + + if (approvalStatus.status === 'approved') { + return renderStatus(mcpCopy.approved) + } + + if (approvalStatus.status !== 'pending') { + return renderStatus(mcpCopy.invalid) + } + + return ( + <div className='space-y-8 text-center'> + <AuthPageHeader + eyebrow={mcpCopy.eyebrow} + title={mcpCopy.confirm.title} + description={mcpCopy.confirm.description} + /> + <form method='post' action='/api/auth/mcp/authorize' className='space-y-3'> + <input type='hidden' name='code' value={code} /> + <input type='hidden' name='approvalToken' value={approvalStatus.approvalToken} /> + <input type='hidden' name='locale' value={locale} /> + <div className='flex flex-col gap-3 sm:flex-row sm:justify-center'> + <Button type='submit' name='action' value='approve' className='text-[15px]'> + {mcpCopy.confirm.approve} + </Button> + <Button + type='submit' + name='action' + value='cancel' + variant='outline' + className='text-[15px]' + > + {mcpCopy.confirm.cancel} + </Button> + </div> + </form> + <p className={`${inter.className} text-muted-foreground text-sm`}> + {mcpCopy.confirm.terminalHint} + </p> + </div> + ) +} diff --git a/apps/tradinggoose/app/[locale]/workspace/page.test.tsx b/apps/tradinggoose/app/[locale]/workspace/page.test.tsx index d302326ec..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) }) @@ -146,20 +149,16 @@ 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' }]) + it('repairs authenticated users with no workspace from the workspace entrypoint', async () => { + mockGetUserWorkspaces.mockResolvedValue([]) await expect(renderWorkspacePage('en')).rejects.toThrow( - 'redirect:/en/workspace/workspace-bootstrapped/dashboard' + 'redirect:/en/workspace/workspace-created/dashboard' ) - expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ - userId: 'user-1', - userName: 'Ada Lovelace', - }) + 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 d4782dbbc..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' @@ -87,14 +87,9 @@ export default async function WorkspacePage({ } } - const [workspace] = await getUserWorkspaces({ - userId, - userName: session.user.name, - }) + const [workspace] = await getUserWorkspaces({ userId }) + const targetWorkspace = + workspace ?? (await createDefaultWorkspaceForUser(userId, session.user.name)) - if (!workspace) { - throw new Error('Expected workspace bootstrap to return a workspace') - } - - return redirect({ href: `/workspace/${workspace.id}/dashboard`, locale }) + return redirect({ href: `/workspace/${targetWorkspace.id}/dashboard`, locale }) } 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..13e5974ab --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts @@ -0,0 +1,174 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockApproveMcpDeviceLogin, + mockCancelMcpDeviceLogin, + mockGetBaseUrl, + mockGetSession, + mockGetSessionCookie, +} = vi.hoisted(() => ({ + mockApproveMcpDeviceLogin: vi.fn(), + mockCancelMcpDeviceLogin: vi.fn(), + mockGetBaseUrl: 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), +})) + +vi.mock('@/lib/urls/utils', () => ({ + getBaseUrl: (...args: unknown[]) => mockGetBaseUrl(...args), +})) + +function createAuthorizeRequest( + body: Record<string, string>, + headers: Record<string, string> = {}, + origin = 'https://studio.example.test' +) { + return new NextRequest(`${origin}/api/auth/mcp/authorize`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + origin, + ...headers, + }, + body: new URLSearchParams(body), + }) +} + +describe('MCP authorize route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetBaseUrl.mockReturnValue('https://studio.example.test') + 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 from an explicit submitted confirmation', async () => { + const { POST } = await import('./route') + + const response = await POST( + createAuthorizeRequest( + { + action: 'approve', + approvalToken: 'approval-token', + 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://studio.example.test/es/mcp/authorize?status=approved' + ) + expect(mockApproveMcpDeviceLogin).toHaveBeenCalledWith({ + approvalToken: 'approval-token', + code: 'login-code', + userId: 'user-1', + }) + expect(mockCancelMcpDeviceLogin).not.toHaveBeenCalled() + }) + + it('cancels a pending device login from an explicit submitted confirmation', 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({ + approvalToken: 'approval-token', + code: 'login-code', + 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', locale: 'es' })) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://studio.example.test/es/mcp/authorize?status=invalid' + ) + expect(mockApproveMcpDeviceLogin).not.toHaveBeenCalled() + expect(mockCancelMcpDeviceLogin).not.toHaveBeenCalled() + }) + + it('rejects approval submissions without the rendered approval token', async () => { + const { POST } = await import('./route') + + 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/es/mcp/authorize?status=invalid' + ) + expect(mockApproveMcpDeviceLogin).not.toHaveBeenCalled() + expect(mockCancelMcpDeviceLogin).not.toHaveBeenCalled() + }) + + it('rejects approval submissions from an untrusted origin', async () => { + const { POST } = await import('./route') + + const response = await POST( + createAuthorizeRequest( + { + action: 'approve', + approvalToken: 'approval-token', + code: 'login-code', + locale: 'es', + }, + { origin: 'https://attacker.example.test' } + ) + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + '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 new file mode 100644 index 000000000..f1d715cce --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts @@ -0,0 +1,82 @@ +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 { getBaseUrl } from '@/lib/urls/utils' +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()) + 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()) + if (getSessionCookie(request.headers)) { + url.searchParams.set('reauth', '1') + } + url.searchParams.set( + 'callbackUrl', + `/${normalizedLocale}/mcp/authorize?code=${encodeURIComponent(code)}` + ) + return NextResponse.redirect(url) +} + +function hasTrustedFormOrigin(request: NextRequest) { + const trustedOrigin = new URL(getBaseUrl()).origin + const submittedOrigin = request.headers.get('origin') + if (submittedOrigin) { + try { + return new URL(submittedOrigin).origin === trustedOrigin + } catch { + return false + } + } + + const referer = request.headers.get('referer') + if (!referer) { + return false + } + + try { + return new URL(referer).origin === trustedOrigin + } catch { + return false + } +} + +export async function POST(request: NextRequest) { + const formData = await request.formData().catch(() => null) + const action = formData?.get('action') + const code = formData?.get('code') + const approvalToken = formData?.get('approvalToken') + const localeValue = formData?.get('locale') + const locale = normalizeLocaleCode(typeof localeValue === 'string' ? localeValue : undefined) + + if ( + (action !== 'approve' && action !== 'cancel') || + typeof code !== 'string' || + !code || + typeof approvalToken !== 'string' || + !approvalToken || + !hasTrustedFormOrigin(request) + ) { + return redirectToAuthorizeStatus(locale, 'invalid') + } + + const session = await getSession(request.headers) + if (!session?.user?.id) { + return redirectToLogin(request, locale, code) + } + + const result = + action === 'approve' + ? await approveMcpDeviceLogin({ approvalToken, code, userId: session.user.id }) + : await cancelMcpDeviceLogin({ approvalToken, code, userId: session.user.id }) + + return redirectToAuthorizeStatus(locale, result.status) +} 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..f2239464c --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts @@ -0,0 +1,130 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +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(), +})) + +vi.mock('@/lib/api/rate-limit', () => ({ + checkPublicApiEndpointRateLimit: (...args: unknown[]) => + 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), +})) + +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, + }) + mockIsApiKeyStorageAvailable.mockReturnValue(true) + mockPollMcpDeviceLogin.mockResolvedValue({ + status: 'approved', + 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 () => { + 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(request) + + 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(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 () => { + 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() + }) + + 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 new file mode 100644 index 000000000..019b61ca2 --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts @@ -0,0 +1,42 @@ +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' + +const PollRequestSchema = z + .object({ + code: z.string().min(1), + verificationKey: z.string().min(1), + ackApiKey: z.string().min(1).optional(), + }) + .strict() + +export async function POST(request: NextRequest) { + const rateLimit = await checkPublicApiEndpointRateLimit(request, 'mcp-auth-poll') + if (!rateLimit.allowed) { + 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)) + if (!parsed.success) { + 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({ + 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/api/auth/mcp/start/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts new file mode 100644 index 000000000..2ac1dede4 --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts @@ -0,0 +1,90 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckPublicApiEndpointRateLimit, + mockIsApiKeyStorageAvailable, + mockStartMcpDeviceLogin, +} = vi.hoisted(() => ({ + mockCheckPublicApiEndpointRateLimit: vi.fn(), + mockIsApiKeyStorageAvailable: vi.fn(), + mockStartMcpDeviceLogin: vi.fn(), +})) + +vi.mock('@/lib/api/rate-limit', () => ({ + checkPublicApiEndpointRateLimit: (...args: unknown[]) => + mockCheckPublicApiEndpointRateLimit(...args), +})) + +vi.mock('@/lib/api-key/service', () => ({ + isApiKeyStorageAvailable: (...args: unknown[]) => mockIsApiKeyStorageAvailable(...args), +})) + +vi.mock('@/lib/mcp/auth', () => ({ + startMcpDeviceLogin: (...args: unknown[]) => mockStartMcpDeviceLogin(...args), +})) + +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, + }) + mockIsApiKeyStorageAvailable.mockReturnValue(true) + mockStartMcpDeviceLogin.mockResolvedValue({ + code: 'login-code', + verificationKey: 'verification-key', + expiresAt: '2026-06-19T12:00:00.000Z', + intervalSeconds: 2, + }) + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('starts a browser approval login and returns an absolute approval URL', async () => { + const { POST } = await import('./route') + const request = new NextRequest('https://preview.example.test/api/auth/mcp/start', { + method: 'POST', + }) + + const response = await POST(request) + + 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', + }) + 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 new file mode 100644 index 000000000..54b53299a --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.ts @@ -0,0 +1,28 @@ +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' + +export const dynamic = 'force-dynamic' + +export async function POST(request: NextRequest) { + const rateLimit = await checkPublicApiEndpointRateLimit(request, 'mcp-auth-start') + if (!rateLimit.allowed) { + 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() + const authorizeUrl = new URL('/mcp/authorize', baseUrl) + authorizeUrl.searchParams.set('code', login.code) + + return NextResponse.json({ + ...login, + authorizeUrl: authorizeUrl.toString(), + }) +} diff --git a/apps/tradinggoose/app/api/chat/[identifier]/route.test.ts b/apps/tradinggoose/app/api/chat/[identifier]/route.test.ts index 33b677036..41b3fec77 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', }, })) @@ -126,6 +125,8 @@ vi.mock('@/lib/utils', () => ({ }, })) +import { GET, POST } from './route' + const chatParams = () => ({ params: Promise.resolve({ identifier: 'test-chat' }) }) const postChatRequest = (body: Record<string, unknown>) => new NextRequest('https://example.com/api/chat/test-chat', { @@ -235,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() @@ -250,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', @@ -281,6 +280,9 @@ describe('/api/chat/[identifier]', () => { }), }) ) + expect(enqueuePendingExecutionMock.mock.calls[0]?.[0].payload).not.toHaveProperty( + 'workflowVariables' + ) const body = await response.text() @@ -304,7 +306,6 @@ describe('/api/chat/[identifier]', () => { }) try { - const { POST } = await import('./route') const response = await POST( postChatRequest({ input: 'Hello', @@ -330,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/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<string, unknown>) - : undefined, metadata: { source: 'published_chat', chatId: deployment.id, 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..95c103709 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 @@ -13,6 +13,7 @@ describe('Copilot Chat POST Generic Sessions', () => { const mockLoadReviewSessionForUser = vi.fn() const mockProxyCopilotRequest = vi.fn() const mockProcessContextsServer = vi.fn() + const mockMirrorLocalCopilotCompletionUsageReports = vi.fn() const mockBuildAppendReviewTurn = vi.fn(() => ({ turn: { id: 'turn-1', @@ -205,6 +206,10 @@ describe('Copilot Chat POST Generic Sessions', () => { })), })) + vi.doMock('@/lib/copilot/completion-usage-billing', () => ({ + mirrorLocalCopilotCompletionUsageReports: mockMirrorLocalCopilotCompletionUsageReports, + })) + vi.doMock('@/lib/copilot/agent/utils', () => ({ requestCopilotTitle: vi.fn().mockResolvedValue(null), })) @@ -228,7 +233,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', () => ({ @@ -290,6 +302,13 @@ describe('Copilot Chat POST Generic Sessions', () => { vi.doMock('@/lib/copilot/process-contents', () => ({ processContextsServer: mockProcessContextsServer, })) + + vi.doMock('@/lib/copilot/runtime-tool-manifest', () => ({ + getCopilotRuntimeToolManifest: vi.fn().mockResolvedValue({ + version: 'v1', + tools: [{ name: 'read_workflow' }, { name: 'edit_workflow' }], + }), + })) }) afterEach(() => { 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..f157c3836 100644 --- a/apps/tradinggoose/app/api/copilot/chat/review-session.test.ts +++ b/apps/tradinggoose/app/api/copilot/chat/review-session.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setupCommonApiMocks } from '@/app/api/__test-utils__/utils' describe('Copilot Chat Review Session GET', () => { + let GET: typeof import('@/app/api/copilot/chat/route').GET const mockSelect = vi.fn() const mockFromSessions = vi.fn() const mockWhereSessions = vi.fn() @@ -20,7 +21,7 @@ describe('Copilot Chat Review Session GET', () => { const mockMapReviewItemToApi = vi.fn() - beforeEach(() => { + beforeEach(async () => { vi.resetModules() setupCommonApiMocks() @@ -177,6 +178,14 @@ describe('Copilot Chat Review Session GET', () => { getCopilotModel: vi.fn(), })) + vi.doMock('@/lib/copilot/completion-usage-billing', () => ({ + mirrorLocalCopilotCompletionUsageReports: vi.fn().mockResolvedValue(undefined), + })) + + vi.doMock('@/lib/copilot/runtime-provider.server', () => ({ + buildCopilotRuntimeProviderConfig: vi.fn(), + })) + vi.doMock('@/lib/copilot/review-sessions/thread-history', () => ({ buildAppendReviewTurn: vi.fn(), MESSAGE_ROLES: { @@ -208,24 +217,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', () => ({ @@ -251,6 +269,7 @@ describe('Copilot Chat Review Session GET', () => { proxyCopilotRequest: vi.fn(), })) + ;({ GET } = await import('@/app/api/copilot/chat/route')) }) afterEach(() => { @@ -263,7 +282,6 @@ describe('Copilot Chat Review Session GET', () => { 'http://localhost:3000/api/copilot/chat?reviewSessionId=review-session-1' ) - const { GET } = await import('@/app/api/copilot/chat/route') const response = await GET(request) expect(response.status).toBe(200) @@ -325,7 +343,6 @@ describe('Copilot Chat Review Session GET', () => { 'http://localhost:3000/api/copilot/chat?reviewSessionId=entity-review-session-1' ) - const { GET } = await import('@/app/api/copilot/chat/route') const response = await GET(request) expect(response.status).toBe(404) @@ -346,7 +363,6 @@ describe('Copilot Chat Review Session GET', () => { 'http://localhost:3000/api/copilot/chat?workspaceId=workspace-1' ) - const { GET } = await import('@/app/api/copilot/chat/route') const response = await GET(request) expect(response.status).toBe(200) @@ -385,5 +401,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..7ca7b584c 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,17 +13,32 @@ import { checkWorkspaceAccess } from '@/lib/permissions/utils' const logger = createLogger('ExecuteCopilotServerToolAPI') -const ExecuteSchema = z.object({ - toolName: z.string().min(1), - payload: 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)) { + 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() @@ -35,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<typeof ExecuteSchema> try { parsedBody = ExecuteSchema.parse(body) @@ -53,20 +63,40 @@ export async function POST(req: NextRequest) { throw error } toolName = parsedBody.toolName - const { payload, context } = 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() - const [{ isToolId }, { routeExecution }] = await Promise.all([ + 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, stageServerManagedToolReview }, + ] = 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') } + const toolId = toolName - 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: toolId, 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,16 +105,17 @@ export async function POST(req: NextRequest) { } } - const result = await routeExecution(toolName, payload, { + const executionContext = { userId, - ...context, + accessLevel: 'limited' as const, + ...executionContextInput, signal: req.signal, - }) - - try { - const resultPreview = JSON.stringify(result).slice(0, 300) - logger.debug(`[${tracker.requestId}] Server tool result preview`, { toolName, resultPreview }) - } catch {} + } + const result = await (reviewAction === 'accept' + ? acceptServerManagedToolReview(toolId, reviewToken!, executionContext) + : routeExecution(toolId, payload, executionContext).then((toolResult) => + stageServerManagedToolReview(toolId, payload, toolResult, executionContext) + )) return NextResponse.json({ success: true, result }) } catch (error) { 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..77733d2d7 --- /dev/null +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -0,0 +1,504 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthenticateApiKeyFromHeader, + mockCheckApiEndpointRateLimit, + mockCheckPublicApiEndpointRateLimit, + mockCreateDefaultWorkspaceForUser, + mockGetCopilotRuntimeToolManifest, + mockGetMcpServerToolIds, + mockGetUserWorkspaces, + mockRouteExecution, + mockUpdateApiKeyLastUsed, +} = vi.hoisted(() => ({ + mockAuthenticateApiKeyFromHeader: vi.fn(), + mockCheckApiEndpointRateLimit: vi.fn(), + mockCheckPublicApiEndpointRateLimit: vi.fn(), + mockCreateDefaultWorkspaceForUser: vi.fn(), + mockGetCopilotRuntimeToolManifest: vi.fn(), + mockGetMcpServerToolIds: vi.fn(), + mockGetUserWorkspaces: vi.fn(), + mockRouteExecution: vi.fn(), + mockUpdateApiKeyLastUsed: vi.fn(), +})) + +vi.mock('@/lib/api/rate-limit', () => ({ + checkApiEndpointRateLimit: (...args: unknown[]) => mockCheckApiEndpointRateLimit(...args), + checkPublicApiEndpointRateLimit: (...args: unknown[]) => + mockCheckPublicApiEndpointRateLimit(...args), +})) + +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', () => ({ + getMcpServerToolIds: (...args: unknown[]) => mockGetMcpServerToolIds(...args), + routeExecution: (...args: unknown[]) => mockRouteExecution(...args), +})) + +vi.mock('@/lib/workspaces/service', () => ({ + createDefaultWorkspaceForUser: (...args: unknown[]) => mockCreateDefaultWorkspaceForUser(...args), + getUserWorkspaces: (...args: unknown[]) => mockGetUserWorkspaces(...args), +})) + +function createMcpRequest( + body: unknown, + authorization = 'Bearer sk-tradinggoose-test', + headers: Record<string, string> = {} +) { + return new NextRequest('https://studio.example.test/api/copilot/mcp', { + method: 'POST', + headers: { + authorization, + 'content-type': 'application/json', + ...headers, + }, + body: JSON.stringify(body), + }) +} + +function initializeRequest(id: string | number = 1, protocolVersion = '2025-06-18') { + return { + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion }, + } +} + +describe('Copilot MCP route', () => { + beforeEach(() => { + vi.resetAllMocks() + mockAuthenticateApiKeyFromHeader.mockResolvedValue({ + success: true, + userId: 'user-1', + keyId: 'key-1', + }) + mockCheckApiEndpointRateLimit.mockResolvedValue({ + allowed: true, + remaining: 99, + limit: 100, + 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' }, + ]) + mockCreateDefaultWorkspaceForUser.mockResolvedValue({ + id: 'workspace-created', + name: 'My Workspace', + permissions: 'admin', + }) + mockGetMcpServerToolIds.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: {} }, + }, + { + name: 'make_api_request', + description: 'Make an HTTP request.', + parameters: { type: 'object', properties: { url: { type: 'string' } } }, + }, + ], + }) + mockRouteExecution.mockResolvedValue({ workflows: [] }) + }) + + it('rejects requests without bearer auth', async () => { + const { POST } = await import('./route') + + const response = await POST(createMcpRequest(initializeRequest(), '')) + 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(initializeRequest())) + const body = await response.json() + + expect(response.headers.get('MCP-Protocol-Version')).toBe('2025-06-18') + expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('sk-tradinggoose-test', { + keyTypes: ['personal'], + }) + expect(mockUpdateApiKeyLastUsed).toHaveBeenCalledWith('key-1') + expect(mockCheckApiEndpointRateLimit).toHaveBeenCalledWith('user-1', 'copilot-mcp') + 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') + expect(body.result.instructions).toContain('workspaceId=workspace-2, permissions=read') + 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') + expect(body.result.instructions).toContain('authenticated MCP key') + 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([]) + + 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') + + const response = await POST(createMcpRequest(initializeRequest(), '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') + + 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', + description: 'List workflows.', + inputSchema: { type: 'object', properties: { workspaceId: { type: 'string' } } }, + }, + ]) + }) + + it('returns MCP rate-limit errors from the shared API limiter', async () => { + const { POST } = await import('./route') + mockCheckApiEndpointRateLimit.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + limit: 10, + resetAt: new Date('2026-06-24T12:01:00.000Z'), + userId: 'user-1', + }) + + const response = await POST(createMcpRequest({ jsonrpc: '2.0', id: 2, method: 'tools/list' })) + const body = await response.json() + + expect(response.status).toBe(429) + expect(response.headers.get('X-RateLimit-Limit')).toBe('10') + expect(response.headers.get('Retry-After')).toBeTruthy() + expect(body.error.message).toBe('Rate limit exceeded') + 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') + + const response = await POST( + createMcpRequest({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'make_api_request', + arguments: { url: 'https://example.test', method: 'GET' }, + }, + }) + ) + const body = await response.json() + + expect(body.error.message).toBe('Unsupported MCP tool: make_api_request') + expect(mockRouteExecution).not.toHaveBeenCalled() + }) + + 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', accessLevel: 'full' } + ) + expect(body.result.structuredContent).toEqual({ workflows: [] }) + 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 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') + 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(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'))) + const notificationResponse = await POST( + createMcpRequest({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }) + ) + 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' } + ) + ) + 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) + 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(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' + ) + 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 () => { + 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') + + 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() + }) + + 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() + }) + + it('rejects oversized JSON-RPC batches before dispatch', async () => { + const { POST } = await import('./route') + + const response = await POST( + createMcpRequest( + Array.from({ length: 11 }, (_, index) => ({ + jsonrpc: '2.0', + id: index + 1, + method: 'tools/call', + params: { name: 'list_workflows', arguments: { workspaceId: 'workspace-1' } }, + })) + ) + ) + const body = await response.json() + + 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 new file mode 100644 index 000000000..f4886f020 --- /dev/null +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -0,0 +1,395 @@ +import { type NextRequest, NextResponse } from 'next/server' +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 { buildCopilotServerToolErrorResponse } from '@/lib/copilot/server-tool-errors' +import { getMcpServerToolIds, routeExecution } from '@/lib/copilot/tools/server/router' +import { createDefaultWorkspaceForUser, getUserWorkspaces } from '@/lib/workspaces/service' + +export const dynamic = 'force-dynamic' + +const MCP_PROTOCOL_VERSION = '2025-06-18' +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 + +type JsonRpcId = string | number + +type JsonRpcRequest = { + jsonrpc?: unknown + id?: unknown + method?: unknown + params?: unknown +} + +type AuthenticatedMcpUser = { + userId: string +} + +function jsonRpcResult(id: JsonRpcId, result: unknown) { + return { + jsonrpc: '2.0', + id, + result, + } +} + +function jsonRpcError(id: JsonRpcId | null, code: number, message: string, data?: unknown) { + return { + jsonrpc: '2.0', + id, + error: { + code, + message, + ...(data === undefined ? {} : { data }), + }, + } +} + +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 + headers.set( + 'MCP-Protocol-Version', + typeof responseProtocolVersion === 'string' && + MCP_NEGOTIABLE_PROTOCOL_VERSIONS.includes(responseProtocolVersion) + ? responseProtocolVersion + : protocolVersion + ) + + return NextResponse.json(body, { + ...init, + headers, + }) +} + +function mcpAcceptedResponse(protocolVersion: string) { + return new NextResponse(null, { + status: 202, + headers: { 'MCP-Protocol-Version': protocolVersion }, + }) +} + +function mcpRateLimitResponse(result: RateLimitResult) { + const headers: Record<string, string> = { + 'X-RateLimit-Limit': result.limit.toString(), + 'X-RateLimit-Remaining': result.remaining.toString(), + 'X-RateLimit-Reset': result.resetAt.toISOString(), + } + const retryAfter = Math.max(0, Math.ceil((result.resetAt.getTime() - Date.now()) / 1000)) + headers['Retry-After'] = retryAfter.toString() + + const status = + result.failureKind === 'auth' ? 401 : result.failureKind === 'dependency' ? 503 : 429 + const message = + result.failureKind === 'dependency' + ? result.error || 'Rate limit service unavailable' + : result.error || 'Rate limit exceeded' + + return mcpJsonResponse(jsonRpcError(null, -32029, message), { status, headers }) +} + +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 +} + +async function authenticateCopilotMcpRequest( + request: NextRequest +): Promise<AuthenticatedMcpUser | { error: string }> { + 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 TradingGoose MCP token' } + } + + if (auth.keyId) { + await updateApiKeyLastUsed(auth.keyId) + } + + return { userId: auth.userId } +} + +async function buildInstructions(userId: string) { + 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}` + ) + + 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 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, + ].join('\n') +} + +async function listMcpTools() { + const serverToolIds = new Set<string>(getMcpServerToolIds()) + 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 + } + if (args !== undefined && (!args || typeof args !== 'object' || Array.isArray(args))) { + return null + } + + return { + name, + args: args ?? {}, + } +} + +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 +} + +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 = 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') + } + + if (request.id === undefined) { + return null + } + if (id === null) { + return jsonRpcError(null, -32600, 'Invalid JSON-RPC request') + } + + try { + switch (request.method) { + case 'initialize': { + const protocolVersion = getInitializeProtocolVersion(request.params) + if (!protocolVersion) { + return jsonRpcError(id, -32602, 'Invalid initialize params') + } + if (!MCP_NEGOTIABLE_PROTOCOL_VERSIONS.includes(protocolVersion)) { + return jsonRpcError(id, -32000, 'Unsupported MCP protocol version', { + supportedProtocolVersions: MCP_NEGOTIABLE_PROTOCOL_VERSIONS, + }) + } + + return jsonRpcResult(id, { + protocolVersion, + 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}`) + } + + // 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 'prompts/list': + return jsonRpcResult(id, { prompts: [] }) + + 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) + } +} + +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 }) + } + + const rateLimit = await checkApiEndpointRateLimit(auth.userId, 'copilot-mcp') + if (!rateLimit.allowed) { + return mcpRateLimitResponse(rateLimit) + } + + const body = (await request.json().catch(() => null)) as JsonRpcRequest | JsonRpcRequest[] | null + if (!body) { + 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) + 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 json(jsonRpcError(null, -32600, 'Invalid JSON-RPC request')) + } + if (body.length > MAX_JSON_RPC_BATCH_SIZE) { + return json( + jsonRpcError(null, -32600, `JSON-RPC batch size cannot exceed ${MAX_JSON_RPC_BATCH_SIZE}`) + ) + } + if (body.some(isInitializeRequest)) { + 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 ? json(responses) : accepted() + } + + if (isJsonRpcResponse(body)) { + return accepted() + } + const response = await handleJsonRpcRequest(body, auth) + return response ? json(response) : accepted() +} + +export async function GET() { + return new NextResponse(null, { + status: 405, + headers: { + Allow: 'POST', + 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION, + }, + }) +} diff --git a/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.test.ts b/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.test.ts index dbe08d223..43e951292 100644 --- a/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.test.ts @@ -17,10 +17,11 @@ function createSseStream(events: unknown[]): ReadableStream<Uint8Array> { } describe('Copilot mark-complete API', () => { + let POST: typeof import('./route').POST const mockAuthenticateCopilotRequestSessionOnly = vi.fn() const mockProxyCopilotRequest = vi.fn() - beforeEach(() => { + beforeEach(async () => { vi.resetModules() mockAuthenticateCopilotRequestSessionOnly.mockReset() mockProxyCopilotRequest.mockReset() @@ -56,10 +57,28 @@ describe('Copilot mark-complete API', () => { })), })) + vi.doMock('@/lib/copilot/completion-usage-billing', () => ({ + mirrorLocalCopilotCompletionUsageReports: vi.fn().mockResolvedValue(undefined), + })) + + vi.doMock('@/lib/utils', () => ({ + encodeSSE: vi.fn((event: unknown) => + new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`) + ), + SSE_HEADERS: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + })) + vi.doMock('@/app/api/copilot/proxy', () => ({ getCopilotApiUrl: vi.fn(() => 'https://copilot.example.test/api/tools/mark-complete'), proxyCopilotRequest: (...args: any[]) => mockProxyCopilotRequest(...args), })) + + ;({ POST } = await import('./route')) }) it('passes through a continuation SSE stream from copilot', async () => { @@ -98,8 +117,6 @@ describe('Copilot mark-complete API', () => { ) ) - const { POST } = await import('./route') - const response = await POST( new NextRequest('http://localhost:3000/api/copilot/tools/mark-complete', { method: 'POST', 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/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/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/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 43d4acd7d..d430b09c7 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -1,13 +1,17 @@ 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 { upsertIndicators } 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 { applySavedEntityYjsStateToRows } from '@/lib/yjs/entity-state' -import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' +import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' +import { + deleteYjsSessionInSocketServer, + notifyEntityListMemberRemoved, +} from '@/lib/yjs/server/snapshot-bridge' import { authenticateIndicatorRequest, checkWorkspacePermission } from '../utils' const logger = createLogger('IndicatorsAPI') @@ -27,7 +31,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({ @@ -37,7 +43,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(), }) ), }) @@ -46,7 +51,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({ @@ -59,54 +63,31 @@ 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 result = await applySavedEntityYjsStateToRows('indicator', rows) - - return NextResponse.json({ data: result }, { status: 200 }) + 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 }) } @@ -146,12 +127,38 @@ 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, + }, + workspaceId, + requestId, + }) + : await createIndicators({ + indicators: indicatorsToCreate, + workspaceId, + userId: auth.userId, + requestId, + }) return NextResponse.json({ success: true, data: resultIndicators }) } catch (validationError) { @@ -172,9 +179,18 @@ 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 }) + } 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 }) } @@ -232,11 +248,15 @@ 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 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/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 abbb380ce..e1a40d3ed 100644 --- a/apps/tradinggoose/app/api/indicators/options/route.ts +++ b/apps/tradinggoose/app/api/indicators/options/route.ts @@ -1,15 +1,13 @@ -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 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 { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' import { authenticateIndicatorRequest, checkWorkspacePermission } from '../utils' const logger = createLogger('IndicatorOptionsAPI') @@ -86,18 +84,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)) - .then((rows) => applySavedEntityYjsStateToRows('indicator', rows)) + const customRows = await listIndicators({ workspaceId }) const customOptions: IndicatorOptionRecord[] = customRows .filter((row) => copilotSurface || isIndicatorTriggerCapable(row.pineCode)) @@ -125,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/[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/[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..eaf305b1c 100644 --- a/apps/tradinggoose/app/api/knowledge/[id]/route.ts +++ b/apps/tradinggoose/app/api/knowledge/[id]/route.ts @@ -2,12 +2,13 @@ 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' +import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('KnowledgeBaseByIdAPI') @@ -17,9 +18,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 +99,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 ) @@ -125,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.responseBody(), { status: error.status }) + } return NextResponse.json({ error: 'Failed to update knowledge base' }, { status: 500 }) } } diff --git a/apps/tradinggoose/app/api/knowledge/route.ts b/apps/tradinggoose/app/api/knowledge/route.ts index 96df89822..abf3f34e5 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') @@ -45,13 +46,14 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) } - const knowledgeBasesWithCounts = await getKnowledgeBases(session.user.id, workspaceId) - return NextResponse.json({ success: true, - data: knowledgeBasesWithCounts, + 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 }) } @@ -100,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/[id]/refresh/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts index ea10bb706..9347d1d5c 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts @@ -4,8 +4,9 @@ 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' -import { mcpService } from '@/lib/mcp/service' +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') @@ -31,7 +32,7 @@ export const POST = withMcpAuth('read')( ) const [server] = await db - .select() + .select({ lastConnected: mcpServers.lastConnected }) .from(mcpServers) .where( and( @@ -62,33 +63,46 @@ export const POST = withMcpAuth('read')( `[${requestId}] Successfully connected to server ${serverId}, discovered ${toolCount} tools` ) } catch (error) { + if ( + error instanceof McpServerNotFoundError || + error instanceof SavedEntityRealtimeRequiredError + ) { + throw error + } connectionStatus = 'error' lastError = error instanceof Error ? error.message : 'Connection test failed' logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error) } - const [refreshedServer] = await db + const now = new Date() + const lastConnected = connectionStatus === 'connected' ? now : server.lastConnected + await db .update(mcpServers) .set({ - lastToolsRefresh: new Date(), + lastToolsRefresh: now, connectionStatus, lastError, - lastConnected: connectionStatus === 'connected' ? new Date() : server.lastConnected, + lastConnected, toolCount, - updatedAt: new Date(), }) - .where(eq(mcpServers.id, serverId)) - .returning() + .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) logger.info(`[${requestId}] Successfully refreshed MCP server: ${serverId}`) return createMcpSuccessResponse({ status: connectionStatus, toolCount, - lastConnected: refreshedServer?.lastConnected?.toISOString() || null, + lastConnected: lastConnected?.toISOString() ?? null, + lastToolsRefresh: now.toISOString(), error: lastError, }) } catch (error) { logger.error(`[${requestId}] Error refreshing MCP server:`, error) + 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/[id]/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts index 1ebafe741..85061f75b 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts @@ -1,22 +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 { 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 { UpdateMcpServerSchema } from '../schema' +import { SavedEntityRealtimeRequiredError } 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 { 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). + * Full config edits are saved through the MCP saved-entity Yjs session. */ export const PATCH = withMcpAuth('write')( async ( @@ -29,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}`), @@ -42,46 +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'], }) - // 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 - } - - // 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(), - }) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) - ) - .returning() - - if (!updatedServer) { + 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', @@ -89,18 +60,30 @@ export const PATCH = withMcpAuth('write')( ) } - await applySavedEntityState( + const currentFields = await readBootstrappedSavedEntityFields( 'mcp_server', - updatedServer.id, - savedEntityRowToFields('mcp_server', updatedServer) + serverId, + workspaceId ) - - // Clear MCP service cache after update - mcpService.clearCache(workspaceId) + await applySavedEntityState('mcp_server', serverId, { ...currentFields, name: body.name }) logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`) - return createMcpSuccessResponse({ server: updatedServer }) + return createMcpSuccessResponse({ + server: { + id: serverId, + workspaceId, + 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) + } + 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/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 3bfb06406..aa53a7e0d 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -1,32 +1,25 @@ 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 { 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 { mcpService } from '@/lib/mcp/service' -import type { McpTransport } from '@/lib/mcp/types' -import { validateMcpServerUrl } from '@/lib/mcp/url-validator' +import { McpServerConfigError, mcpService } from '@/lib/mcp/service' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' +import { requireSavedEntityRealtimeListMembers } from '@/lib/yjs/server/bootstrap-review-target' import { - applySavedEntityYjsStateToRows, - savedEntityRowToFields, -} from '@/lib/yjs/entity-state' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' -import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' + deleteYjsSessionInSocketServer, + notifyEntityListMemberRemoved, +} from '@/lib/yjs/server/snapshot-bridge' import { CreateMcpServerSchema } from './schema' 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 */ @@ -35,17 +28,60 @@ export const GET = withMcpAuth('read')( try { logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) - const rows = await db - .select() - .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) - const servers = await applySavedEntityYjsStateToRows('mcp_server', rows) + const listMembers = await requireSavedEntityRealtimeListMembers('mcp_server', workspaceId) + const listMemberIds = listMembers.map((member) => member.entityId) + const statusById = new Map( + listMemberIds.length === 0 + ? [] + : ( + await db + .select({ + id: mcpServers.id, + 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) + if (!status) { + return [] + } + + return { + id: server.entityId, + name: server.entityName, + enabled: server.enabled !== false, + workspaceId, + updatedAt: status.updatedAt?.toISOString(), + 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}` ) 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'), @@ -81,67 +117,35 @@ export const POST = withMcpAuth('write')( workspaceId, }) - if (isUrlBasedTransport(body.transport as McpTransport) && body.url) { - 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 serverId = body.id || 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 applySavedEntityState( - 'mcp_server', - server.id, - savedEntityRowToFields('mcp_server', server) - ) - - mcpService.clearCache(workspaceId) + const created = await mcpService.createWorkspaceServer({ + userId, + workspaceId, + fields: body, + }) - logger.info(`[${requestId}] Successfully registered MCP server: ${body.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': body.name, - 'mcp.transport': body.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) + } logger.error(`[${requestId}] Error registering MCP server:`, error) return createMcpErrorResponse( error instanceof Error ? error : new Error('Failed to register MCP server'), @@ -171,13 +175,12 @@ 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) - .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - .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', @@ -185,15 +188,25 @@ export const DELETE = withMcpAuth('write')( ) } - await deleteYjsSessionInSocketServer(serverId) await db .delete(mcpServers) - .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) + ) + ) - mcpService.clearCache(workspaceId) + 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` }) + return createMcpSuccessResponse({ + message: `Server ${serverId} deleted successfully`, + }) } catch (error) { logger.error(`[${requestId}] Error deleting MCP server:`, error) return createMcpErrorResponse( diff --git a/apps/tradinggoose/app/api/mcp/servers/schema.ts b/apps/tradinggoose/app/api/mcp/servers/schema.ts index 9da0e2a8b..f877c3ae4 100644 --- a/apps/tradinggoose/app/api/mcp/servers/schema.ts +++ b/apps/tradinggoose/app/api/mcp/servers/schema.ts @@ -1,13 +1,9 @@ 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), + name: z.string().trim().min(1), description: z.string().optional(), - transport: z.string().min(1), + transport: z.enum(['http', 'sse', 'streamable-http']), url: z.string().optional(), headers: z.record(z.string()).optional(), command: z.string().optional(), @@ -18,22 +14,14 @@ const McpServerBaseSchema = z.object({ enabled: z.boolean().optional(), }) -/** - * Schema for creating a new MCP server. - * `name` and `transport` are required; `id` is optional (auto-generated if omitted). - */ -export const CreateMcpServerSchema = McpServerBaseSchema.extend({ - id: z.string().optional(), -}) +export const CreateMcpServerSchema = McpServerBaseSchema.refine( + (server) => server.enabled === false || !!server.url?.trim(), + { + message: 'URL is required when an MCP server is enabled', + path: ['url'], + } +) -/** - * 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(), -}) +export const RenameMcpServerSchema = z.object({ + name: z.string().trim().min(1), +}).strict() 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<string, number> = {} 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<typeof IndicatorMonitorUpdateSchema.parse> -type PortfolioUpdatePayload = ReturnType<typeof PortfolioMonitorUpdateSchema.parse> -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/shared.ts b/apps/tradinggoose/app/api/monitors/shared.ts index a2e0b5b5b..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 { applySavedEntityYjsStateToRows } 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) => applySavedEntityYjsStateToRows('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) => applySavedEntityYjsStateToRows('indicator', rows)) const row = rows[0] if (!row) { 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<typeof IndicatorMonitorUpdateSchema.parse> +type PortfolioUpdatePayload = ReturnType<typeof PortfolioMonitorUpdateSchema.parse> +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/providers/trading/order/route.test.ts b/apps/tradinggoose/app/api/providers/trading/order/route.test.ts index ff09260cd..e02f8a4cc 100644 --- a/apps/tradinggoose/app/api/providers/trading/order/route.test.ts +++ b/apps/tradinggoose/app/api/providers/trading/order/route.test.ts @@ -3,7 +3,7 @@ */ import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { TradingServiceError } from '@/lib/trading/errors' import { createMockRequest } from '@/app/api/__test-utils__/utils' @@ -18,6 +18,7 @@ const mockUpdateOrderHistoryResult = vi.fn() const mockFetch = vi.fn() const idempotencyStore = new Map<string, unknown>() let idempotencyCounter = 0 +let routePost: typeof import('@/app/api/providers/trading/order/route').POST vi.mock('@/lib/logs/console/logger', () => ({ createLogger: () => ({ @@ -73,6 +74,10 @@ vi.mock('@/providers/trading/portfolio', async () => { } }) +beforeAll(async () => { + ;({ POST: routePost } = await import('@/app/api/providers/trading/order/route')) +}) + const stockListing = { listing_type: 'default', listing_id: 'AAPL', @@ -211,8 +216,7 @@ describe('Trading provider order route', () => { }) it('rejects invalid JSON before auth or broker calls', async () => { - const { POST } = await import('@/app/api/providers/trading/order/route') - const response = await POST( + const response = await routePost( new NextRequest('http://localhost:3000/api/providers/trading/order', { method: 'POST', body: '{', 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.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..6433e18db 100644 --- a/apps/tradinggoose/app/api/skills/route.ts +++ b/apps/tradinggoose/app/api/skills/route.ts @@ -8,8 +8,10 @@ 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' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' +import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' const logger = createLogger('SkillsAPI') @@ -57,9 +59,11 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const result = await listSkills({ workspaceId }) - return NextResponse.json({ data: result }, { status: 200 }) + 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 }) } @@ -95,12 +99,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) { @@ -119,13 +147,22 @@ 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 }) } + if (validationError instanceof Error && validationError.message.includes('was not found')) { + return NextResponse.json({ error: validationError.message }, { status: 404 }) + } 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/templates/[id]/use/route.ts b/apps/tradinggoose/app/api/templates/[id]/use/route.ts index e8474a355..c8f635eb5 100644 --- a/apps/tradinggoose/app/api/templates/[id]/use/route.ts +++ b/apps/tradinggoose/app/api/templates/[id]/use/route.ts @@ -6,9 +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 { getBaseUrl } from '@/lib/urls/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' +import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' const logger = createLogger('TemplateUseAPI') @@ -65,51 +67,49 @@ 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 + 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) + 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, }) - 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), - }) - - 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 } - ) - } + const regeneratedState = regenerateWorkflowStateIds(templateState) + 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' }, + { status: 500 } + ) } await db 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/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.test.ts b/apps/tradinggoose/app/api/tools/custom/route.test.ts index d21b1c2b5..9fdcc31e6 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.test.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.test.ts @@ -6,7 +6,10 @@ 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() +const mockReadWorkflowAccessContext = vi.fn() vi.mock('@/lib/auth/hybrid', () => ({ checkHybridAuth: mockCheckHybridAuth, @@ -17,7 +20,13 @@ vi.mock('@/lib/permissions/utils', () => ({ })) vi.mock('@/lib/custom-tools/operations', () => ({ - upsertCustomTools: mockUpsertCustomTools, + createCustomTools: mockCreateCustomTools, + saveCustomTool: mockSaveCustomTool, + listCustomTools: mockListCustomTools, +})) + +vi.mock('@/lib/workflows/utils', () => ({ + readWorkflowAccessContext: mockReadWorkflowAccessContext, })) vi.mock('@tradinggoose/db', () => ({ @@ -45,7 +54,10 @@ 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([]) + mockReadWorkflowAccessContext.mockResolvedValue(null) }) afterEach(() => { @@ -71,7 +83,24 @@ describe('Custom Tools API Routes', () => { 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 7e38db129..264ec7349 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.ts @@ -1,15 +1,21 @@ 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' 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' +import { readWorkflowAccessContext } from '@/lib/workflows/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' +import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' +import { + deleteYjsSessionInSocketServer, + notifyEntityListMemberRemoved, +} from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('CustomToolsAPI') @@ -17,8 +23,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 workflowId = searchParams.get('workflowId') + const queryWorkspaceId = searchParams.get('workspaceId')?.trim() ?? '' + const workflowId = searchParams.get('workflowId')?.trim() ?? '' try { const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) @@ -28,43 +34,41 @@ 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}`) + let workspaceId = queryWorkspaceId + if (!workspaceId && workflowId) { + const accessContext = await readWorkflowAccessContext(workflowId, userId) + if (!accessContext) { return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } - - resolvedWorkspaceId = workflowData.workspaceId - } - - if (!resolvedWorkspaceId) { - 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 ( + !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 ${resolvedWorkspaceId}` + `[${requestId}] User ${userId} does not have access to workspace ${workspaceId}` ) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } } - const result = await listCustomTools({ workspaceId: resolvedWorkspaceId }) - - return NextResponse.json({ data: result }, { status: 200 }) + 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 }) } @@ -85,7 +89,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) { @@ -102,12 +106,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) { @@ -128,9 +159,21 @@ 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 }) + } + if (validationError instanceof Error && validationError.message.includes('was not found')) { + return NextResponse.json({ error: validationError.message }, { status: 404 }) + } 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 }) } @@ -174,20 +217,25 @@ 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() + const [existingTool] = await db + .select({ id: customTools.id }) .from(customTools) .where(and(eq(customTools.id, toolId), eq(customTools.workspaceId, workspaceId))) .limit(1) - if (existingTool.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(eq(customTools.id, 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 }) 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/users/me/api-keys/route.ts b/apps/tradinggoose/app/api/users/me/api-keys/route.ts index 96179b231..af04b24ed 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/auth' +import { + createApiKey, + getApiKeyDisplayFormat, + isApiKeyStorageAvailable, +} from '@/lib/api-key/service' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' @@ -32,16 +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) => { - const displayFormat = await getApiKeyDisplayFormat(key.key) - return { - ...key, - key: key.key, - displayKey: displayFormat, - } - }) - ) + const maskedKeys = keys.flatMap(({ key, ...apiKey }) => { + const displayKey = getApiKeyDisplayFormat(key) + return displayKey ? [{ ...apiKey, displayKey }] : [] + }) return NextResponse.json({ keys: maskedKeys }) } catch (error) { @@ -86,10 +84,13 @@ export async function POST(request: NextRequest) { ) } - const { key: plainKey, encryptedKey } = await createApiKey(true) + if (!isApiKeyStorageAvailable()) { + return NextResponse.json({ error: 'API key access is not configured' }, { status: 503 }) + } - 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 @@ -99,7 +100,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/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/v1/middleware.ts b/apps/tradinggoose/app/api/v1/middleware.ts index 485b1c44f..dd151eff8 100644 --- a/apps/tradinggoose/app/api/v1/middleware.ts +++ b/apps/tradinggoose/app/api/v1/middleware.ts @@ -1,50 +1,15 @@ import { type NextRequest, NextResponse } from 'next/server' -import { getPersonalEffectiveSubscription } from '@/lib/billing/core/subscription' -import { isBillingEnabledForRuntime } from '@/lib/billing/settings' +import { + checkApiEndpointRateLimit, + createApiAuthFailureRateLimitResult, + type RateLimitResult, +} from '@/lib/api/rate-limit' import { createLogger } from '@/lib/logs/console/logger' -import { ExecutionLimiter } from '@/services/queue/ExecutionLimiter' import { authenticateV1Request } from './auth' const logger = createLogger('V1Middleware') -const rateLimiter = new ExecutionLimiter() -type RateLimitFailureKind = 'auth' | 'dependency' - -async function getDefaultApiEndpointRateLimit(): Promise<number> { - return (await isBillingEnabledForRuntime()) ? 0 : Number.MAX_SAFE_INTEGER -} - -export interface RateLimitResult { - allowed: boolean - remaining: number - resetAt: Date - limit: number - userId?: string - error?: string - failureKind?: RateLimitFailureKind -} - -function createAuthFailureResult(error: string, limit: number): RateLimitResult { - return { - allowed: false, - remaining: 0, - limit, - resetAt: new Date(), - error, - failureKind: 'auth', - } -} - -function createDependencyFailureResult(error: string): RateLimitResult { - return { - allowed: false, - remaining: 0, - limit: 0, - resetAt: new Date(Date.now() + 60000), - error, - failureKind: 'dependency', - } -} +export type { RateLimitResult } from '@/lib/api/rate-limit' export async function checkRateLimit( request: NextRequest, @@ -56,65 +21,16 @@ export async function checkRateLimit( auth = await authenticateV1Request(request) } catch (error) { logger.error('Authentication error during rate limit check', { error }) - const limit = await getDefaultApiEndpointRateLimit().catch(() => 0) - return createAuthFailureResult('Authentication failed', limit) + return createApiAuthFailureRateLimitResult('Authentication failed') } if (!auth.authenticated) { - const limit = await getDefaultApiEndpointRateLimit() - return createAuthFailureResult(auth.error || 'Unauthorized', limit) + return createApiAuthFailureRateLimitResult(auth.error || 'Unauthorized') } const userId = auth.userId! - try { - const billingEnabled = await isBillingEnabledForRuntime() - if (!billingEnabled) { - return { - allowed: true, - remaining: Number.MAX_SAFE_INTEGER, - limit: Number.MAX_SAFE_INTEGER, - resetAt: new Date(Date.now() + 60000), - userId, - } - } - - const subscription = await getPersonalEffectiveSubscription(userId) - - const result = await rateLimiter.checkRateLimitWithSubscription( - userId, - subscription, - 'api-endpoint', - false - ) - - if (!result.allowed) { - logger.warn(`Rate limit exceeded for user ${userId}`, { - endpoint, - remaining: result.remaining, - resetAt: result.resetAt, - }) - } - - const rateLimitStatus = await rateLimiter.getRateLimitStatusWithSubscription( - userId, - subscription, - 'api-endpoint', - false - ) - - return { - ...result, - limit: rateLimitStatus.limit, - userId, - } - } catch (error) { - logger.error('Rate limit check error; failing closed', { error, endpoint, userId }) - return { - ...createDependencyFailureResult('Rate limit service unavailable'), - userId, - } - } + return checkApiEndpointRateLimit(userId, endpoint) } export function createRateLimitResponse(result: RateLimitResult): NextResponse { diff --git a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts index 45217e348..a64185652 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts @@ -3,8 +3,11 @@ 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 { 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' @@ -51,22 +54,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ userId, }) - let currentWorkflowData: { blocks: Record<string, any>; 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 database`) - currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId) - } - - if (!currentWorkflowData) { - logger.error(`[${requestId}] Could not load workflow ${workflowId} for autolayout`) - return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 }) + logger.info(`[${requestId}] Loading blocks from current workflow state`) } const autoLayoutOptions = { @@ -79,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:`, { @@ -98,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 @@ -112,11 +122,12 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ data: { blockCount, elapsed: `${elapsed}ms`, - layoutedBlocks: layoutResult.blocks, }, }) } 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 cf6145158..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(), - loadWorkflowState: (...args: unknown[]) => mockLoadWorkflowState(...args), + requireWorkflowRealtimeState: (...args: unknown[]) => mockLoadWorkflowState(...args), })) vi.doMock('@/lib/chat/published-deployment', () => ({ @@ -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 851a54a5c..59b99c661 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts @@ -7,11 +7,15 @@ 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, 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' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { + createErrorResponse, + createSuccessResponse, + createWorkflowRealtimeRequiredResponse, +} from '@/app/api/workflows/utils' const logger = createLogger('WorkflowDeployAPI') @@ -99,7 +103,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 requireWorkflowRealtimeState(id) if (currentState) { needsRedeployment = hasWorkflowChanged(currentState, active.state as any) } @@ -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) } } @@ -290,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]/deployments/[version]/revert/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts index a9e244307..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 @@ -6,28 +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 mockTryApplyWorkflowState = 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 } - }) - mockTryApplyWorkflowState.mockImplementation(async () => { - callOrder.push('apply') - return { success: true } - }) + mockApplyWorkflowState.mockResolvedValue(undefined) mockDbSelectLimit.mockResolvedValue([ { state: { @@ -53,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 })), @@ -71,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', @@ -107,11 +83,12 @@ describe('Revert To Deployment Version API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, + ensureUniqueBlockIds: vi.fn(async (_workflowId: string, state: any) => state), + ensureUniqueEdgeIds: vi.fn(async (_workflowId: string, state: any) => state), })) vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ - tryApplyWorkflowState: mockTryApplyWorkflowState, + applyWorkflowState: mockApplyWorkflowState, })) vi.doMock('@/lib/yjs/workflow-session', () => ({ @@ -121,14 +98,13 @@ describe('Revert To Deployment Version API Route', () => { loops: partial.loops ?? {}, parallels: partial.parallels ?? {}, lastSaved: partial.lastSaved, - isDeployed: partial.isDeployed, - deployedAt: partial.deployedAt, })), })) 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', () => ({ @@ -144,7 +120,7 @@ describe('Revert To Deployment Version API Route', () => { vi.clearAllMocks() }) - it('publishes the reverted Yjs state only after the durable writes complete', 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' @@ -155,8 +131,7 @@ describe('Revert To Deployment Version API Route', () => { }) expect(response.status).toBe(200) - expect(callOrder).toEqual(['save', 'db-update', 'apply']) - expect(mockTryApplyWorkflowState).toHaveBeenCalledWith( + expect(mockApplyWorkflowState).toHaveBeenCalledWith( 'workflow-1', expect.objectContaining({ blocks: expect.any(Object), @@ -173,8 +148,8 @@ 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 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( @@ -186,7 +161,37 @@ describe('Revert To Deployment Version API Route', () => { }) expect(response.status).toBe(500) - expect(callOrder).toEqual(['save']) - expect(mockTryApplyWorkflowState).not.toHaveBeenCalled() + expect(mockApplyWorkflowState).toHaveBeenCalledOnce() + }) + + it('preserves current variables when the deployment snapshot omits variables', async () => { + mockDbSelectLimit.mockResolvedValueOnce([ + { + state: { + blocks: { + 'block-1': { + id: 'block-1', + type: 'script', + subBlocks: {}, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }, + }, + ]) + + 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' + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workflow-1', version: 'active' }), + }) + + expect(response.status).toBe(200) + 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 b249490aa..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 @@ -1,15 +1,19 @@ -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' import { generateRequestId } from '@/lib/utils' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' +import { ensureUniqueBlockIds, ensureUniqueEdgeIds } 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' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { + createErrorResponse, + createSuccessResponse, + createWorkflowRealtimeRequiredResponse, +} from '@/app/api/workflows/utils' const logger = createLogger('RevertToDeploymentVersionAPI') @@ -71,52 +75,32 @@ export async function POST( } const now = new Date() - const revertVariables = deployedState.variables || undefined - - const saveResult = await saveWorkflowToNormalizedTables(id, { + const revertVariables = + deployedState.variables && + typeof deployedState.variables === 'object' && + !Array.isArray(deployedState.variables) + ? deployedState.variables + : undefined + + const revertedState = { blocks: deployedState.blocks, edges: deployedState.edges, loops: deployedState.loops || {}, parallels: deployedState.parallels || {}, 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, loops: persistedRevertedState.loops, parallels: persistedRevertedState.parallels, lastSaved: now.toISOString(), - isDeployed: true, - deployedAt: now.toISOString(), }) - await db - .update(workflow) - .set({ - lastSynced: now, - updatedAt: now, - ...(revertVariables ? { variables: revertVariables } : {}), - }) - .where(eq(workflow.id, id)) - - // Publish the reverted state to Yjs only after the durable writes succeed. - await tryApplyWorkflowState(id, revertSnapshot, revertVariables) + await applyWorkflowState(id, revertSnapshot, revertVariables) await pauseMonitorsMissingDeployedTrigger(id) await notifyMonitorsReconcile({ requestId, logger }) @@ -127,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]/duplicate/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts index 1c7a3cea0..9f79c7af1 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts @@ -6,10 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('Workflow Duplicate API Route', () => { let loadWorkflowStateMock: ReturnType<typeof vi.fn> - let remapVariableIdsMock: ReturnType<typeof vi.fn> let regenerateWorkflowStateIdsMock: ReturnType<typeof vi.fn> - let saveWorkflowToNormalizedTablesMock: ReturnType<typeof vi.fn> - let tryApplyWorkflowStateMock: ReturnType<typeof vi.fn> + let applyWorkflowStateMock: ReturnType<typeof vi.fn> let insertValuesMock: ReturnType<typeof vi.fn> let deleteWhereMock: ReturnType<typeof vi.fn> @@ -44,21 +42,8 @@ describe('Workflow Duplicate API Route', () => { vi.clearAllMocks() loadWorkflowStateMock = vi.fn() - remapVariableIdsMock = vi.fn((variables, newWorkflowId: string) => - Object.fromEntries( - Object.values(variables as Record<string, any>).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().mockResolvedValue({ success: true }) - tryApplyWorkflowStateMock = vi.fn().mockResolvedValue({ success: true }) + applyWorkflowStateMock = vi.fn().mockResolvedValue(undefined) insertValuesMock = vi.fn().mockResolvedValue(undefined) deleteWhereMock = vi.fn().mockResolvedValue(undefined) @@ -122,18 +107,14 @@ describe('Workflow Duplicate API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadWorkflowState: loadWorkflowStateMock, - remapVariableIds: remapVariableIdsMock, + 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', () => ({ - tryApplyWorkflowState: tryApplyWorkflowStateMock, - })) - - vi.doMock('@/lib/yjs/workflow-session', () => ({ - createWorkflowSnapshot: vi.fn((snapshot: Record<string, unknown>) => snapshot), + applyWorkflowState: applyWorkflowStateMock, })) }) @@ -141,7 +122,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': { @@ -167,7 +148,6 @@ describe('Workflow Duplicate API Route', () => { }, }, lastSaved: Date.now(), - source: 'yjs', }) const { POST } = await import('@/app/api/workflows/[id]/duplicate/route') @@ -180,42 +160,33 @@ describe('Workflow Duplicate API Route', () => { expect(response.status).toBe(201) expect(insertValuesMock).toHaveBeenCalledOnce() - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() - expect(tryApplyWorkflowStateMock).toHaveBeenCalledOnce() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() 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 savedState = 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(appliedWorkflowId) - expect(appliedSnapshot.blocks).toEqual( - expect.objectContaining({ - [Object.keys(appliedSnapshot.blocks)[0]]: expect.objectContaining({ - name: 'Live Agent', - }), - }) - ) - expect(savedState.blocks).toEqual( + expect(insertedWorkflow.id).toBe(persistedWorkflowId) + expect(persistedState.blocks).toEqual( expect.objectContaining({ - [Object.keys(savedState.blocks)[0]]: expect.objectContaining({ + [Object.keys(persistedState.blocks)[0]]: expect.objectContaining({ name: 'Live Agent', }), }) ) - expect(Object.keys(appliedVariables)).toHaveLength(1) - expect(Object.values(appliedVariables)).toEqual([ + expect(Object.keys(persistedVariables)).toHaveLength(1) + expect(Object.values(persistedVariables)).toEqual([ expect.objectContaining({ name: 'liveVar', value: 'live value', - workflowId: appliedWorkflowId, + workflowId: persistedWorkflowId, }), ]) - expect((Object.values(appliedVariables)[0] as { id: string }).id).not.toBe('live-var') + expect((Object.values(persistedVariables)[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 state materialization fails', async () => { loadWorkflowStateMock.mockResolvedValue({ blocks: {}, edges: [], @@ -223,12 +194,8 @@ describe('Workflow Duplicate API Route', () => { parallels: {}, variables: {}, lastSaved: Date.now(), - source: 'normalized', - }) - tryApplyWorkflowStateMock.mockResolvedValueOnce({ - success: false, - error: new Error('socket bridge unavailable'), }) + applyWorkflowStateMock.mockRejectedValueOnce(new Error('realtime unavailable')) const { POST } = await import('@/app/api/workflows/[id]/duplicate/route') const response = await POST( @@ -238,10 +205,9 @@ 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(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..5194a0903 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -8,15 +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 { normalizeVariables } from '@/lib/workflows/variable-utils' import { - loadWorkflowState, regenerateWorkflowStateIds, - remapVariableIds, - saveWorkflowToNormalizedTables, + requireWorkflowRealtimeState, } from '@/lib/workflows/db-helpers' -import { tryApplyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +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' @@ -29,38 +29,25 @@ const DuplicateRequestSchema = z.object({ folderId: z.string().nullable().optional(), }) -async function loadSourceWorkflowArtifacts( - sourceWorkflowId: string, - sourceVariables: unknown -): Promise<{ +async function loadSourceWorkflowRealtimeArtifacts(sourceWorkflowId: string): Promise<{ workflowState: WorkflowState variables: Record<string, Variable> - source: 'yjs' | 'normalized' }> { - const stateWithSource = await loadWorkflowState(sourceWorkflowId) - if (!stateWithSource) { + const editableState = await requireWorkflowRealtimeState(sourceWorkflowId) + if (!editableState) { 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, - 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, - source: stateWithSource.source, + variables: normalizeVariables(editableState.variables), } } @@ -117,7 +104,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: ) } - const sourceArtifacts = await loadSourceWorkflowArtifacts(sourceWorkflowId, source.variables) + const sourceArtifacts = await loadSourceWorkflowRealtimeArtifacts(sourceWorkflowId) const newWorkflowId = crypto.randomUUID() const now = new Date() @@ -125,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, @@ -132,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, @@ -140,58 +128,29 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: isDeployed: false, collaborators: [], runCount: 0, - variables: duplicatedVariables, isPublished: false, marketplaceData: null, }) try { - const lastSaved = now.toISOString() - - // 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, - loops: persistedDuplicatedState.loops, - parallels: persistedDuplicatedState.parallels, - lastSaved, - isDeployed: false, - }) - - const yjsApplyResult = await tryApplyWorkflowState( + await applyWorkflowState( newWorkflowId, - duplicatedSnapshot, + createWorkflowSnapshot(duplicatedWorkflowState), duplicatedVariables, - name + { name, description: resolvedDescription, folderId: folderId || null } ) - if (!yjsApplyResult.success) { - logger.warn( - `[${requestId}] Duplicated workflow ${newWorkflowId} without Yjs sync; canonical state was persisted`, - { sourceWorkflowId, newWorkflowId, error: yjsApplyResult.error } - ) - } - } catch (duplicationError) { + } catch (error) { await db.delete(workflow).where(eq(workflow.id, newWorkflowId)) - throw duplicationError + throw error } - 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 editable workflow state from Yjs`, { + 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( @@ -215,6 +174,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.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index bb5f160f4..c5c8270c1 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -18,8 +18,9 @@ describe('Workflow By ID API Route', () => { const mockReadWorkflowById = vi.fn() const mockReadWorkflowAccessContext = vi.fn() - const mockDeleteYjsSessionInSocketServer = vi.fn() const mockLoadWorkflowState = vi.fn() + const mockApplyWorkflowMetadata = vi.fn() + const mockDeleteYjsSession = vi.fn() beforeEach(() => { vi.resetModules() @@ -33,7 +34,9 @@ describe('Workflow By ID API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadWorkflowState: mockLoadWorkflowState, + WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', + isWorkflowRealtimeRequiredError: vi.fn(() => false), + requireWorkflowRealtimeState: mockLoadWorkflowState, })) vi.doMock('@tradinggoose/db', () => ({ @@ -65,13 +68,24 @@ describe('Workflow By ID API Route', () => { mockReadWorkflowById.mockReset() mockReadWorkflowAccessContext.mockReset() - mockDeleteYjsSessionInSocketServer.mockReset() mockLoadWorkflowState.mockReset() - mockDeleteYjsSessionInSocketServer.mockResolvedValue(undefined) + mockApplyWorkflowMetadata.mockReset() + mockDeleteYjsSession.mockReset() mockLoadWorkflowState.mockResolvedValue(null) + 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', () => ({ + applyWorkflowMetadata: mockApplyWorkflowMetadata, + })) vi.doMock('@/lib/yjs/server/snapshot-bridge', () => ({ - deleteYjsSessionInSocketServer: mockDeleteYjsSessionInSocketServer, + deleteYjsSessionInSocketServer: mockDeleteYjsSession, })) vi.doMock('@/lib/workflows/utils', () => ({ @@ -84,6 +98,13 @@ describe('Workflow By ID API Route', () => { vi.clearAllMocks() }) + function expectWorkflowRenameApplied() { + expect(mockLoadWorkflowState).not.toHaveBeenCalled() + expect(mockApplyWorkflowMetadata).toHaveBeenCalledWith('workflow-123', { + name: 'Updated Workflow', + }) + } + describe('GET /api/workflows/[id]', () => { it('should return 401 when user is not authenticated', async () => { vi.doMock('@/lib/auth', () => ({ @@ -141,7 +162,6 @@ describe('Workflow By ID API Route', () => { edges: [], loops: {}, parallels: {}, - source: 'normalized', } vi.doMock('@/lib/auth', () => ({ @@ -194,7 +214,6 @@ describe('Workflow By ID API Route', () => { edges: [], loops: {}, parallels: {}, - source: 'normalized', } vi.doMock('@/lib/auth', () => ({ @@ -268,7 +287,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 current workflow state when the loader has it', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -281,7 +300,6 @@ describe('Workflow By ID API Route', () => { edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2' }], loops: {}, parallels: {}, - source: 'yjs', } vi.doMock('@/lib/auth', () => ({ @@ -313,7 +331,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 current workflow state is missing', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -342,17 +360,14 @@ 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') }) }) describe('DELETE /api/workflows/[id]', () => { - it('should delete the socket/Yjs session after 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', @@ -360,6 +375,10 @@ describe('Workflow By ID API Route', () => { workspaceId: null, } const events: string[] = [] + mockDeleteYjsSession.mockImplementation(async () => { + events.push('yjs-delete') + throw new Error('socket offline') + }) vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ @@ -375,10 +394,6 @@ describe('Workflow By ID API Route', () => { isOwner: true, isWorkspaceOwner: false, }) - mockDeleteYjsSessionInSocketServer.mockImplementationOnce(async () => { - events.push('socket-delete') - }) - vi.doMock('@tradinggoose/db', () => ({ db: { delete: vi.fn().mockReturnValue({ @@ -402,11 +417,10 @@ 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(events).toEqual(['db-delete', 'socket-delete']) + expect(events).toEqual(['db-delete', 'yjs-delete']) }) - it('should not clean up the Yjs session if workflow row deletion fails', async () => { + it('should return 500 if workflow row deletion fails before session cleanup', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -450,8 +464,8 @@ 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).not.toHaveBeenCalled() expect(deleteWhereMock).toHaveBeenCalledOnce() - expect(mockDeleteYjsSessionInSocketServer).not.toHaveBeenCalled() }) it('should allow admin to delete workspace workflow', async () => { @@ -499,14 +513,53 @@ 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 deny deletion for non-admin users', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'other-user', + name: 'Test Workflow', + workspaceId: 'workspace-456', + } + + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), + })) + + mockReadWorkflowById.mockResolvedValueOnce(mockWorkflow) + mockReadWorkflowAccessContext.mockResolvedValueOnce({ + workflow: mockWorkflow, + workspaceOwnerId: 'workspace-456', + workspacePermission: null, + isOwner: false, + isWorkspaceOwner: false, + }) + + 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(403) + const data = await response.json() + expect(data.error).toBe('Access denied') + }) + }) + + describe('PUT /api/workflows/[id]', () => { + it('should allow owner to update workflow', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', name: 'Test Workflow', workspaceId: null, } - const deleteWhereMock = vi.fn().mockResolvedValue([{ id: 'workflow-123' }]) + + const updateData = { name: 'Updated Workflow' } vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ @@ -522,38 +575,23 @@ describe('Workflow By ID API Route', () => { isOwner: true, isWorkspaceOwner: false, }) - mockDeleteYjsSessionInSocketServer.mockRejectedValueOnce(new Error('socket offline')) - - 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', + method: 'PUT', + body: JSON.stringify(updateData), }) const params = Promise.resolve({ id: 'workflow-123' }) - const { DELETE } = await import('@/app/api/workflows/[id]/route') - const response = await DELETE(req, { params }) + 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.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(data.workflow.name).toBe('Updated Workflow') + expectWorkflowRenameApplied() }) - it('should deny deletion for non-admin users', async () => { + it('should allow users with write permission to update workflow', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'other-user', @@ -561,6 +599,8 @@ describe('Workflow By ID API Route', () => { workspaceId: 'workspace-456', } + const updateData = { name: 'Updated Workflow' } + vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ user: { id: 'user-123' }, @@ -571,36 +611,42 @@ describe('Workflow By ID API Route', () => { mockReadWorkflowAccessContext.mockResolvedValueOnce({ workflow: mockWorkflow, workspaceOwnerId: 'workspace-456', - workspacePermission: null, + workspacePermission: 'write', isOwner: false, isWorkspaceOwner: false, }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'DELETE', + method: 'PUT', + body: JSON.stringify(updateData), }) const params = Promise.resolve({ id: 'workflow-123' }) - const { DELETE } = await import('@/app/api/workflows/[id]/route') - const response = await DELETE(req, { params }) + const { PUT } = await import('@/app/api/workflows/[id]/route') + const response = await PUT(req, { params }) - expect(response.status).toBe(403) + expect(response.status).toBe(200) const data = await response.json() - expect(data.error).toBe('Access denied') + expect(data.workflow.name).toBe('Updated Workflow') + expectWorkflowRenameApplied() }) - }) - describe('PUT /api/workflows/[id]', () => { - it('should allow owner to update workflow', async () => { + it('updates workflow metadata through the Yjs session without loading workflow state', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', name: 'Test Workflow', + description: 'Old description', + folderId: null, workspaceId: null, } - const updateData = { name: 'Updated Workflow' } - const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() } + const updateData = { description: 'New description', folderId: 'folder-1' } + mockApplyWorkflowMetadata.mockResolvedValueOnce({ + ...mockWorkflow, + ...updateData, + updatedAt: new Date(), + }) vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ @@ -617,19 +663,6 @@ 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), @@ -641,19 +674,31 @@ 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(mockApplyWorkflowMetadata).toHaveBeenCalledWith('workflow-123', updateData) }) - it('should allow users with write permission to update workflow', async () => { + it('updates workflow name, description, and folder in one Yjs metadata patch', async () => { const mockWorkflow = { id: 'workflow-123', - userId: 'other-user', + userId: 'user-123', name: 'Test Workflow', - workspaceId: 'workspace-456', + description: 'Old description', + folderId: null, + workspaceId: null, } - - const updateData = { name: 'Updated Workflow' } - const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() } + 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({ @@ -664,25 +709,12 @@ describe('Workflow By ID API Route', () => { mockReadWorkflowById.mockResolvedValueOnce(mockWorkflow) mockReadWorkflowAccessContext.mockResolvedValueOnce({ workflow: mockWorkflow, - workspaceOwnerId: 'workspace-456', - workspacePermission: 'write', - isOwner: false, + workspaceOwnerId: null, + workspacePermission: null, + isOwner: true, 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), @@ -695,6 +727,10 @@ 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(mockApplyWorkflowMetadata).toHaveBeenCalledWith('workflow-123', updateData) }) it('should deny update for users with only read permission', async () => { @@ -759,8 +795,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', @@ -774,6 +809,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 cc0088b93..17a501a9e 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -6,19 +6,21 @@ 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 { 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' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' 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(), }) @@ -27,7 +29,7 @@ const UpdateWorkflowSchema = z /** * GET /api/workflows/[id] * Fetch a single workflow by ID - * Uses the authoritative Yjs-first 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() @@ -123,32 +125,29 @@ 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) + logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from Yjs session`) + const workflowState = await requireWorkflowRealtimeState(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 saved 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 editable Yjs 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) { @@ -163,6 +162,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 } : {}), @@ -173,13 +177,11 @@ 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, }, } - 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`) @@ -187,6 +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) + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } @@ -286,17 +290,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 deleteYjsSessionInSocketServer(workflowId).catch(() => undefined) const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully deleted workflow ${workflowId} in ${elapsed}ms`) @@ -368,22 +362,19 @@ 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() + const metadata = { + ...(updates.name !== undefined ? { name: updates.name } : {}), + ...(updates.description !== undefined ? { description: updates.description } : {}), + ...(updates.folderId !== undefined ? { folderId: updates.folderId } : {}), + } + 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`, { - updates: updateData, + updates, }) return NextResponse.json({ workflow: updatedWorkflow }, { status: 200 }) @@ -400,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/app/api/workflows/[id]/state/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts deleted file mode 100644 index 5de1bb481..000000000 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts +++ /dev/null @@ -1,235 +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 loadWorkflowStateFromYjsMock: ReturnType<typeof vi.fn> - let saveWorkflowToNormalizedTablesMock: ReturnType<typeof vi.fn> - let tryApplyWorkflowStateMock: ReturnType<typeof vi.fn> - let updateSetMock: ReturnType<typeof vi.fn> - - const createRequest = (body: Record<string, unknown>) => - 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: {}, - } - - beforeEach(() => { - vi.resetModules() - vi.clearAllMocks() - - loadWorkflowStateFromYjsMock = vi.fn().mockResolvedValue(null) - saveWorkflowToNormalizedTablesMock = vi.fn().mockResolvedValue({ success: true }) - tryApplyWorkflowStateMock = vi.fn().mockResolvedValue({ success: true }) - 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({ - 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', - 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', () => ({ - loadWorkflowStateFromYjs: loadWorkflowStateFromYjsMock, - saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, - toISOStringOrUndefined: vi.fn((value: string | number | Date | null | undefined) => - value == null ? undefined : new Date(value).toISOString() - ), - })) - - vi.doMock('@/lib/workflows/custom-tools-persistence', () => ({ - extractAndPersistCustomTools: vi.fn().mockResolvedValue({ - saved: 0, - errors: [], - }), - })) - - vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ - tryApplyWorkflowState: tryApplyWorkflowStateMock, - })) - }) - - afterEach(() => { - 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(), - }) - - 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).toHaveBeenCalledWith( - 'workflow-id', - expect.any(Object), - { - 'live-var': expect.objectContaining({ - name: 'liveVar', - value: 'live value', - }), - }, - undefined - ) - expect(updateSetMock).toHaveBeenCalledWith( - expect.objectContaining({ - variables: { - 'live-var': expect.objectContaining({ - name: 'liveVar', - value: 'live value', - }), - }, - }) - ) - }) - - it('does not republish workflow-row variables when no Yjs state is available in-process', 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(), - }) - ) - }) - - it('continues saving when authoritative Yjs variable lookup fails', async () => { - loadWorkflowStateFromYjsMock.mockRejectedValueOnce(new Error('socket bridge unavailable')) - - 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(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledWith( - 'workflow-id', - expect.any(Object) - ) - expect(tryApplyWorkflowStateMock).not.toHaveBeenCalled() - expect(updateSetMock).toHaveBeenCalledWith( - expect.not.objectContaining({ - variables: expect.anything(), - }) - ) - }) - - it('does not apply Yjs state when the canonical save fails', 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' }), - }) - - expect(response.status).toBe(500) - expect(tryApplyWorkflowStateMock).not.toHaveBeenCalled() - }) -}) 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 1219f2d28..000000000 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.ts +++ /dev/null @@ -1,295 +0,0 @@ -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' -import { generateRequestId } from '@/lib/utils' -import { extractAndPersistCustomTools } from '@/lib/workflows/custom-tools-persistence' -import { - 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 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()).optional(), -}) - -type ResolvedVariables = { - value: Record<string, any> | undefined - source: 'request' | 'yjs' | 'unavailable' -} - -/** - * PUT /api/workflows/[id]/state - * Save complete workflow state to normalized 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), - } - - // 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 } - ) - } - } - - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState as any) - - if (!saveResult.success) { - logger.error(`[${requestId}] Failed to save workflow ${workflowId} state:`, saveResult.error) - return NextResponse.json( - { error: 'Failed to save workflow state', details: saveResult.error }, - { status: 500 } - ) - } - - 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}` - ) - } - - // 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 }) - } - - // Update workflow metadata and persist variables - const syncedAt = new Date(workflowState.lastSaved) - await db - .update(workflow) - .set({ - lastSynced: syncedAt, - updatedAt: syncedAt, - ...(resolvedVariables.source !== 'unavailable' - ? { variables: resolvedVariables.value ?? {} } - : {}), - }) - .where(eq(workflow.id, workflowId)) - - 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/workflows/[id]/status/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts index fcfaa4240..bc5b265b0 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts @@ -51,10 +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', () => ({ - loadWorkflowState: mockLoadWorkflowState, + WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', + isWorkflowRealtimeRequiredError: vi.fn(() => false), + requireWorkflowRealtimeState: mockLoadWorkflowState, })) vi.doMock('@/lib/workflows/utils', () => ({ @@ -96,10 +99,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 +121,6 @@ describe('Workflow Status API Route', () => { value: 'us-west-2', }, }, - source: 'normalized', }) mockLimit.mockResolvedValue([ @@ -152,8 +151,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 +175,6 @@ describe('Workflow Status API Route', () => { value: 'us-west-2', }, }, - source: 'normalized', }) mockLimit.mockResolvedValue([ @@ -202,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 38e4345ff..a50b260c7 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 { loadWorkflowState } 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 { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { + createErrorResponse, + createSuccessResponse, + createWorkflowRealtimeRequiredResponse, +} from '@/app/api/workflows/utils' const logger = createLogger('WorkflowStatusAPI') @@ -26,10 +30,9 @@ 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 workflow state and the active deployment version in parallel. const [currentState, [active]] = await Promise.all([ - loadWorkflowState(id), + requireWorkflowRealtimeState(id), db .select({ state: workflowDeploymentVersion.state }) .from(workflowDeploymentVersion) @@ -44,7 +47,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ ]) if (!currentState) { - return createErrorResponse('Failed to load workflow state', 500) + logger.warn(`[${requestId}] Workflow ${id} is missing editable state`) + return createErrorResponse('Workflow state is missing', 409) } if (active?.state) { @@ -60,6 +64,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) } } diff --git a/apps/tradinggoose/app/api/workflows/middleware.ts b/apps/tradinggoose/app/api/workflows/middleware.ts index 0f5bf3e15..750db7c5d 100644 --- a/apps/tradinggoose/app/api/workflows/middleware.ts +++ b/apps/tradinggoose/app/api/workflows/middleware.ts @@ -1,8 +1,8 @@ import type { NextRequest } from 'next/server' -import { authenticateApiKey } from '@/lib/api-key/auth' import { type ApiKeyAuthResult, authenticateApiKeyFromHeader, + storedApiKeyMatches, updateApiKeyLastUsed, } from '@/lib/api-key/service' import { env } from '@/lib/env' @@ -67,7 +67,18 @@ export async function validateWorkflowAccess( // If a pinned key exists, only accept that specific key if (workflow.pinnedApiKey?.key) { - const isValidPinnedKey = await authenticateApiKey(apiKeyHeader, 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 { error: { @@ -82,45 +93,45 @@ 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, }, } + } + + // Try personal keys first + const personalResult = await authenticateApiKeyFromHeader(apiKeyHeader, { + userId: workflow.userId as string, + keyTypes: ['personal'], + }) + + let validResult = null + if (personalResult.success) { + validResult = personalResult } else { - // Try personal keys first - const personalResult = await authenticateApiKeyFromHeader(apiKeyHeader, { - userId: workflow.userId as string, - keyTypes: ['personal'], + // Try workspace keys + const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader, { + workspaceId: workflow.workspaceId as string, + keyTypes: ['workspace'], }) - let validResult = null - if (personalResult.success) { - validResult = personalResult - } else { - // Try workspace keys - const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader, { - workspaceId: workflow.workspaceId as string, - keyTypes: ['workspace'], - }) - - if (workspaceResult.success) { - validResult = workspaceResult - } + if (workspaceResult.success) { + validResult = workspaceResult } + } - // If no valid key found, reject - if (!validResult) { - return { - error: { - message: 'Unauthorized: Invalid API key', - status: 401, - }, - } + // If no valid key found, reject + if (!validResult) { + return { + error: { + message: 'Unauthorized: Invalid API key', + status: 401, + }, } - - await updateApiKeyLastUsed(validResult.keyId!) - return { workflow, apiKeyAuth: validResult } } + + await updateApiKeyLastUsed(validResult.keyId!) + return { workflow, apiKeyAuth: validResult } } return { workflow } } catch (error) { diff --git a/apps/tradinggoose/app/api/workflows/route.test.ts b/apps/tradinggoose/app/api/workflows/route.test.ts index d10cb019b..d388e6b16 100644 --- a/apps/tradinggoose/app/api/workflows/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/route.test.ts @@ -7,8 +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 tryApplyWorkflowStateMock = vi.fn() + const applyWorkflowStateMock = vi.fn() const randomUUIDMock = vi.fn() const createRequest = (body: Record<string, unknown>) => @@ -26,8 +25,7 @@ describe('Workflow API Route', () => { insertValuesMock.mockResolvedValue(undefined) deleteWhereMock.mockResolvedValue(undefined) - saveWorkflowToNormalizedTablesMock.mockResolvedValue({ success: true }) - tryApplyWorkflowStateMock.mockResolvedValue({ success: true }) + applyWorkflowStateMock.mockResolvedValue(undefined) randomUUIDMock.mockReset() randomUUIDMock.mockReturnValueOnce('workflow-123').mockReturnValueOnce('variable-123') vi.stubGlobal('crypto', { @@ -86,28 +84,12 @@ describe('Workflow API Route', () => { generateRequestId: vi.fn(() => 'request-id'), })) - vi.doMock('@/lib/workflows/db-helpers', () => ({ - remapVariableIds: vi.fn((variables: Record<string, any>, workflowId: string) => - Object.fromEntries( - Object.entries(variables).map(([key, variable]) => [ - key, - { - ...variable, - id: crypto.randomUUID(), - workflowId, - }, - ]) - ) - ), - saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, - })) - vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ - tryApplyWorkflowState: tryApplyWorkflowStateMock, + applyWorkflowState: applyWorkflowStateMock, })) - vi.doMock('@/lib/yjs/workflow-session', () => ({ - createWorkflowSnapshot: vi.fn((snapshot) => snapshot), + vi.doMock('@/app/api/workflows/utils', () => ({ + createWorkflowRealtimeRequiredResponse: vi.fn(() => null), })) vi.doMock('@/lib/telemetry/tracer', () => ({ @@ -119,7 +101,7 @@ describe('Workflow API Route', () => { vi.unstubAllGlobals() }) - it('persists initial workflow state canonically before seeding Yjs', async () => { + it('applies initial workflow state through Yjs materialization', async () => { const initialWorkflowState = { blocks: { 'block-1': { @@ -158,13 +140,13 @@ describe('Workflow API Route', () => { expect(response.status).toBe(200) expect(insertValuesMock).toHaveBeenCalledOnce() - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() - expect(tryApplyWorkflowStateMock).toHaveBeenCalledOnce() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() const insertedWorkflow = insertValuesMock.mock.calls[0][0] - const canonicalState = saveWorkflowToNormalizedTablesMock.mock.calls[0][1] + const persistedState = applyWorkflowStateMock.mock.calls[0][1] + const persistedVariables = applyWorkflowStateMock.mock.calls[0][2] - const insertedVariableValues = Object.values(insertedWorkflow.variables as Record<string, any>) + const insertedVariableValues = Object.values(persistedVariables as Record<string, any>) expect(insertedVariableValues).toHaveLength(1) expect(insertedVariableValues[0]).toEqual({ id: 'variable-123', @@ -173,32 +155,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, - isDeployed: false, - }) - ) - expect(canonicalState.lastSaved).toEqual(expect.any(Number)) - expect(tryApplyWorkflowStateMock).toHaveBeenCalledWith( - insertedWorkflow.id, - expect.objectContaining({ - blocks: initialWorkflowState.blocks, }), - insertedWorkflow.variables, - 'Workflow Copy' + persistedVariables, + expect.objectContaining({ + name: 'Workflow Copy', + description: 'Created from seed', + folderId: null, + }) ) }) - 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 persistence fails', async () => { + applyWorkflowStateMock.mockRejectedValueOnce(new Error('realtime unavailable')) const { POST } = await import('@/app/api/workflows/route') const response = await POST( @@ -216,9 +191,36 @@ describe('Workflow API Route', () => { ) expect(response.status).toBe(500) - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() expect(deleteWhereMock).toHaveBeenCalledOnce() - expect(tryApplyWorkflowStateMock).not.toHaveBeenCalled() + }) + + 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({ + name: 'Blank Workflow', + workspaceId: 'workspace-1', + }) + ) + + expect(response.status).toBe(200) + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + + const insertedWorkflow = insertValuesMock.mock.calls[0][0] + const persistedVariables = applyWorkflowStateMock.mock.calls[0][2] + expect(persistedVariables).toEqual({}) + expect(applyWorkflowStateMock).toHaveBeenCalledWith( + insertedWorkflow.id, + expect.objectContaining({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + }), + {}, + expect.any(Object) + ) }) 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..e751fa9f8 100644 --- a/apps/tradinggoose/app/api/workflows/route.ts +++ b/apps/tradinggoose/app/api/workflows/route.ts @@ -8,10 +8,12 @@ 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 { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' +import { remapVariableIds } from '@/lib/workflows/import-export' 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 { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowAPI') @@ -34,32 +36,30 @@ function getInitialWorkflowState( ): { canonicalState: WorkflowState variables: Record<string, unknown> -} | null { - if (!isPlainObject(initialWorkflowState)) { - return null - } - - 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 source = isPlainObject(initialWorkflowState) + ? initialWorkflowState + : buildDefaultWorkflowArtifacts().workflowState + const sourceRecord = source as Record<string, unknown> + + 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 : {} + 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'], parallels: parallels as WorkflowState['parallels'], lastSaved: now.getTime(), - isDeployed: false, - deployedAt: undefined, - deploymentStatuses: {}, - needsRedeployment: false, }, variables, } @@ -157,7 +157,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) @@ -190,39 +190,20 @@ export async function POST(req: NextRequest) { isDeployed: false, collaborators: [], runCount: 0, - variables: remappedVariables, isPublished: false, 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 - } - - // 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 yjsSeedResult = await tryApplyWorkflowState( - workflowId, - defaultWorkflowSnapshot, - remappedVariables, - name - ) - if (yjsSeedResult.success) { - logger.info(`[${requestId}] Seeded Yjs doc for new workflow ${workflowId}`) + 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 error } logger.info(`[${requestId}] Successfully created workflow ${workflowId}`) @@ -238,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/app/api/workflows/utils.ts b/apps/tradinggoose/app/api/workflows/utils.ts index 75ee1ab97..36610ba66 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( + 'Editable workflow realtime orchestration is required', + 503, + WORKFLOW_REALTIME_REQUIRED_CODE + ) +} 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..be2c7be80 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts @@ -92,19 +92,20 @@ describe('Workflow YAML Export API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadWorkflowState: loadWorkflowStateMock, + WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', + isWorkflowRealtimeRequiredError: vi.fn(() => false), + requireWorkflowRealtimeState: loadWorkflowStateMock, })) - vi.doMock('@/lib/copilot/tools/client/workflow/block-output-utils', () => ({ + vi.doMock('@/lib/copilot/workflow/block-output-utils', () => ({ extractSubBlockValuesFromBlocks: vi.fn((blocks: Record<string, any>) => Object.fromEntries( 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] + ) ), ]) ) @@ -130,43 +131,42 @@ describe('Workflow YAML Export API Route', () => { }) it( - 'prefers the live Yjs workflow snapshot 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({ - 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: 'yjs', - }) + 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({ @@ -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 current workflow state', async () => { loadWorkflowStateMock.mockResolvedValue({ blocks: { 'db-block': { @@ -221,7 +221,6 @@ describe('Workflow YAML Export API Route', () => { }, }, lastSaved: Date.now(), - source: 'normalized', }) 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 b65bc902d..96bbf44d7 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts @@ -3,12 +3,13 @@ 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/tools/client/workflow/block-output-utils' +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' import { resolveOutputType } from '@/blocks/utils' @@ -37,7 +38,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) @@ -70,9 +71,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const stateWithSource = await loadWorkflowState(workflowId) + const editableState = await requireWorkflowRealtimeState(workflowId) - if (!stateWithSource) { + if (!editableState) { return NextResponse.json( { success: false, error: 'Workflow has no state data' }, { status: 400 } @@ -81,17 +82,18 @@ export async function GET(request: NextRequest) { const workflowState: any = { deploymentStatuses: {}, - 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, @@ -180,6 +182,8 @@ export async function GET(request: NextRequest) { }) } catch (error) { logger.error(`[${requestId}] YAML export failed`, error) + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return NextResponse.json( { success: false, 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..5db1cceaf 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/auth' +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' @@ -57,16 +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) => { - const displayFormat = await getApiKeyDisplayFormat(key.key) - return { - ...key, - key: key.key, - displayKey: displayFormat, - } - }) - ) + const formattedWorkspaceKeys = workspaceKeys.flatMap(({ key, ...apiKey }) => { + const displayKey = getApiKeyDisplayFormat(key) + return displayKey ? [{ ...apiKey, displayKey }] : [] + }) return NextResponse.json({ keys: formattedWorkspaceKeys, @@ -122,10 +120,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ) } - const { key: plainKey, encryptedKey } = await createApiKey(true) + if (!isApiKeyStorageAvailable()) { + return NextResponse.json({ error: 'API key access is not configured' }, { status: 503 }) + } - 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 @@ -136,7 +137,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/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 b4010a1d6..c8c7124b5 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 }) + 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}` diff --git a/apps/tradinggoose/app/api/workspaces/route.test.ts b/apps/tradinggoose/app/api/workspaces/route.test.ts index 1e282e324..c720aee00 100644 --- a/apps/tradinggoose/app/api/workspaces/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/route.test.ts @@ -1,24 +1,10 @@ /** * @vitest-environment node */ -import { NextRequest } from 'next/server' 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 updateWhereMock = vi.fn() - const updateSetMock = vi.fn() - const updateMock = vi.fn() - const mockSaveWorkflowToNormalizedTables = vi.fn() - const mockTryApplyWorkflowState = vi.fn() let userWorkspaces: Array<{ workspace: Record<string, unknown> permissionType: 'admin' | 'write' | 'read' | null @@ -29,20 +15,8 @@ describe('Workspaces API Route', () => { vi.clearAllMocks() userWorkspaces = [] - txInsertValuesMock.mockResolvedValue(undefined) - transactionMock.mockImplementation(async (callback) => - callback({ insert: txInsertMock, delete: deleteMock }) - ) - deleteWhereMock.mockResolvedValue(undefined) - updateWhereMock.mockResolvedValue([]) - updateSetMock.mockReturnValue({ where: updateWhereMock }) - updateMock.mockReturnValue({ set: updateSetMock }) - mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) - mockTryApplyWorkflowState.mockResolvedValue({ success: true }) - vi.doMock('@tradinggoose/db', () => ({ db: { - delete: deleteMock, select: vi.fn(() => ({ from: vi.fn(() => ({ leftJoin: vi.fn(() => ({ @@ -57,8 +31,10 @@ describe('Workspaces API Route', () => { })), })), })), - update: updateMock, transaction: transactionMock, + insert: vi.fn(() => ({ + values: vi.fn().mockResolvedValue(undefined), + })), }, })) @@ -69,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', @@ -97,29 +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/yjs/server/apply-workflow-state', () => ({ - tryApplyWorkflowState: mockTryApplyWorkflowState, - })) - - vi.doMock('@/lib/yjs/workflow-session', () => ({ - createWorkflowSnapshot: vi.fn(() => ({})), - })) - vi.doMock('@/lib/workspaces/billing-owner', () => ({ toWorkspaceApiRecord: vi.fn((workspace) => ({ ...workspace, @@ -137,28 +85,17 @@ 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 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: { @@ -177,7 +114,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) @@ -192,7 +129,6 @@ describe('Workspaces API Route', () => { role: 'owner', permissions: 'admin', }) - expect(updateMock).not.toHaveBeenCalled() expect(transactionMock).not.toHaveBeenCalled() }) @@ -215,7 +151,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) @@ -228,60 +164,4 @@ 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.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', - () => - mockTryApplyWorkflowState.mockResolvedValue({ - success: false, - error: new Error('socket unavailable'), - }), - ], - ])('removes a newly created workspace when default workflow %s', async (_case, fail) => { - fail() - 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/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/app/api/yjs/sessions/[sessionId]/snapshot/route.ts b/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts index 9273c4d0d..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,51 +5,48 @@ import { parseYjsTransportEnvelope, } from '@/lib/copilot/review-sessions/identity' import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' +import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' import { - readBootstrappedReviewTargetSnapshot, - ReviewTargetBootstrapError, -} from '@/lib/yjs/server/bootstrap-review-target' + applyYjsUpdateInSocketServer, + SocketServerBridgeError, +} from '@/lib/yjs/server/snapshot-bridge' export const dynamic = 'force-dynamic' -export async function GET( +function getPublicBridgeStatus(error: SocketServerBridgeError) { + const { status } = error + return status === 400 || status === 404 || status === 409 || status === 410 ? status : 503 +} + +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<string, string | undefined> = {} - 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,24 +59,77 @@ 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, }) } 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 }) } } + +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 }) + } + + try { + const { updateBase64 } = (await request.json().catch(() => ({}))) as { + updateBase64?: unknown + } + if (typeof updateBase64 !== 'string' || !updateBase64) { + return NextResponse.json({ error: 'updateBase64 is required' }, { status: 400 }) + } + + await applyYjsUpdateInSocketServer( + descriptor.yjsSessionId, + request.nextUrl.search, + updateBase64 + ) + + return NextResponse.json({ success: true }) + } catch (error) { + if (error instanceof SocketServerBridgeError) { + 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/app/mcp/[[...command]]/route.test.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts new file mode 100644 index 000000000..e0216da45 --- /dev/null +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts @@ -0,0 +1,183 @@ +/** + * @vitest-environment node + */ + +import { spawnSync } from 'child_process' +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildMcpInstallScript } from '../../../lib/mcp/install-script' + +async function callInstaller( + pathname: string, + command?: string[], + headers?: HeadersInit, + origin = 'https://studio.example.test' +) { + const { GET } = await import('./route') + return GET(new NextRequest(`${origin}${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', () => { + beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://studio.example.test') + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + 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 <studio-url>/mcp/setup | sh') + expect(script).toContain('curl -fsSL <studio-url>/mcp/setup/codex | sh') + expect(script).toContain('curl -fsSL <studio-url>/mcp/login | sh') + expect(script).toContain('irm <studio-url>/mcp/setup | iex') + expect(script).toContain('irm <studio-url>/mcp/setup/codex | iex') + expect(script).toContain('irm <studio-url>/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('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 ' + 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, login.token])') + expect(script).toContain("const mcpServerName = 'TradingGoose'") + 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')") + 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 ' + 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, login.token])' + ) + const acknowledgeIndex = script.indexOf('await acknowledge(login)', setupIndex) + expect(printedTokenIndex).toBeGreaterThan(firstReturnTokenIndex) + expect(configWriteIndex).toBeGreaterThan(setupIndex) + expect(acknowledgeIndex).toBeGreaterThan(setupIndex) + expect(acknowledgeIndex).toBeLessThan(configWriteIndex) + }) + + 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('uses configured and quoted installer base URLs', async () => { + const response = await callInstaller( + '/mcp', + undefined, + undefined, + 'https://request.example.test' + ) + const script = await response.text() + + 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", + { + command: 'login', + format: 'sh', + } + ) + const powerShellScript = buildMcpInstallScript( + "https://studio.example.test/$(bad)`bad`'quote", + { + command: 'login', + format: 'powershell', + } + ) + + expectShellScript(shellScript) + expect(shellScript).toContain( + "BASE_URL='https://studio.example.test/$(touch pwn)`bad`'\"'\"'quote'" + ) + expect(powerShellScript).toContain( + "$BaseUrl = 'https://studio.example.test/$(bad)`bad`''quote'" + ) + }) + + 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 <studio-url>/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'") + 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') + }) + + 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..8bd242b79 --- /dev/null +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.ts @@ -0,0 +1,71 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { + buildMcpInstallScript, + type McpInstallScriptFormat, + type McpInstallScriptOptions, +} from '../../../lib/mcp/install-script' +import { getBaseUrl } from '../../../lib/urls/utils' + +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(getBaseUrl(), { ...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/query-provider.tsx b/apps/tradinggoose/app/query-provider.tsx index 1d73c209e..de1745bd8 100644 --- a/apps/tradinggoose/app/query-provider.tsx +++ b/apps/tradinggoose/app/query-provider.tsx @@ -4,17 +4,28 @@ 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, - }, - }, - }) - ) +let browserQueryClient: QueryClient | undefined + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, + }, + }) +} + +export function getQueryClient() { + if (typeof window === 'undefined') { + return createQueryClient() + } + browserQueryClient ??= createQueryClient() + return browserQueryClient +} + +export function QueryProvider({ children }: { children: ReactNode }) { + const [queryClient] = useState(getQueryClient) return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/api-keys/workspace-api-keys-card.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/api-keys/workspace-api-keys-card.tsx index a9e1283c5..2aefb2e45 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/api-keys/workspace-api-keys-card.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/api-keys/workspace-api-keys-card.tsx @@ -2,33 +2,17 @@ import { forwardRef, + type Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, - type Ref, } from 'react' +import { AlertCircle, Check, Copy, Pencil, Plus, Search, Trash2, X } from 'lucide-react' import { useLocale, useTranslations } from 'next-intl' -import { - useApiKeys, - useCreateApiKey, - useDeleteApiKey, - type ApiKey, -} from '@/hooks/queries/api-keys' -import { - AlertCircle, - Check, - Copy, - Eye, - EyeOff, - Pencil, - Plus, - Search, - Trash2, - X, -} from 'lucide-react' +import { Alert, AlertDescription, Button, Input, Label, Skeleton } from '@/components/ui' import { AlertDialog, AlertDialogAction, @@ -39,11 +23,11 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' -import { Alert, AlertDescription, Button, Input, Label, Skeleton } from '@/components/ui' import { createLogger } from '@/lib/logs/console/logger' -import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { cn } from '@/lib/utils' -import { type LocaleCode } from '@/i18n/utils' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { type ApiKey, useApiKeys, useCreateApiKey, useDeleteApiKey } from '@/hooks/queries/api-keys' +import type { LocaleCode } from '@/i18n/utils' interface WorkspaceApiKeysCardProps { workspaceId?: string @@ -61,25 +45,6 @@ export interface WorkspaceApiKeysCardHandle { openCreateDialog: () => void } -const getMaskedKeyValue = (apiKey: ApiKey): string => { - const sourceKey = apiKey.key || apiKey.displayKey || '' - if (!sourceKey) return '' - - const prefixLength = Math.min(4, sourceKey.length) - const suffixLength = Math.min(4, sourceKey.length - prefixLength) - const prefix = sourceKey.slice(0, prefixLength) - const suffix = sourceKey.slice(sourceKey.length - suffixLength) - - const totalLength = apiKey.key?.length ?? sourceKey.length - const maskedSegmentLength = Math.max(totalLength - (prefixLength + suffixLength), 3) - - if (maskedSegmentLength <= 0) { - return `${prefix}${suffix}` - } - - return `${prefix}${'.'.repeat(maskedSegmentLength)}${suffix}` -} - function ApiKeyDisplay({ value }: { value: string }) { return ( <div className='flex h-9 items-center justify-center rounded-md bg-muted/70 px-3 text-center'> @@ -122,8 +87,6 @@ const WorkspaceApiKeysCardComponent = ( const [deleteConfirmationName, setDeleteConfirmationName] = useState('') const [copySuccess, setCopySuccess] = useState(false) const [createError, setCreateError] = useState<string | null>(null) - const [revealedKeys, setRevealedKeys] = useState<Record<string, boolean>>({}) - const [copiedKeyId, setCopiedKeyId] = useState<string | null>(null) const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const [editingKeyId, setEditingKeyId] = useState<string | null>(null) const [editingKeyName, setEditingKeyName] = useState('') @@ -209,35 +172,6 @@ const WorkspaceApiKeysCardComponent = ( }) } - const handleCopyKey = useCallback( - (keyValue: string, keyId: string) => { - if (!keyValue || typeof navigator === 'undefined' || !navigator.clipboard) { - return - } - - void navigator.clipboard - .writeText(keyValue) - .then(() => { - setCopiedKeyId(keyId) - if (copyTimeoutRef.current) { - clearTimeout(copyTimeoutRef.current) - } - copyTimeoutRef.current = setTimeout(() => setCopiedKeyId(null), 1500) - }) - .catch((error) => { - logger.error('Error copying API key', { error, scope }) - }) - }, - [] - ) - - const toggleRevealKey = useCallback((keyId: string) => { - setRevealedKeys((prev) => ({ - ...prev, - [keyId]: !prev[keyId], - })) - }, []) - const startEditingKey = useCallback( (key: ApiKey) => { if (!canRenameKeys) return @@ -299,7 +233,18 @@ const WorkspaceApiKeysCardComponent = ( } finally { setIsUpdatingKeyName(false) } - }, [cancelEditingKey, canRenameKeys, editingKeyId, editingKeyName, refetchApiKeys, scope, scopeLabel, t, workspaceId, isWorkspaceScope]) + }, [ + cancelEditingKey, + canRenameKeys, + editingKeyId, + editingKeyName, + refetchApiKeys, + scope, + scopeLabel, + t, + workspaceId, + isWorkspaceScope, + ]) const handleCreateKey = async () => { if (!newKeyName.trim() || isSubmittingCreate) return @@ -327,9 +272,7 @@ const WorkspaceApiKeysCardComponent = ( } catch (error) { logger.error('Error creating API key', { error, scope }) const message = - error instanceof Error - ? error.message - : t('labels.failedCreate', { scope: scopeLabel }) + error instanceof Error ? error.message : t('labels.failedCreate', { scope: scopeLabel }) setCreateError(message) } } @@ -356,9 +299,22 @@ const WorkspaceApiKeysCardComponent = ( } const copyToClipboard = (key: string) => { - navigator.clipboard.writeText(key) - setCopySuccess(true) - setTimeout(() => setCopySuccess(false), 1500) + if (typeof navigator === 'undefined' || !navigator.clipboard) { + return + } + + void navigator.clipboard + .writeText(key) + .then(() => { + setCopySuccess(true) + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current) + } + copyTimeoutRef.current = setTimeout(() => setCopySuccess(false), 1500) + }) + .catch((error) => { + logger.error('Error copying API key', { error, scope }) + }) } const renderCardView = () => { @@ -403,16 +359,6 @@ const WorkspaceApiKeysCardComponent = ( return ( <div className='grid grid-cols-1 gap-4 lg:grid-cols-2'> {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 - return ( <div key={key.id} @@ -422,7 +368,7 @@ const WorkspaceApiKeysCardComponent = ( <div className='w-full'> {canRenameKeys && editingKeyId === key.id ? ( <div className='py-1.5'> - <div className='flex items-center gap-2 max-w-md'> + <div className='flex max-w-md items-center gap-2'> <Input ref={(el) => { if (editingKeyId === key.id) { @@ -442,7 +388,7 @@ const WorkspaceApiKeysCardComponent = ( } }} disabled={isUpdatingKeyName} - className='h-8 flex-1 min-w-0' + className='h-8 min-w-0 flex-1' autoComplete='off' /> <button @@ -455,9 +401,7 @@ const WorkspaceApiKeysCardComponent = ( <span className='sr-only'>{t('labels.saveName')}</span> </button> </div> - {renameError && ( - <p className='text-destructive text-xs'>{renameError}</p> - )} + {renameError && <p className='text-destructive text-xs'>{renameError}</p>} </div> ) : ( <div className='flex items-center justify-center gap-2'> @@ -475,7 +419,9 @@ const WorkspaceApiKeysCardComponent = ( disabled={isUpdatingKeyName || (isWorkspaceScope && !workspaceId)} > <Pencil className='h-3.5 w-3.5' /> - <span className='sr-only'>{t('labels.rename', { scope: scopeLabel })}</span> + <span className='sr-only'> + {t('labels.rename', { scope: scopeLabel })} + </span> </button> )} </div> @@ -483,39 +429,9 @@ const WorkspaceApiKeysCardComponent = ( </div> <div className='flex w-full justify-center'> <div className='flex flex-col items-center gap-2 md:flex-row md:justify-center md:gap-2'> - <button - type='button' - disabled={!canRevealOrCopy} - className='inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50' - onClick={() => toggleRevealKey(key.id)} - > - {isRevealed ? ( - <EyeOff className='h-3.5 w-3.5' /> - ) : ( - <Eye className='h-3.5 w-3.5' /> - )} - <span className='sr-only'> - {isRevealed - ? t('labels.hide', { scope: scopeLabel }) - : t('labels.reveal', { scope: scopeLabel })} - </span> - </button> <div className='max-w-xs'> - <ApiKeyDisplay value={displayValue} /> + <ApiKeyDisplay value={key.displayKey || '—'} /> </div> - <button - type='button' - disabled={!canRevealOrCopy} - className='inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50' - onClick={() => handleCopyKey(rawKeyValue, key.id)} - > - {isCopied ? ( - <Check className='h-3.5 w-3.5' /> - ) : ( - <Copy className='h-3.5 w-3.5' /> - )} - <span className='sr-only'>{t('labels.copy', { scope: scopeLabel })}</span> - </button> <button type='button' disabled={!canDeleteKeys} @@ -579,8 +495,8 @@ const WorkspaceApiKeysCardComponent = ( setIsCreateDialogOpen(true) setCreateError(null) }} - > - <Plus className='mr-2 h-4 w-4' /> + > + <Plus className='mr-2 h-4 w-4' /> {t(`emptyState.${scope}.button`)} </Button> )} @@ -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 ( <tr key={key.id} className='border-b transition-colors hover:bg-card/30'> - <td className='px-4 py-4 text-muted-foreground text-sm text-center'> + <td className='px-4 py-4 text-center text-muted-foreground text-sm'> {formatDate(key.createdAt)} </td> - <td className='px-4 py-4 align-center'> + <td className='px-4 py-4 align-middle'> {canRenameKeys && editingKeyId === key.id ? ( <div className='space-y-2'> <div className='flex max-w-sm items-center gap-2'> @@ -646,58 +553,24 @@ const WorkspaceApiKeysCardComponent = ( <p className='text-destructive text-xs'>{renameError}</p> )} </div> - ) : ( - <div className='text-center'> - <p className='font-medium text-sm'>{key.name}</p> - </div> - )} + ) : ( + <div className='text-center'> + <p className='font-medium text-sm'>{key.name}</p> + </div> + )} </td> <td className='px-4 py-4'> <div className='flex flex-wrap items-center gap-2 md:flex-nowrap'> - <Button - type='button' - variant='ghost' - size='icon' - disabled={!canRevealOrCopy} - className='h-8 w-8 text-muted-foreground' - onClick={() => toggleRevealKey(key.id)} - > - {isRevealed ? ( - <EyeOff className='h-4 w-4' /> - ) : ( - <Eye className='h-4 w-4' /> - )} - <span className='sr-only'> - {isRevealed - ? t('labels.hide', { scope: scopeLabel }) - : t('labels.reveal', { scope: scopeLabel })} - </span> - </Button> <div className='min-w-0 flex-1'> - <ApiKeyDisplay value={displayValue} /> + <ApiKeyDisplay value={key.displayKey || '—'} /> </div> - <Button - type='button' - variant='ghost' - size='icon' - disabled={!canRevealOrCopy} - className='h-8 w-8 text-muted-foreground' - onClick={() => handleCopyKey(rawKeyValue, key.id)} - > - {isCopied ? ( - <Check className='h-4 w-4' /> - ) : ( - <Copy className='h-4 w-4' /> - )} - <span className='sr-only'>{t('labels.copy', { scope: scopeLabel })}</span> - </Button> </div> </td> - <td className='px-4 py-4 text-muted-foreground text-sm text-center'> + <td className='px-4 py-4 text-center text-muted-foreground text-sm'> {formatDate(key.lastUsed)} </td> <td className='px-4 py-4'> - <div className='flex items-center justify-centergap-1.5'> + <div className='flex items-center justify-center gap-1.5'> {isEditing ? ( <> <Button @@ -824,9 +697,7 @@ const WorkspaceApiKeysCardComponent = ( return ( <Alert variant='destructive'> <AlertCircle className='h-4 w-4' /> - <AlertDescription> - {t('labels.unableToDetermineWorkspace')} - </AlertDescription> + <AlertDescription>{t('labels.unableToDetermineWorkspace')}</AlertDescription> </Alert> ) } @@ -921,7 +792,7 @@ const WorkspaceApiKeysCardComponent = ( if (createError) setCreateError(null) }} /> - {createError && <p className='text-sm text-red-600'>{createError}</p>} + {createError && <p className='text-red-600 text-sm'>{createError}</p>} </div> <AlertDialogFooter className='flex'> @@ -960,21 +831,22 @@ const WorkspaceApiKeysCardComponent = ( <AlertDialogContent className='rounded-md sm:max-w-md'> <AlertDialogHeader> <AlertDialogTitle>{t('dialogs.newKeyTitle', { scope: scopeLabel })}</AlertDialogTitle> - <AlertDialogDescription> - {t('dialogs.newKeyDescription')} - </AlertDialogDescription> + <AlertDialogDescription>{t('dialogs.newKeyDescription')}</AlertDialogDescription> </AlertDialogHeader> {newKey && ( <div className='relative'> <div className='flex h-10 items-center rounded-md bg-muted px-3 pr-10'> - <code className='flex-1 truncate font-mono text-sm'>{newKey.key}</code> + <code className='flex-1 truncate font-mono text-sm'>{newKey.key || '—'}</code> </div> <Button variant='ghost' size='icon' + disabled={!newKey.key} className='-translate-y-1/2 absolute top-1/2 right-1 h-7 w-7 rounded-sm text-muted-foreground hover:bg-card hover:text-foreground' - onClick={() => copyToClipboard(newKey.key)} + onClick={() => { + if (newKey.key) copyToClipboard(newKey.key) + }} > {copySuccess ? <Check className='h-3.5 w-3.5' /> : <Copy className='h-3.5 w-3.5' />} </Button> @@ -987,16 +859,12 @@ const WorkspaceApiKeysCardComponent = ( <AlertDialogContent className='rounded-md sm:max-w-md'> <AlertDialogHeader> <AlertDialogTitle>{t('dialogs.deleteTitle', { scope: scopeLabel })}</AlertDialogTitle> - <AlertDialogDescription> - {t('dialogs.deleteDescription')} - </AlertDialogDescription> + <AlertDialogDescription>{t('dialogs.deleteDescription')}</AlertDialogDescription> </AlertDialogHeader> {deleteKey && ( <div className='py-2'> - <p className='mb-2 text-sm'> - {t('dialogs.deletePrompt', { name: deleteKey.name })} - </p> + <p className='mb-2 text-sm'>{t('dialogs.deletePrompt', { name: deleteKey.name })}</p> <Input autoFocus value={deleteConfirmationName} 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({ <div className='flex flex-col gap-2'> <div className='flex items-center gap-2 text-muted-foreground text-xs'> - <span> - {docCount} {docCount === 1 ? t('docsSingular') : t('docsPlural')} - </span> - <span>•</span> <div className='flex items-center gap-2'> <span className='truncate font-mono'>{id?.slice(0, 8)}</span> <button @@ -205,23 +145,6 @@ export function BaseOverview({ </div> </div> - {/* Timestamps */} - {(createdAt || updatedAt) && ( - <div className='flex items-center gap-2 text-muted-foreground text-xs'> - {updatedAt && ( - <span title={`${t('updated')}: ${formatAbsoluteDate(updatedAt, locale)}`}> - {t('updated')} {formatRelativeTime(updatedAt, locale)} - </span> - )} - {updatedAt && createdAt && <span>•</span>} - {createdAt && ( - <span title={`${t('created')}: ${formatAbsoluteDate(createdAt, locale)}`}> - {t('created')} {formatRelativeTime(createdAt, locale)} - </span> - )} - </div> - )} - <p className='line-clamp-2 overflow-hidden text-muted-foreground text-xs'> {description} </p> @@ -240,13 +163,7 @@ export function BaseOverview({ <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle> - <AlertDialogDescription> - {t('deleteDescription', { - title, - count: docCount, - plural: docCount === 1 ? '' : 's', - })} - </AlertDialogDescription> + <AlertDialogDescription>{t('deleteDescription', { title })}</AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel disabled={isDeleting}>{t('cancel')}</AlertDialogCancel> 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')} - </p> - <p className='text-muted-foreground text-xs'> - {t('supportedFormats')} + {isDragging ? t('dropFilesHere') : t('dropFilesHereOrClickToBrowse')} </p> + <p className='text-muted-foreground text-xs'>{t('supportedFormats')}</p> </div> </div> </div> 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<SortOption>('updatedAt') - const [sortOrder, setSortOrder] = useState<SortOrder>('desc') + const [sortOrder, setSortOrder] = useState<SortOrder>('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() { > <div className={`${commandListClass} py-1`}> {sortOptions.map((option, index) => ( - <div key={option.value}> + <div key={option.value}> <DropdownMenuItem onSelect={() => 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 && ( <div className='mb-4 rounded-md border border-red-200 bg-red-50 p-4'> - <p className='text-red-800 text-sm'> - {t('errors.load', { error })} - </p> + <p className='text-red-800 text-sm'>{t('errors.load', { error })}</p> <button onClick={handleRetry} className='mt-2 text-red-600 text-sm underline hover:text-red-800' @@ -211,7 +199,7 @@ export function Knowledge() { onClick={ userPermissions.canEdit === true ? () => setIsCreateModalOpen(true) - : () => { } + : () => {} } icon={<LibraryBig className='h-4 w-4 text-muted-foreground' />} /> @@ -222,18 +210,13 @@ export function Knowledge() { ) ) : ( filteredAndSortedKnowledgeBases.map((kb) => { - const displayData = formatKnowledgeBaseForDisplay( - kb as KnowledgeBaseWithDocCount - ) + const displayData = formatKnowledgeBaseForDisplay(kb) return ( <BaseOverview key={kb.id} id={displayData.id} title={displayData.title} - docCount={displayData.docCount} description={displayData.description} - createdAt={displayData.createdAt} - updatedAt={displayData.updatedAt} canEdit={canManageKnowledgeBases} /> ) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/utils/sort.ts b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/utils/sort.ts index 660699fbc..f96b020da 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/utils/sort.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/utils/sort.ts @@ -1,38 +1,15 @@ import type { KnowledgeBaseData } from '@/stores/knowledge/store' -import type { SortOption, SortOrder } from '../components/shared' - -interface KnowledgeBaseWithDocCount extends KnowledgeBaseData { - docCount?: number -} +import type { SortOrder } from '../components/shared' /** * Sort knowledge bases by the specified field and order */ export function sortKnowledgeBases( knowledgeBases: KnowledgeBaseData[], - sortBy: SortOption, sortOrder: SortOrder ): KnowledgeBaseData[] { return [...knowledgeBases].sort((a, b) => { - let comparison = 0 - - switch (sortBy) { - case 'name': - comparison = a.name.localeCompare(b.name) - break - case 'createdAt': - comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - break - case 'updatedAt': - comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() - break - case 'docCount': - comparison = - ((a as KnowledgeBaseWithDocCount).docCount || 0) - - ((b as KnowledgeBaseWithDocCount).docCount || 0) - break - } - + const comparison = a.name.localeCompare(b.name) return sortOrder === 'asc' ? comparison : -comparison }) } 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..813b584a2 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 @@ -154,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/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/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/app/workspace/[workspaceId]/monitor/monitor.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/monitor/monitor.tsx index fbea62d2d..c3bcccc60 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, @@ -64,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 @@ -579,6 +576,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, @@ -682,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) @@ -802,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[]) => { @@ -1051,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[]) => { @@ -1173,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 } @@ -1185,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 } @@ -1218,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/components/ui/tool-call.tsx b/apps/tradinggoose/components/ui/tool-call.tsx index d90fe2169..19c048e42 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 ( @@ -48,13 +56,13 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps > <div className='flex min-w-0 items-center gap-2 overflow-hidden'> <Settings className='h-4 w-4 shrink-0 animate-pulse text-yellow-600 dark:text-yellow-400' /> - <span className='min-w-0 truncate font-mono text-yellow-800 text-xs dark:text-yellow-200'> + <span className='min-w-0 truncate font-mono text-xs text-yellow-800 dark:text-yellow-200'> {toolCall.displayName || toolCall.name} </span> {toolCall.progress && ( <Badge variant='outline' - className='shrink-0 text-yellow-700 text-xs dark:text-yellow-300' + className='shrink-0 text-xs text-yellow-700 dark:text-yellow-300' > {toolCall.progress} </Badge> @@ -69,15 +77,14 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps </CollapsibleTrigger> <CollapsibleContent className='min-w-0 max-w-full px-3 pb-3'> <div className='min-w-0 max-w-full space-y-2'> - <div className='flex items-center gap-2 text-yellow-700 text-xs dark:text-yellow-300'> + <div className='flex items-center gap-2 text-xs text-yellow-700 dark:text-yellow-300'> <Loader2 className='h-3 w-3 shrink-0 animate-spin' /> <span>Executing...</span> </div> {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') && ( <div className='min-w-0 max-w-full rounded border border-yellow-200 bg-yellow-50 p-2 dark:border-yellow-800 dark:bg-yellow-950'> {toolCall.name === 'make_api_request' ? ( <div className='w-full overflow-hidden rounded border border-muted bg-card'> @@ -99,9 +106,9 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps <div className='min-w-0'> <span className='block overflow-x-auto whitespace-nowrap font-mono text-foreground text-xs' - title={String((toolCall.parameters as any).url || '')} + title={redactUrlQuery((toolCall.parameters as any).url)} > - {String((toolCall.parameters as any).url || '') || 'URL not provided'} + {redactUrlQuery((toolCall.parameters as any).url) || 'URL not provided'} </span> </div> </div> @@ -112,10 +119,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 ( <div className='w-full overflow-hidden rounded border border-yellow-200 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-950'> <div className='grid grid-cols-2 gap-0 border-yellow-200/60 border-b px-2 py-1.5 dark:border-yellow-800/60'> @@ -126,82 +134,25 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps Value </div> </div> - {entries.length === 0 ? ( + {names.length === 0 ? ( <div className='px-2 py-2 text-muted-foreground text-xs'> No variables provided </div> ) : ( <div className='divide-y divide-yellow-200 dark:divide-yellow-800'> - {entries.map(([k, v]) => ( + {names.map((name) => ( <div - key={k} + key={name} className='grid grid-cols-[auto_1fr] items-center gap-2 px-2 py-1.5' > - <div className='truncate font-medium text-yellow-800 text-xs dark:text-yellow-200'> - {k} - </div> - <div className='min-w-0'> - <span className='block overflow-x-auto whitespace-nowrap font-mono text-yellow-700 text-xs dark:text-yellow-300'> - {String(v)} - </span> + <div className='truncate font-medium text-xs text-yellow-800 dark:text-yellow-200'> + {name} </div> - </div> - ))} - </div> - )} - </div> - ) - })() - : null} - - {toolCall.name === 'set_workflow_variables' - ? (() => { - const ops = Array.isArray((toolCall.parameters as any).operations) - ? ((toolCall.parameters as any).operations as any[]) - : [] - return ( - <div className='w-full overflow-hidden rounded border border-yellow-200 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-950'> - <div className='grid grid-cols-3 gap-0 border-yellow-200/60 border-b px-2 py-1.5 dark:border-yellow-800/60'> - <div className='font-medium text-[10px] text-yellow-700 uppercase tracking-wide dark:text-yellow-300'> - Name - </div> - <div className='font-medium text-[10px] text-yellow-700 uppercase tracking-wide dark:text-yellow-300'> - Type - </div> - <div className='font-medium text-[10px] text-yellow-700 uppercase tracking-wide dark:text-yellow-300'> - Value - </div> - </div> - {ops.length === 0 ? ( - <div className='px-2 py-2 text-muted-foreground text-xs'> - No operations provided - </div> - ) : ( - <div className='divide-y divide-yellow-200 dark:divide-yellow-800'> - {ops.map((op, idx) => ( - <div - key={idx} - className='grid grid-cols-3 items-center gap-0 px-2 py-1.5' - > <div className='min-w-0'> - <span className='truncate text-yellow-800 text-xs dark:text-yellow-200'> - {String(op.name || '')} - </span> - </div> - <div> - <span className='rounded border px-1 py-0.5 text-[10px] text-muted-foreground'> - {String(op.type || '')} + <span className='block overflow-x-auto whitespace-nowrap font-mono text-xs text-yellow-700 dark:text-yellow-300'> + {REDACTED_VALUE} </span> </div> - <div className='min-w-0'> - {op.value !== undefined ? ( - <span className='block overflow-x-auto whitespace-nowrap font-mono text-yellow-700 text-xs dark:text-yellow-300'> - {String(op.value)} - </span> - ) : ( - <span className='text-muted-foreground text-xs'>—</span> - )} - </div> </div> ))} </div> @@ -307,38 +258,6 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp </CollapsibleTrigger> <CollapsibleContent className='min-w-0 max-w-full px-3 pb-3'> <div className='min-w-0 max-w-full space-y-2'> - {toolCall.parameters && - Object.keys(toolCall.parameters).length > 0 && - (toolCall.name === 'make_api_request' || - toolCall.name === 'set_environment_variables') && ( - <div - className={cn( - 'min-w-0 max-w-full rounded p-2', - isSuccess && 'bg-green-100 dark:bg-green-900', - isError && 'bg-red-100 dark:bg-red-900' - )} - > - <div - className={cn( - 'mb-1 font-medium text-xs', - isSuccess && 'text-green-800 dark:text-green-200', - isError && 'text-red-800 dark:text-red-200' - )} - > - Parameters: - </div> - <div - className={cn( - 'min-w-0 max-w-full break-all font-mono text-xs', - isSuccess && 'text-green-700 dark:text-green-300', - isError && 'text-red-700 dark:text-red-300' - )} - > - {JSON.stringify(toolCall.parameters, null, 2)} - </div> - </div> - )} - {toolCall.error && ( <div className='min-w-0 max-w-full rounded bg-red-100 p-2 dark:bg-red-900'> <div className='mb-1 font-medium text-red-800 text-xs dark:text-red-200'> diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/help/help-modal.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/help/help-modal.tsx index 28cddac7b..ed580cdca 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/help/help-modal.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/help/help-modal.tsx @@ -129,12 +129,14 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { useEffect(() => { if (images.length > 0 && scrollContainerRef.current) { const scrollContainer = scrollContainerRef.current - setTimeout(() => { + const timer = setTimeout(() => { scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth', }) }, SCROLL_DELAY_MS) + + return () => clearTimeout(timer) } }, [images.length]) diff --git a/apps/tradinggoose/hooks/queries/api-keys.ts b/apps/tradinggoose/hooks/queries/api-keys.ts index 2daf9fb1d..165676985 100644 --- a/apps/tradinggoose/hooks/queries/api-keys.ts +++ b/apps/tradinggoose/hooks/queries/api-keys.ts @@ -17,7 +17,7 @@ export const apiKeysKeys = { export interface ApiKey { id: string name: string - key: string + key?: string displayKey?: string lastUsed?: string createdAt: string diff --git a/apps/tradinggoose/hooks/queries/custom-tools.ts b/apps/tradinggoose/hooks/queries/custom-tools.ts index dbc729eab..e053cf5be 100644 --- a/apps/tradinggoose/hooks/queries/custom-tools.ts +++ b/apps/tradinggoose/hooks/queries/custom-tools.ts @@ -49,12 +49,7 @@ function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomTo code: typeof tool.code === 'string' ? tool.code : '', workspaceId: tool.workspaceId ?? workspaceId, userId: tool.userId ?? null, - createdAt: - typeof tool.createdAt === 'string' - ? tool.createdAt - : tool.updatedAt && typeof tool.updatedAt === 'string' - ? tool.updatedAt - : new Date().toISOString(), + createdAt: typeof tool.createdAt === 'string' ? tool.createdAt : undefined, updatedAt: typeof tool.updatedAt === 'string' ? tool.updatedAt : undefined, schema: { type: tool.schema.type ?? 'function', @@ -320,36 +315,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<CustomToolDefinition[]>( - customToolsKeys.list(workspaceId) - ) - - if (previousTools) { - queryClient.setQueryData<CustomToolDefinition[]>( - 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 +351,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<CustomToolDefinition[]>( - customToolsKeys.list(workspaceId) - ) - - if (previousTools) { - queryClient.setQueryData<CustomToolDefinition[]>( - 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..d2cc4400e 100644 --- a/apps/tradinggoose/hooks/queries/indicators.ts +++ b/apps/tradinggoose/hooks/queries/indicators.ts @@ -36,12 +36,7 @@ function normalizeIndicator(indicator: ApiIndicator, workspaceId: string): Indic indicator.inputMeta && typeof indicator.inputMeta === 'object' ? (indicator.inputMeta as InputMetaMap) : undefined, - createdAt: - typeof indicator.createdAt === 'string' - ? indicator.createdAt - : indicator.updatedAt && typeof indicator.updatedAt === 'string' - ? indicator.updatedAt - : new Date().toISOString(), + createdAt: typeof indicator.createdAt === 'string' ? indicator.createdAt : undefined, updatedAt: typeof indicator.updatedAt === 'string' ? indicator.updatedAt : undefined, } } @@ -142,7 +137,7 @@ export function useIndicators(workspaceId: string) { interface CreateIndicatorParams { workspaceId: string - indicator: Pick<IndicatorDefinition, 'name' | 'pineCode' | 'inputMeta'> + indicator: Pick<IndicatorDefinition, 'name' | 'pineCode'> } export function useCreateIndicator() { @@ -183,9 +178,7 @@ export function useCreateIndicator() { interface UpdateIndicatorParams { workspaceId: string indicatorId: string - updates: Partial< - Omit<IndicatorDefinition, 'id' | 'workspaceId' | 'userId' | 'color' | 'createdAt' | 'updatedAt'> - > + updates: Partial<Pick<IndicatorDefinition, 'name' | 'pineCode'>> } interface ImportIndicatorsParams { @@ -209,10 +202,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 +211,6 @@ export function useUpdateIndicator() { id: indicatorId, name: updates.name ?? currentIndicator.name, pineCode: updates.pineCode ?? currentIndicator.pineCode, - inputMeta: resolvedInputMeta, }, ], workspaceId, @@ -242,37 +230,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<IndicatorDefinition[]>( - indicatorKeys.list(workspaceId) - ) - - if (previousIndicators) { - queryClient.setQueryData<IndicatorDefinition[]>( - 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..797e23bfc 100644 --- a/apps/tradinggoose/hooks/queries/skills.ts +++ b/apps/tradinggoose/hooks/queries/skills.ts @@ -41,12 +41,7 @@ function normalizeSkill( name: rawSkill.name, description: rawSkill.description, content: rawSkill.content, - createdAt: - typeof rawSkill.createdAt === 'string' - ? rawSkill.createdAt - : rawSkill.updatedAt && typeof rawSkill.updatedAt === 'string' - ? rawSkill.updatedAt - : new Date().toISOString(), + createdAt: typeof rawSkill.createdAt === 'string' ? rawSkill.createdAt : undefined, updatedAt: typeof rawSkill.updatedAt === 'string' ? rawSkill.updatedAt : undefined, } } @@ -274,36 +269,6 @@ export function useUpdateSkill() { return data.data }, - onMutate: async ({ workspaceId, skillId, updates }) => { - await queryClient.cancelQueries({ queryKey: skillsKeys.list(workspaceId) }) - - const previousSkills = queryClient.getQueryData<SkillDefinition[]>( - skillsKeys.list(workspaceId) - ) - - if (previousSkills) { - queryClient.setQueryData<SkillDefinition[]>( - 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 +300,7 @@ export function useDeleteSkill() { return data }, - onMutate: async ({ workspaceId, skillId }) => { - await queryClient.cancelQueries({ queryKey: skillsKeys.list(workspaceId) }) - - const previousSkills = queryClient.getQueryData<SkillDefinition[]>( - skillsKeys.list(workspaceId) - ) - - if (previousSkills) { - queryClient.setQueryData<SkillDefinition[]>( - 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/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/hooks/use-mcp-tools.ts b/apps/tradinggoose/hooks/use-mcp-tools.ts index d06db9ec0..95b10d27d 100644 --- a/apps/tradinggoose/hooks/use-mcp-tools.ts +++ b/apps/tradinggoose/hooks/use-mcp-tools.ts @@ -6,14 +6,15 @@ */ 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' 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') +const DISCOVERY_CACHE_MS = 5 * 60 * 1000 export interface McpToolForUI { id: string @@ -31,10 +32,62 @@ export interface UseMcpToolsResult { mcpTools: McpToolForUI[] isLoading: boolean error: string | null - refreshTools: (forceRefresh?: boolean) => Promise<void> + refreshTools: () => Promise<void> getToolsByServer: (serverId: string) => McpToolForUI[] } +const discoveryCache = new Map<string, { expiresAt: number; tools: McpToolForUI[] }>() +const discoveryRequests = new Map<string, Promise<McpToolForUI[]>>() + +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<McpToolForUI[]>([]) const [isLoading, setIsLoading] = useState(false) @@ -43,21 +96,23 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { const servers = useMcpServersStore((state) => state.servers) - // Track the last fingerprint - const lastProcessedFingerprintRef = useRef<string>('') - // Create a stable server fingerprint const serversFingerprint = useMemo(() => { return servers - .filter((s) => s.enabled && !s.deletedAt) - .map((s) => `${s.id}-${s.enabled}-${s.updatedAt}`) + .filter((s) => !s.deletedAt) + .map((s) => `${s.id}:${s.enabled !== false ? '1' : '0'}:${s.updatedAt ?? ''}`) .sort() .join('|') }, [servers]) - const refreshTools = useCallback( - async (forceRefresh = false) => { - 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) @@ -68,42 +123,8 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { setError(null) try { - logger.info('Discovering MCP tools', { forceRefresh, workspaceId: normalizedWorkspaceId }) - - const response = await fetch( - `/api/mcp/tools/discover?workspaceId=${encodeURIComponent( - normalizedWorkspaceId - )}&refresh=${forceRefresh}` - ) - - 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` - ) + logger.info('Discovering MCP tools', { workspaceId: normalizedWorkspaceId }) + 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,41 +154,36 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { return } - refreshTools() - }, [normalizedWorkspaceId, refreshTools]) + void loadTools() + }, [loadTools, normalizedWorkspaceId]) - // Refresh tools when servers change useEffect(() => { - if ( - !normalizedWorkspaceId || - !serversFingerprint || - serversFingerprint === lastProcessedFingerprintRef.current - ) { - return - } + if (!normalizedWorkspaceId) return - logger.info('Active servers changed, refreshing MCP tools', { - serverCount: servers.filter((s) => s.enabled && !s.deletedAt).length, - fingerprint: serversFingerprint, - }) + const handleToolsChanged = (event: Event) => { + const workspaceId = (event as CustomEvent<{ workspaceId?: string }>).detail?.workspaceId + if (!workspaceId || workspaceId === normalizedWorkspaceId) { + void refreshTools() + } + } - lastProcessedFingerprintRef.current = serversFingerprint - refreshTools() - }, [normalizedWorkspaceId, serversFingerprint, refreshTools, servers]) + 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( () => { 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/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<string, Loop> parallels: Record<string, Parallel> 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/i18n/messages/en.json b/apps/tradinggoose/i18n/messages/en.json index 2feb70333..41ef3b9e8 100644 --- a/apps/tradinggoose/i18n/messages/en.json +++ b/apps/tradinggoose/i18n/messages/en.json @@ -356,6 +356,32 @@ "ssoDisabled": "SSO authentication is disabled. Please use another sign-in method.", "failed": "SSO sign-in failed. Please try again." } + }, + "mcp": { + "eyebrow": "MCP authorization", + "confirm": { + "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." + }, + "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": "Personal API key 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." + } } }, "localeNames": { @@ -1015,13 +1041,8 @@ "title": "Knowledge", "searchPlaceholder": "Search knowledge bases...", "sort": { - "lastUpdated": "Last Updated", - "newestFirst": "Newest First", - "oldestFirst": "Oldest First", "nameAsc": "Name (A-Z)", - "nameDesc": "Name (Z-A)", - "mostDocuments": "Most Documents", - "leastDocuments": "Least Documents" + "nameDesc": "Name (Z-A)" }, "actions": { "create": "Create", @@ -1057,14 +1078,10 @@ "failedToCopy": "Failed to copy knowledge base" }, "baseOverview": { - "docsSingular": "doc", - "docsPlural": "docs", - "updated": "Updated", - "created": "Created", "copyId": "Copy knowledge base ID", "deleteButtonLabel": "Delete knowledge base", "deleteTitle": "Delete Knowledge Base", - "deleteDescription": "Are you sure you want to delete \"{title}\"? This will remove the knowledge base and its {count} document{plural} permanently.", + "deleteDescription": "Are you sure you want to delete \"{title}\"? This will permanently remove the knowledge base.", "cancel": "Cancel", "deleteConfirm": "Delete", "deleting": "Deleting..." diff --git a/apps/tradinggoose/i18n/messages/es.json b/apps/tradinggoose/i18n/messages/es.json index 77a91d41d..dfc5b4696 100644 --- a/apps/tradinggoose/i18n/messages/es.json +++ b/apps/tradinggoose/i18n/messages/es.json @@ -356,6 +356,32 @@ "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", + "confirm": { + "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." + }, + "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": "Clave API personal aprobada", + "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." + } } }, "localeNames": { @@ -1015,13 +1041,8 @@ "title": "Conocimiento", "searchPlaceholder": "Buscar bases de conocimiento...", "sort": { - "lastUpdated": "Última actualización", - "newestFirst": "Más recientes primero", - "oldestFirst": "Más antiguos primero", "nameAsc": "Nombre (A-Z)", - "nameDesc": "Nombre (Z-A)", - "mostDocuments": "Más documentos", - "leastDocuments": "Menos documentos" + "nameDesc": "Nombre (Z-A)" }, "actions": { "create": "Crear", @@ -1057,14 +1078,10 @@ "failedToCopy": "Error al copiar la base de conocimiento" }, "baseOverview": { - "docsSingular": "doc", - "docsPlural": "docs", - "updated": "Actualizado", - "created": "Creado", "copyId": "Copiar ID de la base de conocimiento", "deleteButtonLabel": "Eliminar base de conocimiento", "deleteTitle": "Eliminar base de conocimiento", - "deleteDescription": "¿Estás seguro de que quieres eliminar \"{title}\"? Esto eliminará la base de conocimiento y sus {count} documento{plural} de forma permanente.", + "deleteDescription": "¿Estás seguro de que quieres eliminar \"{title}\"? Esto eliminará la base de conocimiento de forma permanente.", "cancel": "Cancelar", "deleteConfirm": "Eliminar", "deleting": "Eliminando..." diff --git a/apps/tradinggoose/i18n/messages/zh.json b/apps/tradinggoose/i18n/messages/zh.json index 36ea2520b..f434ac41e 100644 --- a/apps/tradinggoose/i18n/messages/zh.json +++ b/apps/tradinggoose/i18n/messages/zh.json @@ -356,6 +356,32 @@ "ssoDisabled": "SSO身份验证已禁用。请使用其他登录方式。", "failed": "SSO登录失败。请重试。" } + }, + "mcp": { + "eyebrow": "MCP 授权", + "confirm": { + "title": "批准个人 API 密钥", + "description": "本地 TradingGoose MCP 设置命令正在请求为此账户创建个人 API 密钥。", + "approve": "批准密钥", + "cancel": "取消", + "terminalHint": "仅在你自己终端中启动了设置或登录命令时才批准。" + }, + "invalid": { + "title": "MCP 登录无效", + "description": "本地设置命令未提供有效的登录代码。" + }, + "expired": { + "title": "MCP 登录已过期", + "description": "返回终端并重新运行 TradingGoose MCP 登录命令。" + }, + "approved": { + "title": "个人 API 密钥已批准", + "description": "返回终端完成本地代理配置。" + }, + "cancelled": { + "title": "MCP 登录已取消", + "description": "未创建 API 密钥。你可以关闭此页面。" + } } }, "localeNames": { @@ -1002,13 +1028,8 @@ "title": "知识库", "searchPlaceholder": "搜索知识库...", "sort": { - "lastUpdated": "最近更新", - "newestFirst": "最新在前", - "oldestFirst": "最早在前", "nameAsc": "名称(A-Z)", - "nameDesc": "名称(Z-A)", - "mostDocuments": "文档最多", - "leastDocuments": "文档最少" + "nameDesc": "名称(Z-A)" }, "actions": { "create": "创建", @@ -1044,14 +1065,10 @@ "failedToCopy": "复制知识库失败" }, "baseOverview": { - "docsSingular": "个文档", - "docsPlural": "个文档", - "updated": "已更新", - "created": "已创建", "copyId": "复制知识库 ID", "deleteButtonLabel": "删除知识库", "deleteTitle": "删除知识库", - "deleteDescription": "确定要删除“{title}”吗?此操作将永久删除该知识库及其 {count} 个文档{plural}。", + "deleteDescription": "确定要删除“{title}”吗?此操作将永久删除该知识库。", "cancel": "取消", "deleteConfirm": "删除", "deleting": "删除中..." diff --git a/apps/tradinggoose/i18n/public-copy.test.ts b/apps/tradinggoose/i18n/public-copy.test.ts index 01e064805..f22baf2bc 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('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/api-key/auth.ts b/apps/tradinggoose/lib/api-key/auth.ts deleted file mode 100644 index 0036f1ec3..000000000 --- a/apps/tradinggoose/lib/api-key/auth.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { - 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') - -/** - * API key authentication utilities supporting both legacy plain text keys - * and modern encrypted keys for gradual migration without breaking existing keys - */ - -/** - * Checks if a stored key is in the new encrypted format - * @param storedKey - The key stored in the database - * @returns true if the key is encrypted, false if it's plain text - */ -export function isEncryptedKey(storedKey: string): boolean { - // Check if it follows the encrypted format: iv:encrypted:authTag - return storedKey.includes(':') && storedKey.split(':').length === 3 -} - -/** - * Authenticates an API key against a stored key, supporting both legacy and new encrypted formats - * @param inputKey - The API key provided by the client - * @param storedKey - The key stored in the database (may be plain text or encrypted) - * @returns Promise<boolean> - true if the key is valid - */ -export async function authenticateApiKey(inputKey: string, storedKey: string): Promise<boolean> { - try { - // If input key has new encrypted prefix (sk-tradinggoose-), only check against encrypted storage - if (isEncryptedApiKeyFormat(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 false - } - } - // New format keys should never match against plain text storage - return false - } - - // If input key has the plain-text prefix (tradinggoose_), check both encrypted and plain text - if (isLegacyApiKeyFormat(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 }) - // Fall through to plain text comparison if decryption fails - } - } - // Legacy format can match against plain text storage - return inputKey === storedKey - } - - // If no recognized prefix, fall back to original behavior - 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 - } -} - -/** - * Encrypts an API key for secure storage - * @param apiKey - The plain text API key to encrypt - * @returns Promise<string> - The encrypted key - */ -export async function encryptApiKeyForStorage(apiKey: string): Promise<string> { - 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) - * @returns Promise<{key: string, encryptedKey?: string}> - The plain key and optionally encrypted version - */ -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') - } -} - -/** - * Decrypts an API key from storage for display purposes - * @param encryptedKey - The encrypted API key from the database - * @returns Promise<string> - The decrypted API key - */ -export async function decryptApiKeyFromStorage(encryptedKey: string): Promise<string> { - try { - const { decrypted } = await decryptApiKey(encryptedKey) - return decrypted - } catch (error) { - logger.error('API key decryption error:', { error }) - throw new Error('Failed to decrypt API key') - } -} - -/** - * Gets the last 4 characters of an API key for display purposes - * @param apiKey - The API key (plain text) - * @returns string - The last 4 characters - */ -export function getApiKeyLast4(apiKey: string): string { - return apiKey.slice(-4) -} - -/** - * Gets the display format for an API key showing prefix and last 4 characters - * @param encryptedKey - The encrypted API key from the database - * @returns Promise<string> - The display format like "sk-tradinggoose-...r6AA" - */ -export async function getApiKeyDisplayFormat(encryptedKey: string): Promise<string> { - try { - if (isEncryptedKey(encryptedKey)) { - const decryptedKey = await decryptApiKeyFromStorage(encryptedKey) - return formatApiKeyForDisplay(decryptedKey) - } - // For plain text keys (legacy), format directly - return formatApiKeyForDisplay(encryptedKey) - } catch (error) { - logger.error('Failed to format API key for display:', { error }) - return '****' - } -} - -/** - * Formats an API key for display showing prefix and last 4 characters - * @param apiKey - The API key (plain text) - * @returns string - The display format like "sk-tradinggoose-...r6AA" or "tradinggoose_...r6AA" - */ -export function formatApiKeyForDisplay(apiKey: string): string { - if (isEncryptedApiKeyFormat(apiKey)) { - // For sk-tradinggoose- format: "sk-tradinggoose-...r6AA" - const last4 = getApiKeyLast4(apiKey) - return `sk-tradinggoose-...${last4}` - } - if (isLegacyApiKeyFormat(apiKey)) { - // For tradinggoose_ format: "tradinggoose_...r6AA" - const last4 = getApiKeyLast4(apiKey) - return `tradinggoose_...${last4}` - } - // Unknown format, just show last 4 - const last4 = getApiKeyLast4(apiKey) - return `...${last4}` -} - -/** - * Gets the last 4 characters of an encrypted API key by decrypting it first - * @param encryptedKey - The encrypted API key from the database - * @returns Promise<string> - The last 4 characters - */ -export async function getEncryptedApiKeyLast4(encryptedKey: string): Promise<string> { - try { - if (isEncryptedKey(encryptedKey)) { - const decryptedKey = await decryptApiKeyFromStorage(encryptedKey) - return getApiKeyLast4(decryptedKey) - } - // For plain text keys (legacy), return last 4 directly - return getApiKeyLast4(encryptedKey) - } catch (error) { - logger.error('Failed to get last 4 characters of API key:', { error }) - return '****' - } -} - -/** - * Validates API key format (basic validation) - * @param apiKey - The API key to validate - * @returns boolean - true if the format appears valid - */ -export function isValidApiKeyFormat(apiKey: string): boolean { - return typeof apiKey === 'string' && apiKey.length > 10 && apiKey.length < 200 -} 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..f84b5815e --- /dev/null +++ b/apps/tradinggoose/lib/api-key/service.test.ts @@ -0,0 +1,140 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDbSelect, mockEnv } = vi.hoisted(() => ({ + mockDbSelect: vi.fn(), + mockEnv: { API_ENCRYPTION_KEY: 'a'.repeat(64) } as { API_ENCRYPTION_KEY?: string }, +})) + +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 })), + like: vi.fn((field, value) => ({ field, value })), +})) + +vi.mock('@/lib/env', () => ({ env: mockEnv })) +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(() => ({ + limit: vi.fn().mockResolvedValue(rows), + })), + })), + }) +} + +describe('API key service', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + mockEnv.API_ENCRYPTION_KEY = 'a'.repeat(64) + 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() + }) + + 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') + + 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(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 { 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 8e3a9bd58..8f0f7c511 100644 --- a/apps/tradinggoose/lib/api-key/service.ts +++ b/apps/tradinggoose/lib/api-key/service.ts @@ -1,29 +1,56 @@ -import { createCipheriv, createDecipheriv, randomBytes } from 'crypto' +import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from 'crypto' import { db } from '@tradinggoose/db' -import { apiKey as apiKeyTable, workspace } from '@tradinggoose/db/schema' -import { and, eq } from 'drizzle-orm' +import { apiKey as apiKeyTable } from '@tradinggoose/db/schema' +import { and, eq, inArray, like, type SQL } from 'drizzle-orm' import { nanoid } from 'nanoid' -import { authenticateApiKey } from '@/lib/api-key/auth' 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_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'] +// Current API-key contract: only sk-tradinggoose-* rows stored as +// display:lookupDigest:iv:ciphertext:authTag are authenticated or listed. + +export type ApiKeyType = 'personal' | 'workspace' 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 } +export async function createApiKey(useStorage = true): Promise<{ + key: string + storedKey?: string +}> { + try { + const plainKey = generateApiKey() + + if (useStorage) { + return { key: plainKey, storedKey: getStoredApiKey(plainKey) } + } + + return { key: plainKey } + } catch (error) { + logger.error('API key creation error:', { error }) + throw new Error('Failed to create API key') + } +} + /** * Authenticate an API key from header with flexible filtering options */ @@ -31,30 +58,19 @@ export async function authenticateApiKeyFromHeader( apiKeyHeader: string, options: ApiKeyAuthOptions = {} ): Promise<ApiKeyAuthResult> { - if (!apiKeyHeader) { + const apiKey = apiKeyHeader.trim() + 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' } + } try { - // Build query based on options - let query = db - .select({ - id: apiKeyTable.id, - userId: apiKeyTable.userId, - workspaceId: apiKeyTable.workspaceId, - type: apiKeyTable.type, - key: apiKeyTable.key, - expiresAt: apiKeyTable.expiresAt, - }) - .from(apiKeyTable) - - // Add workspace join if needed for workspace keys - if (options.workspaceId || options.keyTypes?.includes('workspace')) { - query = query.leftJoin(workspace, eq(apiKeyTable.workspaceId, workspace.id)) as any - } - - // Apply filters - const conditions = [] + const conditions: SQL[] = [like(apiKeyTable.key, `${getStoredApiKeyLookupPrefix(apiKey)}%`)] if (options.userId) { conditions.push(eq(apiKeyTable.userId, options.userId)) @@ -64,50 +80,40 @@ export async function authenticateApiKeyFromHeader( conditions.push(eq(apiKeyTable.workspaceId, options.workspaceId)) } - if (options.keyTypes?.length) { - if (options.keyTypes.length === 1) { - conditions.push(eq(apiKeyTable.type, options.keyTypes[0])) - } else { - // For multiple types, we'll filter in memory since drizzle's inArray is complex here - } + 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)) } - if (conditions.length > 0) { - query = query.where(and(...conditions)) as any + const query = db + .select({ + id: apiKeyTable.id, + userId: apiKeyTable.userId, + workspaceId: apiKeyTable.workspaceId, + type: apiKeyTable.type, + key: apiKeyTable.key, + expiresAt: apiKeyTable.expiresAt, + }) + .from(apiKeyTable) + + const [storedKey] = await query.where(and(...conditions)).limit(1) + if (!storedKey || (storedKey.expiresAt && storedKey.expiresAt < new Date())) { + return { success: false, error: 'Invalid API key' } } - const keyRecords = await query - - // Filter by keyTypes in memory if multiple types specified - const filteredRecords = - options.keyTypes?.length && options.keyTypes.length > 1 - ? keyRecords.filter((record) => options.keyTypes!.includes(record.type as any)) - : keyRecords - - // Authenticate each key - for (const storedKey of filteredRecords) { - // Skip expired keys - if (storedKey.expiresAt && storedKey.expiresAt < new Date()) { - continue - } - - try { - const isValid = await authenticateApiKey(apiKeyHeader, storedKey.key) - if (isValid) { - return { - success: true, - userId: storedKey.userId, - keyId: storedKey.id, - keyType: storedKey.type as 'personal' | 'workspace', - workspaceId: storedKey.workspaceId || undefined, - } - } - } catch (error) { - logger.error('Error authenticating API key:', error) - } + if (!(await storedApiKeyMatches(apiKey, storedKey.key))) { + return { success: false, error: 'Invalid API 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' } @@ -146,128 +152,124 @@ export async function getApiKeyOwnerUserId( } } -/** - * Get the API encryption key from the environment - * @returns The API encryption key - */ -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)}` } -/** - * Encrypts an API key using the dedicated API encryption key - * @param apiKey - The API key to encrypt - * @returns A promise that resolves to an object containing the encrypted API key and IV - */ -export async function encryptApiKey(apiKey: string): Promise<{ encrypted: string; iv: string }> { - const key = getApiEncryptionKey() - - // If no API encryption key is set, return the key as-is for backward compatibility - 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') - - const authTag = cipher.getAuthTag() - - // Format: iv:encrypted:authTag - return { - encrypted: `${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`, - iv: iv.toString('hex'), - } +export function isCurrentApiKeyFormat(apiKey: string): boolean { + return apiKey.startsWith(API_KEY_PREFIX) } -/** - * Decrypts an API key using the dedicated API encryption key - * @param encryptedValue - The encrypted value in format "iv:encrypted:authTag" or plain text - * @returns A promise that resolves to an object containing the decrypted API key - */ -export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted: string }> { - // Check if this is actually encrypted (contains colons) - if (!encryptedValue.includes(':') || encryptedValue.split(':').length !== 3) { - // This is a plain text key, return as-is - return { decrypted: encryptedValue } +export function formatApiKeyForDisplay(apiKey: string): string { + const last4 = apiKey.slice(-4) + if (isCurrentApiKeyFormat(apiKey)) { + return `${API_KEY_PREFIX}...${last4}` } + return `...${last4}` +} - const key = getApiEncryptionKey() +function getApiKeyLookupDigest(apiKey: string): string { + return createHmac('sha256', getApiEncryptionKey()).update(apiKey).digest('hex') +} - // If no API encryption key is set, assume it's plain text +function getApiEncryptionKey(): Buffer { + const key = env.API_ENCRYPTION_KEY if (!key) { - return { decrypted: encryptedValue } + 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)') + } + return Buffer.from(key, 'hex') +} + +export function isApiKeyStorageAvailable(): boolean { + return Boolean(env.API_ENCRYPTION_KEY && API_ENCRYPTION_KEY_PATTERN.test(env.API_ENCRYPTION_KEY)) +} - const parts = encryptedValue.split(':') - const ivHex = parts[0] - const authTagHex = parts[parts.length - 1] - const encrypted = parts.slice(1, -1).join(':') +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) +} +function decryptStoredApiKey(storedApiKey: string): string { + const [, , ivHex, encrypted, authTagHex] = storedApiKey.split(STORED_API_KEY_SEPARATOR) if (!ivHex || !encrypted || !authTagHex) { - throw new Error('Invalid encrypted API key format. Expected "iv:encrypted:authTag"') + throw new Error('Invalid stored API key format') } - const iv = Buffer.from(ivHex, 'hex') - const authTag = Buffer.from(authTagHex, 'hex') + 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 +} - try { - const decipher = createDecipheriv('aes-256-gcm', key, iv) - decipher.setAuthTag(authTag) - - 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 - } +function getStoredApiKeyLookupPrefix(apiKey: string): string { + return [formatApiKeyForDisplay(apiKey), getApiKeyLookupDigest(apiKey), ''].join( + STORED_API_KEY_SEPARATOR + ) } -/** - * 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 getStoredApiKey(apiKey: string): string { + return encryptApiKeyForStorage(apiKey) } -/** - * 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)}` +function isCurrentStoredApiKeyFormat(storedApiKey: string): boolean { + const [displayKey, lookupDigest, iv, encrypted, authTag, extra] = + storedApiKey.split(STORED_API_KEY_SEPARATOR) + return Boolean( + displayKey?.startsWith(API_KEY_PREFIX) && + lookupDigest?.length === 64 && + iv && + encrypted && + authTag && + !extra + ) } -/** - * Determines if an API key uses the new encrypted format based on prefix - * @param apiKey - The API key to check - * @returns true if the key uses the new encrypted format (sk-tradinggoose- prefix) - */ -export function isEncryptedApiKeyFormat(apiKey: string): boolean { - return apiKey.startsWith('sk-tradinggoose-') +function constantTimeEqual(left: string, right: string): boolean { + return left.length === right.length && timingSafeEqual(Buffer.from(left), Buffer.from(right)) } -/** - * Determines if an API key uses the plain-text format based on prefix - * @param apiKey - The API key to check - * @returns true if the key uses the plain-text format (tradinggoose_ prefix) - */ -export function isLegacyApiKeyFormat(apiKey: string): boolean { - return apiKey.startsWith('tradinggoose_') && !apiKey.startsWith('sk-tradinggoose-') +export async function storedApiKeyMatches(apiKey: string, storedApiKey: string): Promise<boolean> { + if ( + !isApiKeyStorageAvailable() || + !isApiKeyFormat(apiKey) || + !isCurrentStoredApiKeyFormat(storedApiKey) + ) { + return false + } + 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 + } +} + +export function getApiKeyDisplayFormat(storedApiKey: string): string | null { + return isCurrentStoredApiKeyFormat(storedApiKey) + ? storedApiKey.split(STORED_API_KEY_SEPARATOR)[0] + : null } diff --git a/apps/tradinggoose/lib/api/rate-limit.ts b/apps/tradinggoose/lib/api/rate-limit.ts new file mode 100644 index 000000000..3e84549c8 --- /dev/null +++ b/apps/tradinggoose/lib/api/rate-limit.ts @@ -0,0 +1,169 @@ +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' + +const logger = createLogger('ApiRateLimit') +const rateLimiter = new ExecutionLimiter() + +export interface RateLimitResult { + allowed: boolean + remaining: number + resetAt: Date + limit: number + userId?: string + error?: string + failureKind?: 'auth' | 'dependency' +} + +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<Record<ApiRateLimitEndpoint, number>> = { + 'copilot-mcp-public': 300, + 'mcp-auth-start': 20, + 'mcp-auth-poll': 120, +} + +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<RateLimitResult> { + const limit = await isBillingEnabledForRuntime() + .then((enabled) => (enabled ? 0 : Number.MAX_SAFE_INTEGER)) + .catch(() => 0) + return { + allowed: false, + remaining: 0, + limit, + resetAt: new Date(), + error, + failureKind: 'auth', + } +} + +export async function checkApiEndpointRateLimit( + userId: string, + endpoint: ApiRateLimitEndpoint = 'api-endpoint' +): Promise<RateLimitResult> { + try { + const billingEnabled = await isBillingEnabledForRuntime() + if (!billingEnabled) { + return { + allowed: true, + remaining: Number.MAX_SAFE_INTEGER, + limit: Number.MAX_SAFE_INTEGER, + resetAt: new Date(Date.now() + 60000), + userId, + } + } + + const subscription = await getPersonalEffectiveSubscription(userId) + const billingScope = getApiEndpointRateLimitScope(userId, endpoint) + + const result = await rateLimiter.checkRateLimitWithSubscription( + userId, + subscription, + 'api-endpoint', + false, + billingScope + ) + + if (!result.allowed) { + logger.warn(`Rate limit exceeded for user ${userId}`, { + endpoint, + remaining: result.remaining, + resetAt: result.resetAt, + }) + } + + const rateLimitStatus = await rateLimiter.getRateLimitStatusWithSubscription( + userId, + subscription, + 'api-endpoint', + false, + billingScope + ) + + return { + ...result, + limit: rateLimitStatus.limit, + userId, + } + } catch (error) { + logger.error('Rate limit check error; failing closed', { error, endpoint, userId }) + return { + allowed: false, + remaining: 0, + limit: 0, + resetAt: new Date(Date.now() + 60000), + error: 'Rate limit service unavailable', + failureKind: 'dependency', + userId, + } + } +} + +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 host = request.headers.get('host')?.trim() || new URL(request.url).host + return createHash('sha256').update(`${host}\n${requester}`).digest('hex') +} + +export async function checkPublicApiEndpointRateLimit( + request: Request, + endpoint: Extract<ApiRateLimitEndpoint, 'copilot-mcp-public' | 'mcp-auth-start' | 'mcp-auth-poll'> +): Promise<RateLimitResult> { + 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, failClosedOnError: true } + ) + + return { + ...result, + limit, + userId: scopeId, + } +} 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/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/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..1f02ddd37 100644 --- a/apps/tradinggoose/lib/copilot/entity-documents.ts +++ b/apps/tradinggoose/lib/copilot/entity-documents.ts @@ -1,14 +1,20 @@ 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 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 @@ -35,8 +41,8 @@ const CustomToolDocumentSchema = z.object({ const IndicatorDocumentSchema = z.object({ name: z.string(), + color: z.string(), pineCode: z.string(), - inputMeta: z.record(z.unknown()).nullable(), }) const McpServerDocumentSchema = z.object({ @@ -44,27 +50,89 @@ 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(), }) +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<K extends EntityDocumentKind> = z.infer< (typeof EntityDocumentSchemas)[K] > -function normalizeEntityFields( +export const ENTITY_SECRET_PLACEHOLDER = '[redacted]' +const HTTP_HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/ + +function redactStringRecordValues(value: unknown): Record<string, string> { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + + return Object.fromEntries( + Object.keys(value as Record<string, unknown>).map((key) => [key, ENTITY_SECRET_PLACEHOLDER]) + ) +} + +export function normalizeStringRecord(value: unknown): Record<string, string> { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {} + } + + return Object.fromEntries( + Object.entries(value as Record<string, unknown>).map(([key, item]) => [ + key, + typeof item === 'string' ? item : String(item ?? ''), + ]) + ) +} + +function normalizeHttpHeaderRecord(value: unknown): Record<string, string> { + 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<string, unknown> | null | undefined ): Record<string, unknown> { @@ -73,63 +141,68 @@ 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<string, unknown>) - : null, + name: typeof source.name === 'string' ? source.name.trim() : '', + color: typeof source.color === 'string' ? source.color.trim() : '', + pineCode, + inputMeta: inferInputMetaFromPineCode(pineCode) ?? null, } - case 'mcp_server': + } + 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 enabled = typeof source.enabled === 'boolean' ? source.enabled : true + const rawUrl = typeof source.url === 'string' ? source.url.trim() : '' + 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}`) + } + return { - name: typeof source.name === 'string' ? source.name : '', - description: typeof source.description === 'string' ? source.description : '', - transport: - source.transport === 'http' || - source.transport === 'sse' || - source.transport === 'streamable-http' - ? source.transport - : 'http', - url: typeof source.url === 'string' ? source.url : '', - headers: - source.headers && typeof source.headers === 'object' && !Array.isArray(source.headers) - ? Object.fromEntries( - Object.entries(source.headers as Record<string, unknown>).map(([key, value]) => [ - key, - typeof value === 'string' ? value : String(value ?? ''), - ]) - ) - : {}, - command: typeof source.command === 'string' ? source.command : '', + name: typeof source.name === 'string' ? source.name.trim() : '', + description: typeof source.description === 'string' ? source.description.trim() : '', + transport: source.transport, + url: validation?.normalizedUrl ?? rawUrl, + 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<string, unknown>).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, + enabled, + } + } + case 'knowledge_base': + return { + name: typeof source.name === 'string' ? source.name.trim() : source.name, + description: + typeof source.description === 'string' ? source.description.trim() : source.description, + chunkingConfig: source.chunkingConfig, } } } @@ -151,13 +224,28 @@ export function parseEntityDocument<K extends EntityDocumentKind>( return EntityDocumentSchemas[kind].parse(normalized) as EntityDocumentFields<K> } +function redactEntityDocumentSecretFields<K extends EntityDocumentKind>( + kind: K, + fields: Record<string, unknown> | null | undefined +): EntityDocumentFields<K> { + 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<K> +} + export function serializeEntityDocument<K extends EntityDocumentKind>( kind: K, fields: Record<string, unknown> | null | undefined ): string { - const normalized = normalizeEntityFields(kind, fields) - const parsed = EntityDocumentSchemas[kind].parse(normalized) - return JSON.stringify(parsed, null, 2) + return JSON.stringify(redactEntityDocumentSecretFields(kind, fields), null, 2) } export function getEntityDocumentName( @@ -175,5 +263,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..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} `)) { @@ -214,7 +222,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 +249,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 +408,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) @@ -425,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 ( <div className='mt-0.5 w-full overflow-hidden rounded border border-muted bg-card'> @@ -458,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 ( <div className='mt-0.5 w-full overflow-hidden rounded border border-muted bg-card'> @@ -482,11 +483,11 @@ export function InlineToolCall({ Value </div> </div> - {normalizedEntries.length === 0 ? ( + {variableNames.length === 0 ? ( <div className='px-2 py-2 text-muted-foreground text-xs'>No variables provided</div> ) : ( <div className='divide-y divide-muted/60'> - {normalizedEntries.map(([name, value]) => ( + {variableNames.map((name) => ( <div key={name} className='grid grid-cols-[auto_1fr] items-center gap-2 px-2 py-1.5' @@ -496,7 +497,7 @@ export function InlineToolCall({ </div> <div className='min-w-0'> <span className='block overflow-x-auto whitespace-nowrap font-mono text-xs text-yellow-700 dark:text-yellow-300'> - {value} + {REDACTED_VALUE} </span> </div> </div> @@ -507,54 +508,6 @@ export function InlineToolCall({ ) } - if (toolCall.name === 'set_workflow_variables') { - const ops = Array.isArray(params.operations) ? (params.operations as any[]) : [] - return ( - <div className='mt-0.5 w-full overflow-hidden rounded border border-muted bg-card'> - <div className='grid grid-cols-3 gap-0 border-muted/60 border-b bg-muted/40 px-2 py-1.5'> - <div className='font-medium text-[10px] text-muted-foreground uppercase tracking-wide'> - Name - </div> - <div className='font-medium text-[10px] text-muted-foreground uppercase tracking-wide'> - Type - </div> - <div className='font-medium text-[10px] text-muted-foreground uppercase tracking-wide'> - Value - </div> - </div> - {ops.length === 0 ? ( - <div className='px-2 py-2 text-muted-foreground text-xs'>No operations provided</div> - ) : ( - <div className='divide-y divide-yellow-200 dark:divide-yellow-800'> - {ops.map((op, idx) => ( - <div key={idx} className='grid grid-cols-3 items-center gap-0 px-2 py-1.5'> - <div className='min-w-0'> - <span className='truncate text-xs text-yellow-800 dark:text-yellow-200'> - {String(op.name || '')} - </span> - </div> - <div> - <span className='rounded border px-1 py-0.5 text-[10px] text-muted-foreground'> - {String(op.type || '')} - </span> - </div> - <div className='min-w-0'> - {op.value !== undefined ? ( - <span className='block overflow-x-auto whitespace-nowrap font-mono text-xs text-yellow-700 dark:text-yellow-300'> - {String(op.value)} - </span> - ) : ( - <span className='text-muted-foreground text-xs'>—</span> - )} - </div> - </div> - ))} - </div> - )} - </div> - ) - } - return null } 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 ccb1b8a4e..4c8aa0d01 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, @@ -12,18 +11,22 @@ 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, } 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 { + readBootstrappedReviewTargetSnapshot, + readBootstrappedSavedEntityFields, +} from '@/lib/yjs/server/bootstrap-review-target' 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 type { ChatContext } from '@/stores/copilot/types' import { readCopilotWorkspaceEntityContext } from '@/widgets/widgets/copilot/workspace-entities' @@ -98,6 +101,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 +110,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( @@ -172,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) { @@ -192,7 +187,7 @@ async function processEntityContext(params: { return null } - const fields = await readCopilotEntityFieldsFromYjs( + const fields = await readBootstrappedSavedEntityFields( params.entityKind, params.entityId, access.workspaceId @@ -222,29 +217,6 @@ async function processEntityContext(params: { } } -async function readCopilotEntityFieldsFromYjs( - entityKind: 'skill' | 'indicator' | 'custom_tool' | 'mcp_server', - entityId: string, - workspaceId: string -): Promise<Record<string, unknown>> { - 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<T>( descriptor: Parameters<typeof readBootstrappedReviewTargetSnapshot>[0], read: (doc: Y.Doc) => T @@ -295,7 +267,6 @@ function serializeEntityContext( name: row.name ?? null, color: row.color ?? null, pineCode: row.pineCode ?? null, - inputMeta: row.inputMeta ?? null, } case 'custom_tool': return { @@ -488,23 +459,31 @@ async function processWorkflowContext({ async function processKnowledgeContext( knowledgeBaseId: string, + userId: string, + workspaceId: string | null, tag: string ): Promise<AgentContext | null> { try { - // Load KB metadata - const kbRows = await db - .select({ - id: knowledgeBase.id, - name: knowledgeBase.name, - updatedAt: knowledgeBase.updatedAt, + const access = await verifyReviewTargetAccess( + userId, + buildSavedEntityDescriptor(ENTITY_KIND_KNOWLEDGE_BASE, knowledgeBaseId, workspaceId), + '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 readBootstrappedSavedEntityFields( + 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 +492,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..05ec807b5 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,31 @@ const OptionalEntityTargetArgs = z.object({ const EntityTargetArgs = z.object({ entityId: RequiredId, }) +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({ + scope: z.literal('personal'), + variables: z.record(z.string()), + }) + .strict(), + WorkspaceTargetArgs.extend({ + scope: z.literal('workspace'), + variables: z.record(z.string()), + }).strict(), +]) function buildEntityDocumentMutationArgs<TDocumentFormat extends string>( documentFormat: TDocumentFormat @@ -139,12 +163,10 @@ function buildEntityDocumentMutationArgs<TDocumentFormat extends string>( function buildEntityDocumentCreateArgs<TDocumentFormat extends string>( 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 +174,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 +243,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 +271,44 @@ 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. 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 = { + 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 +338,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 +403,46 @@ export const ToolArgSchemas = { body: z.union([z.record(z.any()), z.string()]).optional(), }), - [CopilotTool.read_environment_variables]: OptionalEntityTargetArgs, + [CopilotTool.read_environment_variables]: PersonalOrWorkspaceReadArgs, - set_environment_variables: OptionalEntityTargetArgs.extend({ - variables: z.record(z.string()), - }), + set_environment_variables: SetEnvironmentVariablesArgs, - [CopilotTool.read_oauth_credentials]: OptionalEntityTargetArgs, + [CopilotTool.read_oauth_credentials]: PersonalOrWorkspaceReadArgs, - [CopilotTool.read_credentials]: OptionalEntityTargetArgs, + [CopilotTool.read_credentials]: PersonalOrWorkspaceReadArgs, 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 +452,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 +487,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<ToolId, z.ZodTypeAny> // Tool-specific SSE schemas (tool_call with typed arguments) @@ -476,13 +520,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 +583,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,20 +697,26 @@ 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()), }) +// A list is a discovery surface: id, canonical name, and basic usability state. 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(), + enabled: z.boolean().optional(), }) const GenericEntityListResult = z.object({ @@ -668,6 +725,38 @@ const GenericEntityListResult = z.object({ 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 +794,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(), @@ -735,8 +828,9 @@ const McpServerDocumentEnvelope = EntityDocumentEnvelopeBase.extend({ documentFormat: z.literal(MCP_SERVER_DOCUMENT_FORMAT), }) -const EditEntityDocumentResultBase = z.object({ - success: z.boolean(), +const DocumentDiffReviewMetadata = z.object({ + requiresReview: z.literal(true).optional(), + reviewBaseStateHash: z.string().optional(), preview: z .object({ documentDiff: z.object({ @@ -747,9 +841,16 @@ 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(), }) +const WorkflowCreateMutationResult = WorkflowMutationResult.extend({ + entityId: z.string().optional(), +}) const CustomToolDocumentMutationResult = EditEntityDocumentResultBase.merge( CustomToolDocumentEnvelope.extend({ @@ -769,6 +870,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,7 +890,9 @@ 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({ @@ -808,6 +917,32 @@ const WorkflowMutationResultShape = { const EditWorkflowResult = WorkflowGraphDocumentEnvelope.extend(WorkflowMutationResultShape) 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({ + documentDiff: z.object({ + before: z.string(), + after: z.string(), + }), + }) + .optional(), +}) + +const EnvironmentVariablesMutationResult = DocumentDiffReviewMetadata.extend({ + success: z.boolean(), + scope: z.enum(['personal', 'workspace']), + workspaceId: z.string().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(), @@ -843,16 +978,11 @@ 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'), }), - [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(), @@ -887,13 +1017,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(z.object({ message: z.any().optional(), data: z.any().optional() })), + [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() }) @@ -986,7 +1117,14 @@ export const ToolResultSchemas = { chatDeployed: z.boolean(), deployedAt: z.string().nullable(), }), - knowledge_base: KnowledgeBaseResultSchema, + list_knowledge_bases: GenericEntityListResult.extend({ + entityKind: z.literal('knowledge_base'), + }), + 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'), }), @@ -1002,6 +1140,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/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 3caf61c82..ff571c637 100644 --- a/apps/tradinggoose/lib/copilot/review-sessions/identity.ts +++ b/apps/tradinggoose/lib/copilot/review-sessions/identity.ts @@ -1,12 +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' const REVIEW_ENTITY_KIND_SET = new Set<string>(REVIEW_ENTITY_KINDS) const YJS_TARGET_KIND_SET = new Set<string>(YJS_TARGET_KINDS) @@ -31,23 +32,67 @@ 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, + } +} + +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, @@ -62,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, @@ -99,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') } @@ -115,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, @@ -144,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') } @@ -183,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 @@ -198,6 +229,10 @@ export function serializeYjsTransportEnvelope( export function parseYjsTransportEnvelope( payload: Record<string, string | undefined> ): YjsTransportEnvelope { + if (normalizeNullableString(payload.workflowId)) { + throw new Error('Yjs transport envelope cannot carry workflowId; use entityId') + } + const envelope: YjsTransportEnvelope = { targetKind: requireYjsTargetKind(payload.targetKind), sessionId: @@ -205,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.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<Awaited<ReturnType<typeof readWorkflowAccessContext>>>['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 5c47c2070..c3c173b9d 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,29 @@ export async function verifyReviewTargetAccess( accessMode: ReviewAccessMode ): Promise<ReviewAccessResult> { 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) + 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)) { + 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) { @@ -306,7 +321,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/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 c16c23dd5..38692376a 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] @@ -34,17 +36,16 @@ export interface ReviewTargetRuntimeState { export interface ResolvedReviewTarget { descriptor: ReviewTargetDescriptor - runtime: ReviewTargetRuntimeState | null + 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/runtime-tool-manifest.test.ts b/apps/tradinggoose/lib/copilot/runtime-tool-manifest.test.ts index 4f3eac525..da6b80da5 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('<variable.name>'), - kind: 'read', + name: 'edit_workflow_variable', + description: expect.stringContaining('workflow-variable document'), + kind: 'edit', entityKind: 'workflow', }), expect.objectContaining({ @@ -290,8 +290,8 @@ describe('copilot runtime tool manifest', () => { ?.semanticValidators?.find((validator) => validator.kind === 'string_json_schema')?.args ?.schema as { properties?: Record<string, unknown>; required?: string[] } | undefined expect(createWorkflowProperties).not.toHaveProperty('color') - expect(createIndicatorSchema?.properties ?? {}).not.toHaveProperty('color') - expect(createIndicatorSchema?.required ?? []).not.toContain('color') + expect(createIndicatorSchema?.properties ?? {}).toHaveProperty('color') + expect(createIndicatorSchema?.required ?? []).toContain('color') expect(editWorkflowProperties).toHaveProperty('entityId') expect(editWorkflowProperties).toHaveProperty('entityDocument') expect(editWorkflowProperties).toHaveProperty('removedBlockIds') diff --git a/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts b/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts index f0928a5b6..739e776d4 100644 --- a/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts +++ b/apps/tradinggoose/lib/copilot/server-tool-errors.test.ts @@ -97,17 +97,24 @@ 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') + ) + const variableResponse = buildCopilotServerToolErrorResponse( + 'edit_workflow_variable', + new Error('Invalid edited workflow variables: Missing removedVariableIds.') ) 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') + 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 2fdf1ee90..b07c7434d 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 @@ -129,13 +131,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,19 +176,28 @@ export function buildCopilotServerToolErrorResponse( } const message = error instanceof Error ? error.message : 'Failed to execute server tool' - if (toolName === 'edit_workflow') { const structuredError = buildEditWorkflowError(message) if (structuredError) { 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, body: { code: 'server_tool_execution_failed', - error: message, + error: GENERIC_SERVER_TOOL_ERROR, retryable: false, }, } diff --git a/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts b/apps/tradinggoose/lib/copilot/tool-prompt-metadata.ts index efad9e3bd..7c002a8c7 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 <paramName> 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<ToolId, ToolPromptMetadata> = { plan: { @@ -28,7 +30,7 @@ export const TOOL_PROMPT_METADATA: Record<ToolId, ToolPromptMetadata> = { }, [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<ToolId, ToolPromptMetadata> = { }, [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,22 +116,23 @@ export const TOOL_PROMPT_METADATA: Record<ToolId, ToolPromptMetadata> = { }, [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 through an explicit personal or workspace scope. Use returned names with the exact `{{ENV_VAR_NAME}}` syntax in block inputs.', kind: 'read', entityKind: 'environment', }, set_environment_variables: { - description: 'Set environment variables.', + description: 'Set personal or workspace environment variables using an explicit scope.', kind: 'edit', entityKind: 'environment', }, [CopilotTool.read_oauth_credentials]: { - description: 'Read OAuth credentials.', + 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.', + description: + 'Read OAuth credentials and related environment variable names through an explicit personal or workspace scope.', kind: 'read', entityKind: 'credential', }, @@ -139,14 +142,9 @@ export const TOOL_PROMPT_METADATA: Record<ToolId, ToolPromptMetadata> = { kind: 'list', entityKind: 'workflow', }, - [CopilotTool.read_workflow_variables]: { + [CopilotTool.edit_workflow_variable]: { description: - 'Read workflow variables. Use returned names with the exact `<variable.name>` 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 `<variable.name>` syntax in block inputs.', kind: 'edit', entityKind: 'workflow', }, @@ -165,9 +163,36 @@ export const TOOL_PROMPT_METADATA: Record<ToolId, ToolPromptMetadata> = { 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: { @@ -279,7 +304,7 @@ export const TOOL_PROMPT_METADATA: Record<ToolId, ToolPromptMetadata> = { }, [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', }, @@ -291,13 +316,13 @@ export const TOOL_PROMPT_METADATA: Record<ToolId, ToolPromptMetadata> = { }, 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', }, @@ -324,13 +349,13 @@ export const TOOL_PROMPT_METADATA: Record<ToolId, ToolPromptMetadata> = { }, 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<string, any>, -> extends BaseClientTool { - private stagedReviewResult?: TReviewResult - - protected getStagedReviewResult(): TReviewResult | undefined { - return this.stagedReviewResult ?? this.resolvePersistedResult<TReviewResult>() - } - - 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<string, any>): Promise<boolean> { - 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<string, unknown> - 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<EntityDocumentKind, EntityApiConfig> = { - 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<string, unknown> -): { endpoint: string; body: Record<string, unknown> } { - 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<string, unknown> -): Promise<{ - entityId: string - entityName: string - fields: Record<string, unknown> -}> { - 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<CopilotEntityYjsSessionLease> { - 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<any[]> { - 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<EntityListEntry[]> { - 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<CopilotIndicatorListEntry[]> { - 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<string, unknown> -}> { - 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<string, unknown> -): 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<TArgs>(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<string, unknown>, - nextFields: Record<string, unknown> -): { 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<void> { - 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<void> { - 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<Record<string, any>> { - 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<void> { - try { - this.currentArgs = args - this.setState(ClientToolCallState.executing) - const executionContext = this.requireExecutionContext() - const resolvedArgs = args || readStoredToolArgs<EditEntityDocumentArgs>(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<string, unknown> = {} - 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<string, any> | undefined): boolean { - return !!result?.entityDocument - } - - async handleAccept(args?: EditEntityDocumentArgs): Promise<void> { - 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<void> { - 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<string, { workspaceId?: string }>, -} - -const mockCopilotState = { - toolCallsById: {} as Record<string, { params?: Record<string, unknown> }>, -} - -const mockEntityFieldState = { - values: {} as Record<string, unknown>, -} -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/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<string, any>, state: ClientToolCallState) => { - const operation = params?.operation as string | undefined - const name = params?.args?.name as string | undefined - - const opVerbs: Record<string, { active: string; past: string; pending?: string }> = { - 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<void> { - await super.handleReject() - this.setState(ClientToolCallState.rejected) - } - - async handleAccept(args?: KnowledgeBaseArgs): Promise<void> { - await this.execute(args) - } - - async execute(args?: KnowledgeBaseArgs): Promise<void> { - 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<EditMonitorArgs>(this.toolCallId) - return args?.monitorDocument ? this.metadata.interrupt : undefined - } - - async execute(args?: EditMonitorArgs): Promise<void> { - this.currentArgs = args - } - - async handleAccept(args?: EditMonitorArgs): Promise<void> { - try { - this.setState(ClientToolCallState.executing) - - const resolvedArgs = - args || this.currentArgs || readStoredToolArgs<EditMonitorArgs>(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<void> { - 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<string, { workspaceId?: string }>, -} - -const mockCopilotState = { - toolCallsById: {} as Record<string, { params?: Record<string, unknown> }>, -} - -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<void> { - 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..c7984bd66 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<TResult = unknown>(input: { } signal?: AbortSignal }): Promise<TResult> { + 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,52 @@ export async function executeCopilotServerTool<TResult = unknown>(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 + reviewToken: string +} { + return ( + !!result && + typeof result === 'object' && + (result as { requiresReview?: unknown }).requiresReview === true && + typeof (result as { reviewToken?: unknown }).reviewToken === 'string' + ) +} + +export async function acceptCopilotServerToolReview<TResult = unknown>(input: { + toolName: string + reviewToken: string + context?: { + contextEntityKind?: ReviewEntityKind + contextEntityId?: string + workspaceId?: string + } + signal?: AbortSignal +}): Promise<TResult> { + 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', + reviewToken: input.reviewToken, + ...(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<string, { params?: Record<string, unknown>; 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<string, any> = { - 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<void> { - 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<TArgs>(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<void> { - this.currentArgs = args - } - - async handleAccept(args?: CreateWorkflowArgs): Promise<void> { - const logger = createLogger('CreateWorkflowClientTool') - - try { - this.setState(ClientToolCallState.executing) - - const executionContext = this.requireExecutionContext() - const resolvedArgs = - args || this.currentArgs || readStoredToolArgs<CreateWorkflowArgs>(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<string, any> | undefined, - currentWorkflowState: string - ): Record<string, any> { - 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<br/>id: block-1<br/>type: trigger"]', -].join('\n') -const workflowGraphDocumentFormat = 'tg-workflow-graph-mermaid-v1' - -let persistedToolCalls: Record<string, any> = {} - -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<unknown>)[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<TArgs>(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<Record<string, any>> { - 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<void> { - 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<EditWorkflowArgs>(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<string, any> | undefined, - currentWorkflowState: string - ): Record<string, any> { - 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<string, any> | undefined): boolean { - return !!result?.workflowState - } - - async execute(args?: EditWorkflowArgs): Promise<void> { - 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<void> { - 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<void> { - 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<typeof readWorkflowVariableOutputs> - } = { - 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<void> { - 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<string>() - const containingParallelIds = new Set<string>() - - Object.values(loops as Record<string, Loop>).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<string, Parallel>).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<string>(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<void> { - 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<void> { - 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<TArgs>(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<void> { - this.currentArgs = args - } - - async handleAccept(args?: RenameWorkflowArgs): Promise<void> { - const logger = createLogger('RenameWorkflowClientTool') - - try { - this.setState(ClientToolCallState.executing) - - const resolvedArgs = - args || this.currentArgs || readStoredToolArgs<RenameWorkflowArgs>(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<string, { params?: Record<string, unknown>; 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<void> { - const logger = createLogger('SetWorkflowVariablesClientTool') - try { - this.setState(ClientToolCallState.executing) - const payload = { ...(args || { operations: [] }) } as Partial<SetWorkflowVariablesArgs> - 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<string, any> = {} - 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<string, any> = {} - 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<void> { - 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<string, { params?: Record<string, unknown> }>, -} - -const mockRegistryState = { - workflows: {} as Record<string, any>, - 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<string, unknown>) => { - 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..dc0db84c4 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/base-tool.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/base-tool.ts @@ -1,14 +1,50 @@ +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 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<TArgs extends WorkspaceArg>( + 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 @@ -19,6 +55,42 @@ 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 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<TArgs = any, TResult = any> { name: ToolId execute(args: TArgs, context?: ServerToolExecutionContext): Promise<TResult> 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 `<variable.riskLimit>`.', examples: ['<variable.riskLimit>', '<variable.companyName>'], - 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: '<variable.name>', 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..f9d8d0fd9 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/custom-tool.ts @@ -0,0 +1,116 @@ +import { ENTITY_KIND_CUSTOM_TOOL } from '@/lib/copilot/review-sessions/types' +import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' +import { createCustomTools } from '@/lib/custom-tools/operations' +import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' +import { savedEntityRowToFields } from '@/lib/yjs/entity-state' +import { + buildDocumentEnvelope, + buildSavedEntityListInfo, + type EntityCreateResult, + type EntityServerTool, + executeCreateEntityDocumentMutation, + executeUpdateEntityDocumentMutation, + readSavedEntityDocumentFields, + requireEntityId, + verifySavedEntityContext, + verifyWorkspaceContext, +} from './shared' + +async function createCustomToolEntity( + fields: Record<string, unknown>, + context: Parameters<typeof verifyWorkspaceContext>[0] +): Promise<EntityCreateResult> { + const { userId, workspaceId } = await verifyWorkspaceContext(context, 'write') + const rows = await createCustomTools({ + userId, + workspaceId, + tools: [ + { + title: String(fields.title ?? ''), + schema: parseCustomToolSchemaText(fields.schemaText), + code: String(fields.codeText ?? ''), + }, + ], + }) + const row = rows[0] + if (!row) { + throw new Error('Created custom tool was not returned') + } + + return { + entityId: row.id, + fields: savedEntityRowToFields(ENTITY_KIND_CUSTOM_TOOL, row), + } +} + +export const listCustomToolsServerTool: EntityServerTool<Record<string, never>> = { + name: 'list_custom_tools', + async execute(args, context) { + const { workspaceId } = await verifyWorkspaceContext( + withWorkspaceArgContext(context, args), + 'read' + ) + const entities = await buildSavedEntityListInfo(ENTITY_KIND_CUSTOM_TOOL, workspaceId) + + 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 readSavedEntityDocumentFields( + 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 executeCreateEntityDocumentMutation( + ENTITY_KIND_CUSTOM_TOOL, + args, + context, + createCustomToolEntity + ) + }, +} + +export const editCustomToolServerTool: EntityServerTool = { + name: 'edit_custom_tool', + execute(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 executeUpdateEntityDocumentMutation( + ENTITY_KIND_CUSTOM_TOOL, + 'rename_custom_tool', + args, + context + ) + }, +} 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..235ffbcf8 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/index.ts @@ -0,0 +1,37 @@ +export { + createCustomToolServerTool, + editCustomToolServerTool, + listCustomToolsServerTool, + readCustomToolServerTool, + renameCustomToolServerTool, +} from './custom-tool' +export { + createIndicatorServerTool, + editIndicatorServerTool, + listIndicatorsServerTool, + readIndicatorServerTool, + renameIndicatorServerTool, +} from './indicator' +export { + createMcpServerServerTool, + editMcpServerServerTool, + listMcpServersServerTool, + readMcpServerServerTool, + renameMcpServerServerTool, +} from './mcp-server' +export { + createSkillServerTool, + editSkillServerTool, + listSkillsServerTool, + readSkillServerTool, + renameSkillServerTool, +} from './skill' +export { + createWorkflowServerTool, + editWorkflowVariableServerTool, + editWorkflowBlockServerTool, + editWorkflowServerTool, + listWorkflowsServerTool, + readWorkflowServerTool, + renameWorkflowServerTool, +} from './workflow' 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..a759292c8 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts @@ -0,0 +1,119 @@ +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 mockReadBootstrappedEntityListMembers = vi.hoisted(() => vi.fn()) +const mockReadBootstrappedSavedEntityFields = 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/bootstrap-review-target', () => ({ + requireSavedEntityRealtimeListMembers: (...args: unknown[]) => + mockReadBootstrappedEntityListMembers(...args), + readBootstrappedSavedEntityFields: (...args: unknown[]) => + mockReadBootstrappedSavedEntityFields(...args), +})) + +describe('indicator server tools', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + }) + mockReadBootstrappedEntityListMembers.mockResolvedValue([ + { + entityId: 'indicator-custom-1', + entityName: 'Custom Momentum', + }, + ]) + mockVerifyReviewTargetAccess.mockResolvedValue({ + hasAccess: true, + workspaceId: 'workspace-1', + }) + mockReadBootstrappedSavedEntityFields.mockResolvedValue({ + name: 'Custom Momentum', + color: '#10b981', + 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', + }) + ) + }) + + 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(JSON.parse(result.entityDocument).color).toBe('') + 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, + }) + expect(JSON.parse(result.entityDocument)).toMatchObject({ + name: 'Custom Momentum', + color: '#10b981', + }) + }) +}) 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..ee059ac05 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.ts @@ -0,0 +1,172 @@ +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' +import { + DEFAULT_INDICATOR_RUNTIME_ENTRIES, + DEFAULT_INDICATOR_RUNTIME_MAP, +} from '@/lib/indicators/default/runtime' +import { savedEntityRowToFields } from '@/lib/yjs/entity-state' +import { + buildDocumentEnvelope, + buildSavedEntityListInfo, + type CopilotIndicatorListEntry, + type EntityCreateResult, + type EntityServerTool, + executeCreateEntityDocumentMutation, + executeUpdateEntityDocumentMutation, + readSavedEntityDocumentFields, + 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, + } +} + +async function listCopilotIndicators(workspaceId: string): Promise<CopilotIndicatorListEntry[]> { + const defaultOptions = DEFAULT_INDICATOR_RUNTIME_ENTRIES.map(toDefaultIndicatorListEntry) + 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)) +} + +async function createIndicatorEntity( + fields: Record<string, unknown>, + context: Parameters<typeof verifyWorkspaceContext>[0] +): Promise<EntityCreateResult> { + const { userId, workspaceId } = await verifyWorkspaceContext(context, 'write') + const rows = await createIndicators({ + userId, + workspaceId, + indicators: [ + { + name: String(fields.name ?? ''), + color: String(fields.color ?? ''), + pineCode: String(fields.pineCode ?? ''), + }, + ], + }) + const row = rows[0] + if (!row) { + throw new Error('Created indicator was not returned') + } + + return { + entityId: row.id, + fields: savedEntityRowToFields(ENTITY_KIND_INDICATOR, row), + } +} + +export const listIndicatorsServerTool: EntityServerTool<Record<string, never>> = { + 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 defaultIndicator = DEFAULT_INDICATOR_RUNTIME_MAP.get(runtimeId) + if (defaultIndicator) { + return buildDocumentEnvelope(ENTITY_KIND_INDICATOR, undefined, { + name: defaultIndicator.name, + color: '', + pineCode: defaultIndicator.pineCode, + }) + } + + 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') + const { workspaceId } = await verifySavedEntityContext( + context, + ENTITY_KIND_INDICATOR, + entityId, + 'read' + ) + const fields = await readSavedEntityDocumentFields(ENTITY_KIND_INDICATOR, entityId, workspaceId) + return buildDocumentEnvelope(ENTITY_KIND_INDICATOR, entityId, fields) + }, +} + +export const createIndicatorServerTool: EntityServerTool = { + name: 'create_indicator', + execute(args, context) { + return executeCreateEntityDocumentMutation( + ENTITY_KIND_INDICATOR, + args, + context, + createIndicatorEntity + ) + }, +} + +export const editIndicatorServerTool: EntityServerTool = { + name: 'edit_indicator', + execute(args, context) { + return executeUpdateEntityDocumentMutation( + ENTITY_KIND_INDICATOR, + 'edit_indicator', + args, + context + ) + }, +} + +export const renameIndicatorServerTool: EntityServerTool = { + name: 'rename_indicator', + execute(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 new file mode 100644 index 000000000..b683dce7f --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts @@ -0,0 +1,195 @@ +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' +import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { + buildDocumentEnvelope, + buildSavedEntityListInfo, + type EntityCreateResult, + type EntityServerTool, + executeCreateEntityDocumentMutation, + executeUpdateEntityDocumentMutation, + readSavedEntityDocumentFields, + requireEntityId, + verifySavedEntityContext, + verifyWorkspaceContext, +} from './shared' + +function normalizeMcpServerDocumentFields( + fields: Record<string, unknown> +): Record<string, unknown> { + return normalizeEntityFields(ENTITY_KIND_MCP_SERVER, fields) +} + +function preserveSecretRecordPlaceholders( + fieldName: 'headers' | 'env', + nextValues: Record<string, string>, + currentValues: Record<string, string> +): Record<string, string> { + 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<string, unknown>): 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<string, unknown>, + currentFields: Record<string, unknown> +): Record<string, unknown> { + const normalizedNext = normalizeMcpServerDocumentFields(nextFields) + const normalizedCurrent = normalizeMcpServerDocumentFields(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<string, unknown>): Record<string, unknown> { + const normalized = normalizeMcpServerDocumentFields(fields) + assertNoSecretPlaceholders(normalized) + return normalized +} + +async function createMcpServerEntity( + fields: Record<string, unknown>, + context: Parameters<typeof verifyWorkspaceContext>[0] +): Promise<EntityCreateResult> { + const { userId, workspaceId } = await verifyWorkspaceContext(context, 'write') + const created = await mcpService.createWorkspaceServer({ userId, workspaceId, fields }) + + return { + entityId: created.entityId, + fields: created.fields, + } +} + +async function applyMcpServerDocument(input: { + entityId: string + fields: Record<string, unknown> + workspaceId: string +}) { + const currentFields = await readSavedEntityDocumentFields( + ENTITY_KIND_MCP_SERVER, + input.entityId, + input.workspaceId + ) + await applySavedEntityState( + ENTITY_KIND_MCP_SERVER, + input.entityId, + preserveMcpServerSecretPlaceholders(input.fields, currentFields) + ) +} + +export const listMcpServersServerTool: EntityServerTool<Record<string, never>> = { + name: 'list_mcp_servers', + async execute(args, context) { + const { workspaceId } = await verifyWorkspaceContext( + withWorkspaceArgContext(context, args), + 'read' + ) + const entities = await buildSavedEntityListInfo(ENTITY_KIND_MCP_SERVER, workspaceId) + + 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 readSavedEntityDocumentFields( + 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 executeCreateEntityDocumentMutation( + ENTITY_KIND_MCP_SERVER, + args, + context, + createMcpServerEntity, + prepareNewMcpServerFields + ) + }, +} + +export const editMcpServerServerTool: EntityServerTool = { + name: 'edit_mcp_server', + execute(args, context) { + return executeUpdateEntityDocumentMutation( + ENTITY_KIND_MCP_SERVER, + 'edit_mcp_server', + args, + context, + applyMcpServerDocument, + normalizeMcpServerDocumentFields + ) + }, +} + +export const renameMcpServerServerTool: EntityServerTool = { + name: 'rename_mcp_server', + execute(args, context) { + return executeUpdateEntityDocumentMutation( + ENTITY_KIND_MCP_SERVER, + 'rename_mcp_server', + args, + context, + applyMcpServerDocument, + normalizeMcpServerDocumentFields + ) + }, +} 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..4efadde9c --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts @@ -0,0 +1,357 @@ +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' +import { + buildDocumentEnvelope, + buildReviewDocumentDiff, + executeCreateEntityDocumentMutation, + executeUpdateEntityDocumentMutation, +} from './shared' + +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()) + +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', () => ({ + requireSavedEntityRealtimeListMembers: (...args: unknown[]) => + mockReadBootstrappedEntityListMembers(...args), + readBootstrappedSavedEntityFields: (...args: unknown[]) => + mockReadBootstrappedSavedEntityFields(...args), +})) + +describe('entity document mutation helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + }) + mockVerifyReviewTargetAccess.mockResolvedValue({ + hasAccess: true, + workspaceId: 'workspace-1', + }) + mockReadBootstrappedEntityListMembers.mockResolvedValue([]) + }) + + 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(mockReadBootstrappedSavedEntityFields).not.toHaveBeenCalled() + }) + + it('persists accepted reviewed updates after verifying the reviewed base', 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(mockApplySavedEntityState).toHaveBeenCalledWith('skill', 'skill-1', nextFields) + }) + + 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', + 'edit_indicator', + { + entityId: 'indicator-1', + documentFormat: INDICATOR_DOCUMENT_FORMAT, + entityDocument: JSON.stringify({ + name: 'Updated Indicator', + color: '#10b981', + pineCode, + inputMeta: { + Stale: { title: 'Stale', type: 'string', defval: 'old' }, + }, + }), + }, + { userId: 'user-1', accessLevel: 'full' } + ) + + expect(mockApplySavedEntityState).toHaveBeenCalledWith('indicator', 'indicator-1', { + name: 'Updated Indicator', + color: '#10b981', + pineCode, + }) + }) + + 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('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( + '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(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', + { + 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' + ) + }) + + 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', + { + 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(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') + 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 new file mode 100644 index 000000000..801a70615 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -0,0 +1,311 @@ +import { + type EntityDocumentKind, + getEntityDocumentFormat, + getEntityDocumentName, + 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, + ServerToolExecutionContext, +} from '@/lib/copilot/tools/server/base-tool' +import { + assertAcceptedServerToolReviewBase, + hashServerToolReviewBase, + shouldStageServerToolMutationForReview, + withWorkspaceArgContext, +} 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 { + readBootstrappedSavedEntityFields, + requireSavedEntityRealtimeListMembers, +} from '@/lib/yjs/server/bootstrap-review-target' + +export type SavedEntityDocumentKind = EntityDocumentKind +export type EntityDocumentArgs = { + entityId?: string + runtimeId?: string + workspaceId?: string + entityDocument?: string + documentFormat?: string +} + +/** + * 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 = { + name: string + source: 'default' | 'custom' + editable: boolean + callableInFunctionBlock: boolean + inputTitles?: string[] + entityId?: string + runtimeId?: string +} + +export type EntityCreateResult = { + entityId: string + fields: Record<string, unknown> +} + +export type CreateEntityFromDocument = ( + fields: Record<string, unknown>, + context: ServerToolExecutionContext | undefined +) => Promise<EntityCreateResult> + +export type ApplyEntityDocument = (input: { + entityId: string + fields: Record<string, unknown> + workspaceId: string +}) => Promise<void> + +export type PrepareEntityDocumentFields = ( + fields: Record<string, unknown> +) => Record<string, unknown> + +const ENTITY_KIND_LABELS: Record<SavedEntityDocumentKind, string> = { + 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 +} + +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, + buildSavedEntityDescriptor(entityKind, entityId, context?.workspaceId?.trim() ?? null), + 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 +} + +function parseEntityMutationDocument( + kind: SavedEntityDocumentKind, + args: EntityDocumentArgs +): Record<string, unknown> { + 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<string, unknown> +) { + return { + entityKind: kind, + ...(entityId ? { entityId } : {}), + entityName: getEntityDocumentName(kind, fields), + documentFormat: getEntityDocumentFormat(kind), + entityDocument: serializeEntityDocument(kind, fields), + } +} + +export function buildReviewDocumentDiff( + kind: SavedEntityDocumentKind, + before: Record<string, unknown>, + after: Record<string, unknown> +) { + return { + before: serializeEntityDocument(kind, before), + after: serializeEntityDocument(kind, after), + } +} + +export async function readSavedEntityDocumentFields( + kind: SavedEntityDocumentKind, + entityId: string, + workspaceId: string +): Promise<Record<string, unknown>> { + return readBootstrappedSavedEntityFields(kind as SavedEntityKind, entityId, workspaceId) +} + +/** + * Canonical read for every saved-entity list_* tool: the workspace's membership + * 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, + workspaceId: string +): Promise<EntityListEntry[]> { + return requireSavedEntityRealtimeListMembers(entityKind, workspaceId) +} + +async function hashCreateEntityReviewBase( + kind: SavedEntityDocumentKind, + workspaceId: string +): Promise<string> { + return hashServerToolReviewBase({ + kind, + workspaceId, + entities: await buildSavedEntityListInfo(kind as SavedEntityKind, workspaceId), + }) +} + +export async function executeCreateEntityDocumentMutation( + kind: SavedEntityDocumentKind, + args: EntityDocumentArgs, + context: ServerToolExecutionContext | undefined, + create: CreateEntityFromDocument, + prepareFields?: PrepareEntityDocumentFields +) { + 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 parsedFields = parseEntityMutationDocument(kind, args) + const fields = prepareFields ? prepareFields(parsedFields) : parsedFields + + if (shouldStageServerToolMutationForReview(context)) { + return { + requiresReview: true, + success: true, + workspaceId, + reviewBaseStateHash: await hashCreateEntityReviewBase(kind, workspaceId), + ...buildDocumentEnvelope(kind, undefined, fields), + preview: { + documentDiff: { + before: '', + after: serializeEntityDocument(kind, fields), + }, + }, + } + } + + if (context?.acceptedReviewBaseStateHash) { + assertAcceptedServerToolReviewBase(context, await hashCreateEntityReviewBase(kind, workspaceId)) + } + const created = await create(fields, scopedContext) + return { + success: true, + workspaceId, + ...buildDocumentEnvelope(kind, created.entityId, created.fields), + } +} + +export async function executeUpdateEntityDocumentMutation( + kind: SavedEntityDocumentKind, + toolName: string, + args: EntityDocumentArgs, + context: ServerToolExecutionContext | undefined, + apply?: ApplyEntityDocument, + prepareFields?: PrepareEntityDocumentFields +) { + const parsedFields = parseEntityMutationDocument(kind, args) + const fields = prepareFields ? prepareFields(parsedFields) : parsedFields + const entityId = requireEntityId(args, toolName) + const { workspaceId } = await verifySavedEntityContext(context, kind, entityId, 'write') + + if (shouldStageServerToolMutationForReview(context)) { + const currentFields = await readSavedEntityDocumentFields(kind, entityId, workspaceId) + return { + requiresReview: true, + success: true, + reviewBaseStateHash: hashServerToolReviewBase(currentFields), + ...buildDocumentEnvelope(kind, entityId, fields), + preview: { + documentDiff: buildReviewDocumentDiff(kind, currentFields, fields), + }, + } + } + + if (context?.acceptedReviewBaseStateHash) { + assertAcceptedServerToolReviewBase( + context, + hashServerToolReviewBase(await readSavedEntityDocumentFields(kind, entityId, workspaceId)) + ) + } + + if (apply) { + await apply({ entityId, fields, workspaceId }) + } else { + await applySavedEntityState(kind, entityId, fields) + } + return { + success: true, + workspaceId, + ...buildDocumentEnvelope(kind, entityId, fields), + } +} + +export type EntityServerTool<TArgs = EntityDocumentArgs> = BaseServerTool<TArgs, any> 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..bff691dcc --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/skill.ts @@ -0,0 +1,96 @@ +import { ENTITY_KIND_SKILL } from '@/lib/copilot/review-sessions/types' +import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' +import { createSkills } from '@/lib/skills/operations' +import { savedEntityRowToFields } from '@/lib/yjs/entity-state' +import { + buildDocumentEnvelope, + buildSavedEntityListInfo, + type EntityCreateResult, + type EntityServerTool, + executeCreateEntityDocumentMutation, + executeUpdateEntityDocumentMutation, + readSavedEntityDocumentFields, + requireEntityId, + verifySavedEntityContext, + verifyWorkspaceContext, +} from './shared' + +async function createSkillEntity( + fields: Record<string, unknown>, + context: Parameters<typeof verifyWorkspaceContext>[0] +): Promise<EntityCreateResult> { + const { userId, workspaceId } = await verifyWorkspaceContext(context, 'write') + const rows = await createSkills({ + userId, + workspaceId, + skills: [ + { + name: String(fields.name ?? ''), + description: String(fields.description ?? ''), + content: String(fields.content ?? ''), + }, + ], + }) + const row = rows[0] + if (!row) { + throw new Error('Created skill was not returned') + } + + return { + entityId: row.id, + fields: savedEntityRowToFields(ENTITY_KIND_SKILL, row), + } +} + +export const listSkillsServerTool: EntityServerTool<Record<string, never>> = { + name: 'list_skills', + async execute(args, context) { + const { workspaceId } = await verifyWorkspaceContext( + withWorkspaceArgContext(context, args), + 'read' + ) + const entities = await buildSavedEntityListInfo(ENTITY_KIND_SKILL, workspaceId) + + 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 readSavedEntityDocumentFields(ENTITY_KIND_SKILL, entityId, workspaceId) + return buildDocumentEnvelope(ENTITY_KIND_SKILL, entityId, fields) + }, +} + +export const createSkillServerTool: EntityServerTool = { + name: 'create_skill', + execute(args, context) { + return executeCreateEntityDocumentMutation(ENTITY_KIND_SKILL, args, context, createSkillEntity) + }, +} + +export const editSkillServerTool: EntityServerTool = { + name: 'edit_skill', + execute(args, context) { + return executeUpdateEntityDocumentMutation(ENTITY_KIND_SKILL, 'edit_skill', args, context) + }, +} + +export const renameSkillServerTool: EntityServerTool = { + name: 'rename_skill', + execute(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 new file mode 100644 index 000000000..807a016bd --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow-variable.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' +import { WORKFLOW_VARIABLE_DOCUMENT_FORMAT } from '@/lib/copilot/entity-documents' +import { + 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 mockVerifyReviewTargetAccess = vi.hoisted(() => vi.fn()) +const mockApplyWorkflowState = vi.hoisted(() => vi.fn()) +const mockApplyWorkflowPatchInSocketServer = 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', () => ({ + verifyReviewTargetAccess: (...args: any[]) => mockVerifyReviewTargetAccess(...args), +})) + +vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ + readBootstrappedReviewTargetSnapshot: (...args: any[]) => + mockReadBootstrappedReviewTargetSnapshot(...args), +})) + +vi.mock('@/lib/yjs/server/apply-workflow-state', () => ({ + applyWorkflowState: (...args: any[]) => mockApplyWorkflowState(...args), + applyWorkflowMetadata: vi.fn(), +})) + +vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ + applyWorkflowPatchInSocketServer: (...args: any[]) => + mockApplyWorkflowPatchInSocketServer(...args), +})) + +function workflowSnapshotBase64( + variables: Record<string, any>, + workflowState = createWorkflowSnapshot() +): string { + const doc = new Y.Doc() + setWorkflowState(doc, workflowState, '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() + mockVerifyReviewTargetAccess.mockReset() + mockApplyWorkflowState.mockReset() + mockApplyWorkflowPatchInSocketServer.mockReset() + mockDbLimit.mockResolvedValue([ + { + id: 'wf-1', + name: 'Strategy Workflow', + workspaceId: 'workspace-1', + }, + ]) + mockVerifyReviewTargetAccess.mockResolvedValue({ + hasAccess: true, + workspaceId: 'workspace-1', + }) + mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue({ + snapshotBase64: workflowSnapshotBase64({ + 'var-1': { + id: 'wrong-id', + workflowId: 'wf-1', + name: 'riskLimit', + type: 'number', + value: 10, + }, + }), + }) + }) + + it('returns workflow variables through read_workflow', async () => { + const result = await readWorkflowServerTool.execute( + { entityId: 'wf-1' }, + { userId: 'user-1', accessLevel: 'limited' } + ) + + expect(result.workflowVariableDocumentFormat).toBe(WORKFLOW_VARIABLE_DOCUMENT_FORMAT) + expect(JSON.parse(result.workflowVariableDocument)).toEqual({ + variables: [{ variableId: 'var-1', name: 'riskLimit', type: 'number', value: 10 }], + }) + }) + + 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: [ + { variableId: 'var-1', name: 'riskLimit', type: 'number', value: 25 }, + { variableId: 'var-2', name: 'enabled', type: 'boolean', value: true }, + ], + }), + }, + { userId: 'user-1', accessLevel: 'limited' } + ) + + 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 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: [] }), + removedVariableIds: ['var-1'], + }, + { 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(result.variables).toEqual({}) + expect(mockApplyWorkflowPatchInSocketServer).toHaveBeenCalledWith('wf-1', { variables: {} }) + expect(mockApplyWorkflowState).not.toHaveBeenCalled() + }) + + 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( + { + 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(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 new file mode 100644 index 000000000..608f40107 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -0,0 +1,613 @@ +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 { 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 { + BaseServerTool, + ServerToolExecutionContext, +} from '@/lib/copilot/tools/server/base-tool' +import { + assertAcceptedServerToolReviewBase, + hashServerToolReviewBase, + shouldStageServerToolMutationForReview, + withWorkspaceArgContext, +} 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 { VariableManager } from '@/lib/variables/variable-manager' +import { TG_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' +import { + readWorkflowContainerBoundaryEdgeViolation, + readWorkflowEdgeScope, + serializeWorkflowToTgMermaid, +} from '@/lib/workflows/studio-workflow-mermaid' +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, + readWorkflowEntityMetadata, + readWorkflowSnapshot, + type WorkflowSnapshot, +} from '@/lib/yjs/workflow-session' +import { requireUserId, verifyWorkspaceContext } from './shared' + +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 = { + variableId: string + name: string + type: WorkflowVariableType + value?: unknown +} + +async function hashWorkflowCreateReviewBase(workspaceId: string): Promise<string> { + 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( + z.object({ + variableId: z.string().trim().min(1), + name: z.string().trim().min(1), + type: z.string().trim().min(1), + value: z.unknown().optional(), + }) + ), + }) + .strict() + +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 verifyWorkflowContext( + workflowId: string, + context: ServerToolExecutionContext | undefined, + accessMode: ReviewAccessMode +) { + const userId = requireUserId(context) + 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` + ) + } + + 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<string, any> +}> { + 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')) + const metadata = readWorkflowEntityMetadata(doc) + return { + workflowId, + entityName: metadata.name ?? workflowRow.name ?? undefined, + workspaceId: workflowRow.workspaceId ?? null, + workflowState: readWorkflowSnapshot(doc), + variables: getVariablesSnapshot(doc), + } + } finally { + doc.destroy() + } +} + +function serializeWorkflowVariableDocument(variables: Record<string, any>): string { + 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 ?? '', + })) + .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<string>() + const seenVariableIds = new Set<string>() + + return parsed.variables.map((variable) => { + 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)) { + 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 { + variableId, + 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 + entityDocument: string +}): Record<string, any> { + const entries = parseWorkflowVariableDocument(input.entityDocument) + + return Object.fromEntries( + entries.map((entry) => { + const id = entry.variableId + 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 + removedVariableIds?: 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, + 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) + + 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, + reviewBaseStateHash: currentVariablesBaseHash, + preview: { + documentDiff: { + before: currentDocument, + after: nextDocument, + }, + }, + } + } + + assertAcceptedServerToolReviewBase(context, currentVariablesBaseHash) + await applyWorkflowPatchInSocketServer(workflowId, { variables: nextVariables }) + return { + success: true, + entityKind: ENTITY_KIND_WORKFLOW, + entityId: workflowId, + ...(workspaceId ? { workspaceId } : {}), + documentFormat: WORKFLOW_VARIABLE_DOCUMENT_FORMAT, + entityDocument: nextDocument, + variables: nextVariables, + } + }, +} + +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 name = args.name?.trim() || 'New workflow' + + if (shouldStageServerToolMutationForReview(context)) { + return { + requiresReview: true, + success: true, + entityKind: ENTITY_KIND_WORKFLOW, + entityName: name, + 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' + 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, + isPublished: false, + marketplaceData: null, + }) + + try { + await applyWorkflowState( + workflowId, + workflowState, + {}, + { name, description, folderId: args.folderId || null } + ) + } catch (error) { + await db.delete(workflow).where(eq(workflow.id, workflowId)) + throw error + } + + 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') + } + + const { workspaceId: accessWorkspaceId } = await verifyWorkflowContext( + workflowId, + context, + 'write' + ) + const [current] = await db + .select({ + name: workflow.name, + workspaceId: workflow.workspaceId, + }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + if (!current) { + throw new Error('Workflow not found') + } + + const workspaceId = current.workspaceId ?? accessWorkspaceId + const currentNameBaseHash = hashServerToolReviewBase({ + workflowId, + entityName: current.name ?? '', + }) + if (shouldStageServerToolMutationForReview(context)) { + return { + requiresReview: true, + success: true, + entityKind: ENTITY_KIND_WORKFLOW, + entityId: workflowId, + entityName: nextName, + workspaceId: workspaceId ?? undefined, + reviewBaseStateHash: currentNameBaseHash, + } + } + + assertAcceptedServerToolReviewBase(context, currentNameBaseHash) + const updatedWorkflow = await applyWorkflowMetadata(workflowId, { name: nextName }) + + return { + success: true, + entityKind: ENTITY_KIND_WORKFLOW, + entityId: workflowId, + entityName: nextName, + workspaceId: updatedWorkflow.workspaceId ?? workspaceId ?? undefined, + } + }, +} + +export { editWorkflowServerTool, editWorkflowBlockServerTool } 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/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<ListGDriveFilesParams, an name: 'list_gdrive_files', async execute(params: ListGDriveFilesParams, context?: ServerToolExecutionContext): Promise<any> { 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<ListGDriveFilesParams, an }, false, undefined, - { signal: context?.signal } + { signal: scopedContext?.signal } ) - throwIfServerToolAborted(context) + throwIfServerToolAborted(scopedContext) if (!result.success) { throw new Error(result.error || 'Failed to list Google Drive files') } @@ -71,7 +72,7 @@ export const listGDriveFilesServerTool: BaseServerTool<ListGDriveFilesParams, an const nextPageToken = output?.nextPageToken || output?.output?.nextPageToken logger.info('Listed Google Drive files', { count: files.length, - workflowId: workflowScope?.workflowId, + workspaceId, }) return { files, total: files.length, nextPageToken } }, 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', diff --git a/apps/tradinggoose/lib/copilot/tools/server/gdrive/read-file.ts b/apps/tradinggoose/lib/copilot/tools/server/gdrive/read-file.ts index c7543a1e8..3d59a316c 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/gdrive/read-file.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/gdrive/read-file.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 ReadGDriveFileParams { - entityId?: string + workspaceId?: string credentialId?: string fileId?: string type?: 'doc' | 'sheet' @@ -24,16 +21,16 @@ export const readGDriveFileServerTool: BaseServerTool<ReadGDriveFileParams, any> name: 'read_gdrive_file', async execute(params: ReadGDriveFileParams, context?: ServerToolExecutionContext): Promise<any> { 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<ReadGDriveFileParams, any> 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<ReadGDriveFileParams, any> { 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<ReadGDriveFileParams, any> }, 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..1e1df60a2 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,197 @@ +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 { 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' import { getQueryStrategy, handleVectorOnlySearch } from '@/app/api/knowledge/search/utils' +import { + buildDocumentEnvelope, + buildSavedEntityListInfo, + type EntityCreateResult, + type EntityDocumentArgs, + type EntityServerTool, + executeCreateEntityDocumentMutation, + executeUpdateEntityDocumentMutation, + readSavedEntityDocumentFields, + requireEntityId, + verifySavedEntityContext, + verifyWorkspaceContext, +} from '../entities/shared' + +const logger = createLogger('KnowledgeBaseServerTools') + +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<string, unknown> = 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<string, unknown>, + context: ServerToolExecutionContext | undefined +): Promise<EntityCreateResult> { + 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 { workspaceId } = await verifyWorkspaceContext(scopedContext, 'read') + const entities = await buildSavedEntityListInfo(ENTITY_KIND_KNOWLEDGE_BASE, workspaceId) + + return { + entityKind: ENTITY_KIND_KNOWLEDGE_BASE, + entities, + count: entities.length, + } + }, +} -const logger = createLogger('KnowledgeBaseServerTool') - -/** - * Knowledge base tool for copilot to create, list, and get knowledge bases - */ -export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, KnowledgeBaseResult> = { - name: 'knowledge_base', - async execute( - params: KnowledgeBaseArgs, - context?: ServerToolExecutionContext - ): Promise<KnowledgeBaseResult> { - 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), + readSavedEntityDocumentFields(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 executeCreateEntityDocumentMutation( + ENTITY_KIND_KNOWLEDGE_BASE, + args, + context, + createKnowledgeBaseEntity + ) + }, +} + +export const editKnowledgeBaseServerTool: EntityServerTool<EntityDocumentArgs> = { + name: 'edit_knowledge_base', + execute(args, context) { + return executeUpdateEntityDocumentMutation( + ENTITY_KIND_KNOWLEDGE_BASE, + 'edit_knowledge_base', + args, + context + ) + }, +} + +export const renameKnowledgeBaseServerTool: EntityServerTool<EntityDocumentArgs> = { + name: 'rename_knowledge_base', + execute(args, context) { + return executeUpdateEntityDocumentMutation( + 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, + })), } }, } 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..7608d16d1 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts @@ -0,0 +1,99 @@ +import { + MONITOR_DOCUMENT_FORMAT, + parseMonitorDocument, + readMonitorDocumentName, + serializeMonitorDocument, +} from '@/lib/copilot/monitor/monitor-documents' +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') + +type EditMonitorArgs = { + monitorId: string + monitorDocument: string + documentFormat?: string +} + +export const editMonitorServerTool: BaseServerTool<EditMonitorArgs> = { + 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 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 nextDocument = serializeMonitorDocument(nextFields) + return { + requiresReview: true, + success: true, + surfaceKind: 'monitor' as const, + workspaceId: row.workflow.workspaceId, + monitorId: args.monitorId, + monitorName: readMonitorDocumentName(nextFields), + documentFormat: MONITOR_DOCUMENT_FORMAT, + monitorDocument: nextDocument, + reviewBaseStateHash, + preview: { + documentDiff: { + before: currentDocument, + after: nextDocument, + }, + }, + } + } + + assertAcceptedServerToolReviewBase(context, reviewBaseStateHash) + const updatedMonitor = (await updateMonitorForUser({ + monitorId: args.monitorId, + userId, + body: { + ...nextFields, + workspaceId: row.workflow.workspaceId, + }, + requestId: crypto.randomUUID(), + logger, + })) as MonitorRecord + + 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 new file mode 100644 index 000000000..e3c5350df --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/monitor/list-monitors.ts @@ -0,0 +1,48 @@ +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 + entityId?: string + blockId?: string +} + +export const listMonitorsServerTool: BaseServerTool<ListMonitorsArgs> = { + 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, + 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 new file mode 100644 index 000000000..fb575f2c6 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/monitor/read-monitor.ts @@ -0,0 +1,38 @@ +import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { + buildMonitorDocumentEnvelope, + type MonitorRecord, +} from '@/lib/copilot/tools/server/monitor/shared' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' +import { getMonitorRowById, toMonitorRecord } from '@/app/api/monitors/shared' + +type ReadMonitorArgs = { + monitorId: string +} + +export const readMonitorServerTool: BaseServerTool<ReadMonitorArgs> = { + 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), workspaceId } + }, +} diff --git a/apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tool-utils.ts b/apps/tradinggoose/lib/copilot/tools/server/monitor/shared.ts similarity index 69% rename from apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tool-utils.ts rename to apps/tradinggoose/lib/copilot/tools/server/monitor/shared.ts index 927a85e86..65ecba1be 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tool-utils.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/monitor/shared.ts @@ -1,23 +1,13 @@ +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' -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 @@ -49,15 +39,6 @@ export type MonitorRecord = { updatedAt: string } -export function readStoredToolArgs<TArgs>(toolCallId: string): TArgs | undefined { - try { - const { toolCallsById } = getCopilotStoreForToolCall(toolCallId).getState() - return toolCallsById[toolCallId]?.params as TArgs | undefined - } catch { - return undefined - } -} - function getListingLabel(listing: Record<string, unknown> | null | undefined): string { if (!listing || typeof listing !== 'object') { return 'listing' @@ -120,17 +101,35 @@ export function toMonitorDocumentFields(record: MonitorRecord) { } } -export async function fetchMonitorById(monitorId: string): Promise<MonitorRecord> { - 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}`) +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), } +} - if (!payload?.data || typeof payload.data !== 'object') { - throw new Error('Invalid monitor response') +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, } - - return payload.data as MonitorRecord } 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<MakeApiRequestParams, any> 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<MakeApiRequestParams, any> 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 new file mode 100644 index 000000000..1191c42ea --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts @@ -0,0 +1,201 @@ +import { db } from '@tradinggoose/db' +import { verification } from '@tradinggoose/db/schema' +import { and, eq, like, lte } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import type { ToolId } from '@/lib/copilot/registry' +import { + assertAcceptedServerToolReviewBase, + 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:' +const REVIEW_TOKEN_TTL_MS = 30 * 60 * 1000 + +type StagedServerToolReview = { + userId?: unknown + toolName?: unknown + encryptedPayload?: unknown + baseStateHash?: unknown + executionContext?: Pick< + ServerToolExecutionContext, + 'contextEntityKind' | 'contextEntityId' | 'workspaceId' + > +} + +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 { reviewBaseStateHash?: unknown } + if (typeof record.reviewBaseStateHash === 'string' && record.reviewBaseStateHash) { + return record.reviewBaseStateHash + } + + throw new Error('Server tool review result is missing base state') +} + +async function deleteExpiredReviewTokens(now = new Date()) { + await db + .delete(verification) + .where( + and( + like(verification.identifier, `${REVIEW_TOKEN_PREFIX}%`), + lte(verification.expiresAt, now) + ) + ) +} + +export async function stageServerManagedToolReview( + toolName: ToolId, + payload: unknown, + 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() + const { reviewBaseStateHash: _reviewBaseStateHash, ...publicResult } = result as Record< + string, + unknown + > + const { contextEntityKind, contextEntityId, workspaceId } = context + const encryptedPayload = (await encryptSecret(JSON.stringify(payload ?? null))).encrypted + await deleteExpiredReviewTokens(now) + await db.insert(verification).values({ + id: nanoid(), + identifier: `${REVIEW_TOKEN_PREFIX}${reviewToken}`, + value: JSON.stringify({ + userId: context.userId, + toolName, + encryptedPayload, + baseStateHash: readBaseStateHash(result), + executionContext: { contextEntityKind, contextEntityId, workspaceId }, + }), + expiresAt: new Date(now.getTime() + REVIEW_TOKEN_TTL_MS), + createdAt: now, + updatedAt: now, + }) + + return { + ...publicResult, + reviewToken, + } +} + +export async function acceptServerManagedToolReview( + toolName: ToolId, + reviewToken: string, + context?: ServerToolExecutionContext +) { + if (!context?.userId) { + throw new Error('Authenticated user is required to accept server tool review') + } + + await deleteExpiredReviewTokens() + + const [row] = await db + .select({ + value: verification.value, + }) + .from(verification) + .where(eq(verification.identifier, `${REVIEW_TOKEN_PREFIX}${reviewToken}`)) + .limit(1) + + if (!row) { + throw new Error('Server tool review token is invalid or expired') + } + + let staged: StagedServerToolReview + try { + staged = JSON.parse(row.value) as StagedServerToolReview + } catch { + throw new Error('Server tool review token is invalid or expired') + } + if ( + !staged || + staged.userId !== context.userId || + staged.toolName !== toolName || + typeof staged.baseStateHash !== 'string' + ) { + throw new Error('Server tool review token does not match this request') + } + + 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') + } + 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 = { + ...staged.executionContext, + userId: context.userId, + signal: context.signal, + } + const currentReview = await routeExecution(toolName, payload, { + ...executionContext, + accessLevel: 'limited', + }) + assertAcceptedServerToolReviewBase( + { ...executionContext, acceptedReviewBaseStateHash: staged.baseStateHash }, + readBaseStateHash(currentReview) + ) + + 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, claimValue))) + .returning({ id: verification.id }) + if (!deletedRow) { + throw new Error('Server tool review token is invalid or expired') + } + + return result +} diff --git a/apps/tradinggoose/lib/copilot/tools/server/router.test.ts b/apps/tradinggoose/lib/copilot/tools/server/router.test.ts index 43ea96079..710de31f1 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, @@ -32,9 +36,23 @@ 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', +})) +const noopEntityExecute = vi.fn(async () => ({})) +const checkWorkspaceAccess = vi.hoisted(() => vi.fn()) + +const entityTool = (name: string, execute = noopEntityExecute) => ({ name, execute }) vi.mock('@/lib/copilot/tools/server/blocks/get-available-blocks', () => ({ getAvailableBlocksServerTool: { @@ -79,9 +97,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', () => ({ @@ -114,6 +152,35 @@ vi.mock('@/lib/copilot/tools/server/user/set-environment-variables', () => ({ execute: setEnvironmentVariablesExecute, }, })) +vi.mock('@/lib/copilot/tools/server/entities', () => ({ + createCustomToolServerTool: entityTool('create_custom_tool'), + createIndicatorServerTool: entityTool('create_indicator'), + createMcpServerServerTool: entityTool('create_mcp_server'), + createSkillServerTool: entityTool('create_skill'), + createWorkflowServerTool: entityTool('create_workflow'), + editCustomToolServerTool: entityTool('edit_custom_tool'), + editIndicatorServerTool: entityTool('edit_indicator'), + editMcpServerServerTool: entityTool('edit_mcp_server'), + editSkillServerTool: entityTool('edit_skill'), + editWorkflowBlockServerTool: entityTool('edit_workflow_block'), + editWorkflowServerTool: entityTool('edit_workflow', editWorkflowExecute), + editWorkflowVariableServerTool: entityTool('edit_workflow_variable'), + listCustomToolsServerTool: entityTool('list_custom_tools'), + listIndicatorsServerTool: entityTool('list_indicators'), + listMcpServersServerTool: entityTool('list_mcp_servers'), + listSkillsServerTool: entityTool('list_skills'), + listWorkflowsServerTool: entityTool('list_workflows'), + readCustomToolServerTool: entityTool('read_custom_tool'), + readIndicatorServerTool: entityTool('read_indicator'), + readMcpServerServerTool: entityTool('read_mcp_server'), + readSkillServerTool: entityTool('read_skill'), + readWorkflowServerTool: entityTool('read_workflow'), + renameCustomToolServerTool: entityTool('rename_custom_tool'), + renameIndicatorServerTool: entityTool('rename_indicator'), + renameMcpServerServerTool: entityTool('rename_mcp_server'), + renameSkillServerTool: entityTool('rename_skill'), + renameWorkflowServerTool: entityTool('rename_workflow'), +})) vi.mock('@/lib/copilot/tools/server/workflow/edit-workflow', () => ({ editWorkflowServerTool: { name: 'edit_workflow', execute: editWorkflowExecute }, })) @@ -123,14 +190,18 @@ vi.mock('@/lib/copilot/tools/server/workflow/read-workflow-logs', () => ({ execute: readWorkflowLogsExecute, }, })) +vi.mock('@/lib/permissions/utils', () => ({ + checkWorkspaceAccess, +})) let getToolContract: typeof import('@/lib/copilot/registry').getToolContract let isToolId: typeof import('@/lib/copilot/registry').isToolId +let getMcpServerToolIds: typeof import('@/lib/copilot/tools/server/router').getMcpServerToolIds let routeExecution: typeof import('@/lib/copilot/tools/server/router').routeExecution beforeAll(async () => { ;({ getToolContract, isToolId } = await import('@/lib/copilot/registry')) - ;({ routeExecution } = await import('@/lib/copilot/tools/server/router')) + ;({ getMcpServerToolIds, routeExecution } = await import('@/lib/copilot/tools/server/router')) }, 30000) beforeEach(() => { @@ -145,6 +216,14 @@ beforeEach(() => { readEnvironmentVariablesExecute.mockClear() readOAuthCredentialsExecute.mockClear() setEnvironmentVariablesExecute.mockClear() + noopEntityExecute.mockClear() + checkWorkspaceAccess.mockReset() + checkWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + workspace: { id: 'workspace-1' }, + }) }) describe('copilot contract registry', () => { @@ -158,6 +237,34 @@ describe('copilot contract registry', () => { expect(getToolContract('unknown_tool')).toBeUndefined() }) + 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') + }) + + 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') @@ -173,12 +280,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 +294,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 +320,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,15 +364,88 @@ 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 }) + }) }) describe('routeExecution', () => { @@ -293,6 +477,26 @@ describe('routeExecution', () => { }) }) + it('rejects inaccessible workspace context before invoking the tool', async () => { + checkWorkspaceAccess.mockResolvedValueOnce({ + exists: true, + hasAccess: false, + canWrite: false, + workspace: { id: 'workspace-denied' }, + }) + + await expect( + routeExecution( + 'list_workflows', + { workspaceId: 'workspace-denied' }, + { userId: 'user-1', accessLevel: 'full' } + ) + ).rejects.toThrow('Access denied: You do not have permission to use this workspace') + + expect(checkWorkspaceAccess).toHaveBeenCalledWith('workspace-denied', 'user-1') + expect(noopEntityExecute).not.toHaveBeenCalled() + }) + it('routes indicator catalog requests through the central contract', async () => { await expect( routeExecution('get_indicator_catalog', { query: 'input', includeItems: true }) @@ -311,8 +515,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 +525,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 +549,6 @@ describe('routeExecution', () => { const payload = { entityDocument: 'flowchart TD\n n1["Input<br/>id: input1<br/>type: input_trigger"]', entityId: 'workflow-123', - currentWorkflowState: '{"blocks":{}}', } await expect(routeExecution('edit_workflow', payload)).resolves.toMatchObject({ @@ -370,48 +575,58 @@ 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-scoped writes', 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({ - variableNames: expect.any(Array), - count: expect.any(Number), + await expect( + routeExecution( + 'set_environment_variables', + { scope: 'workspace', variables: { API_KEY: 'secret' } }, + context + ) + ).resolves.toMatchObject({ + message: 'ok', }) - expect(readEnvironmentVariablesExecute).toHaveBeenCalledWith({}, context) + expect(setEnvironmentVariablesExecute).toHaveBeenCalledWith( + { scope: 'workspace', variables: { API_KEY: 'secret' }, workspaceId: 'workspace-1' }, + context + ) }) it.each([ { toolName: 'read_environment_variables', - payload: { entityId: 'workflow-123' }, + payload: { scope: 'workspace', workspaceId: 'workspace-123' }, execute: readEnvironmentVariablesExecute, }, { toolName: 'set_environment_variables', - payload: { entityId: 'workflow-123', variables: { API_KEY: 'secret' } }, + payload: { + scope: 'workspace', + workspaceId: 'workspace-123', + variables: { API_KEY: 'secret' }, + }, execute: setEnvironmentVariablesExecute, }, { toolName: 'read_credentials', - payload: { entityId: 'workflow-123' }, + payload: { scope: 'workspace', 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 +636,7 @@ describe('routeExecution', () => { { toolName: 'read_gdrive_file', payload: { - entityId: 'workflow-123', + workspaceId: 'workspace-123', credentialId: 'credential-1', fileId: 'file-1', type: 'doc', @@ -430,15 +645,39 @@ describe('routeExecution', () => { }, { toolName: 'read_oauth_credentials', - payload: { entityId: 'workflow-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, }, ])( - '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..74272dd5a 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/router.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/router.ts @@ -9,27 +9,74 @@ 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' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' const logger = createLogger('ServerToolRouter') const serverToolRegistry: Partial<Record<ToolId, BaseServerTool<any, any>>> = { [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,48 +87,157 @@ const serverToolRegistry: Partial<Record<ToolId, BaseServerTool<any, any>>> = { [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<BaseServerTool<any, any> | null> { - if (toolName === CopilotTool.get_available_blocks) { +const lazyServerToolLoaders: Partial<Record<ToolId, () => Promise<BaseServerTool<any, any>>>> = { + [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 - } + }, +} + +const mcpServerToolIds = [ + editWorkflowServerTool.name, + editWorkflowBlockServerTool.name, + editWorkflowVariableServerTool.name, + createWorkflowServerTool.name, + renameWorkflowServerTool.name, + readWorkflowServerTool.name, + listWorkflowsServerTool.name, + readWorkflowLogsServerTool.name, + searchDocumentationServerTool.name, + searchOnlineServerTool.name, + readEnvironmentVariablesServerTool.name, + setEnvironmentVariablesServerTool.name, + listGDriveFilesServerTool.name, + readGDriveFileServerTool.name, + readOAuthCredentialsServerTool.name, + readCredentialsServerTool.name, + listKnowledgeBasesServerTool.name, + readKnowledgeBaseServerTool.name, + createKnowledgeBaseServerTool.name, + editKnowledgeBaseServerTool.name, + renameKnowledgeBaseServerTool.name, + queryKnowledgeBaseServerTool.name, + listMonitorsServerTool.name, + readMonitorServerTool.name, + editMonitorServerTool.name, + checkDeploymentStatusServerTool.name, + readBlockOutputsServerTool.name, + readBlockUpstreamReferencesServerTool.name, + listSkillsServerTool.name, + readSkillServerTool.name, + createSkillServerTool.name, + editSkillServerTool.name, + renameSkillServerTool.name, + listCustomToolsServerTool.name, + readCustomToolServerTool.name, + createCustomToolServerTool.name, + editCustomToolServerTool.name, + renameCustomToolServerTool.name, + listIndicatorsServerTool.name, + readIndicatorServerTool.name, + createIndicatorServerTool.name, + editIndicatorServerTool.name, + renameIndicatorServerTool.name, + listMcpServersServerTool.name, + readMcpServerServerTool.name, + createMcpServerServerTool.name, + editMcpServerServerTool.name, + renameMcpServerServerTool.name, + CopilotTool.get_available_blocks, + CopilotTool.get_blocks_metadata, + CopilotTool.get_agent_accessory_catalog, + CopilotTool.get_indicator_catalog, + CopilotTool.get_indicator_metadata, +] satisfies ToolId[] + +export function getServerToolIds(): ToolId[] { + return [ + ...(Object.keys(serverToolRegistry) as ToolId[]), + ...(Object.keys(lazyServerToolLoaders) as ToolId[]), + ] +} + +export function getMcpServerToolIds(): ToolId[] { + return [...mcpServerToolIds] +} +async function resolveServerTool(toolName: ToolId): Promise<BaseServerTool<any, any> | null> { + const lazyTool = lazyServerToolLoaders[toolName] + if (lazyTool) return lazyTool() return serverToolRegistry[toolName] ?? null } +async function assertWorkspaceAccess(context?: ServerToolExecutionContext): Promise<void> { + if (!context?.workspaceId) { + return + } + + const access = await checkWorkspaceAccess(context.workspaceId, context.userId) + if (!access.exists || !access.hasAccess) { + throw new Error('Access denied: You do not have permission to use this workspace') + } +} + export async function routeExecution( toolName: string, payload: unknown, @@ -101,22 +257,35 @@ export async function routeExecution( throw new Error(`Unknown server tool: ${toolName}`) } - logger.debug('Routing to tool', { - toolName, - payloadPreview: (() => { + logger.debug('Routing to tool', { toolName }) + + 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<string, unknown> | null | undefined) ?? {}), + workspaceId: context.workspaceId, + } try { - return JSON.stringify(payload).slice(0, 200) + args = ServerToolArgSchemas[toolName].parse(payloadWithContextWorkspace) } catch { - return undefined + throw error } - })(), - }) - - const args = ServerToolArgSchemas[toolName].parse(payload ?? {}) - throwIfServerToolAborted(context) + } else { + throw error + } + } + const executionContext = withWorkspaceArgContext(context, args) + throwIfServerToolAborted(executionContext) + await assertWorkspaceAccess(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..fd77ad797 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/user/read-credentials.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/user/read-credentials.ts @@ -3,19 +3,19 @@ import type { BaseServerTool, ServerToolExecutionContext, } from '@/lib/copilot/tools/server/base-tool' +import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' import { - createWorkflowPermissionError, - resolveServerWorkspaceId, - resolveServerWorkflowScope, -} from '@/lib/copilot/tools/server/workflow/workflow-scope' -import { listOAuthCredentialsForUser } from '@/lib/credentials/oauth' + 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 { - entityId?: string -} +type ReadCredentialsParams = + | { scope: 'personal' } + | { scope: 'workspace'; workspaceId: string } export const readCredentialsServerTool: BaseServerTool<ReadCredentialsParams, any> = { name: CopilotTool.read_credentials, @@ -23,28 +23,24 @@ export const readCredentialsServerTool: BaseServerTool<ReadCredentialsParams, an const logger = createLogger('ReadCredentialsServerTool') if (!context?.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 = 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') + } } - const workspaceId = resolveServerWorkspaceId(context, workflowScope) - - const userId = authenticatedUserId logger.info('Fetching credentials for authenticated user', { userId, - workflowId: workflowScope?.workflowId, + scope: params.scope, workspaceId, }) @@ -60,12 +56,15 @@ export const readCredentialsServerTool: BaseServerTool<ReadCredentialsParams, an // Track connected provider IDs const connectedProviderIds = new Set<string>() - 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 b5bea606e..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 @@ -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,38 @@ describe('readEnvironmentVariablesServerTool', () => { vi.clearAllMocks() }) - it('uses ambient current-workflow context to include workspace variables', async () => { - mocks.verifyWorkflowAccess.mockResolvedValue({ + 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, hasAccess: true, - workspaceId: 'workspace-1', + canWrite: true, + workspace: { id: 'workspace-1' }, }) mocks.getPersonalAndWorkspaceEnv.mockResolvedValue({ personalEncrypted: { PERSONAL_KEY: 'encrypted-1' }, @@ -43,20 +69,20 @@ describe('readEnvironmentVariablesServerTool', () => { await expect( readEnvironmentVariablesServerTool.execute( - {}, + { scope: 'workspace', workspaceId: 'workspace-1' }, { userId: 'auth-user', - contextEntityKind: 'workflow', - contextEntityId: 'workflow-1', } ) ).resolves.toEqual({ variableNames: ['PERSONAL_KEY', 'WORKSPACE_KEY'], + personalVariableNames: ['PERSONAL_KEY'], + workspaceVariableNames: ['WORKSPACE_KEY'], + conflicts: [], 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..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 @@ -3,17 +3,14 @@ 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 -} +type ReadEnvironmentVariablesParams = + | { scope: 'personal' } + | { scope: 'workspace'; workspaceId: string } export const readEnvironmentVariablesServerTool: BaseServerTool< ReadEnvironmentVariablesParams, @@ -27,57 +24,41 @@ export const readEnvironmentVariablesServerTool: BaseServerTool< const logger = createLogger('ReadEnvironmentVariablesServerTool') if (!context?.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 = 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') + } } - const userId = authenticatedUserId - const workspaceId = resolveServerWorkspaceId(context, workflowScope) - logger.info('Reading environment variables for authenticated user', { userId, - workflowId: workflowScope?.workflowId, + scope: params.scope, 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 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: result.variableNames, - count: result.count, + variableNames, + personalVariableNames, + workspaceVariableNames, + conflicts: envResult.conflicts, + 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..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 @@ -3,17 +3,17 @@ import type { BaseServerTool, ServerToolExecutionContext, } from '@/lib/copilot/tools/server/base-tool' +import { withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' import { - createWorkflowPermissionError, - resolveServerWorkspaceId, - resolveServerWorkflowScope, -} from '@/lib/copilot/tools/server/workflow/workflow-scope' -import { listOAuthCredentialsForUser } from '@/lib/credentials/oauth' + listOAuthConnectionsForUser, + listOAuthCredentialsForUser, +} from '@/lib/credentials/oauth' import { createLogger } from '@/lib/logs/console/logger' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' -interface ReadOAuthCredentialsParams { - entityId?: string -} +type ReadOAuthCredentialsParams = + | { scope: 'personal' } + | { scope: 'workspace'; workspaceId: string } export const readOAuthCredentialsServerTool: BaseServerTool<ReadOAuthCredentialsParams, any> = { name: CopilotTool.read_oauth_credentials, @@ -24,30 +24,27 @@ export const readOAuthCredentialsServerTool: BaseServerTool<ReadOAuthCredentials const logger = createLogger('ReadOAuthCredentialsServerTool') if (!context?.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 = 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 userId = authenticatedUserId - const workspaceId = resolveServerWorkspaceId(context, workflowScope) + 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') + } 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..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 @@ -3,41 +3,128 @@ import { environmentVariables } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' import { z } from 'zod' import { + assertAcceptedServerToolReviewBase, type BaseServerTool, + hashServerToolReviewBase, type ServerToolExecutionContext, + shouldStageServerToolMutationForReview, throwIfServerToolAborted, + withWorkspaceArgContext, } 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 { checkWorkspaceAccess } from '@/lib/permissions/utils' import { encryptSecret } from '@/lib/utils-server' -interface SetEnvironmentVariablesParams { - variables: Record<string, any> | Array<{ name: string; value: string }> - entityId?: string -} +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<typeof EnvVarSchema> -const EnvVarSchema = z.object({ variables: z.record(z.string()) }) +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)), + }) +} -function normalizeEnvVarInput( - input: Record<string, any> | Array<{ name: string; value: string }> -): Record<string, string> { - 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<string, 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( + 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)) + const updated = variableNames.filter((key) => existingKeySet.has(key)) + + return { existingRows, existingValueByKey, added, updated } +} + +function buildEnvironmentVariablesResult( + scope: 'personal' | 'workspace', + workspaceId: string | undefined, + variableNames: string[], + summary: Awaited<ReturnType<typeof readEnvironmentVariableSummary>>, + 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, + totalVariableCount: summary.existingRows.length + summary.added.length, + addedVariables: summary.added, + updatedVariables: summary.updated, } - return Object.fromEntries( - Object.entries(input || {}).map(([k, v]) => [k, String(v ?? '')]) - ) as Record<string, string> +} + +async function encryptEnvironmentVariables( + variables: Record<string, string>, + context?: ServerToolExecutionContext +): Promise<Record<string, string>> { + const encryptedVariables: Record<string, string> = {} + for (const [key, value] of Object.entries(variables)) { + throwIfServerToolAborted(context) + encryptedVariables[key] = (await encryptSecret(value)).encrypted + } + return encryptedVariables +} + +async function writeEncryptedEnvironmentVariables( + scope: 'personal' | 'workspace', + targetId: string, + encryptedVariables: Record<string, string>, + 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(), + ...(scope === 'workspace' ? { workspaceId: targetId } : { userId: targetId }), + key, + value: encrypted, + }) + .onConflictDoUpdate({ + target: + scope === 'workspace' + ? [environmentVariables.workspaceId, environmentVariables.key] + : [environmentVariables.userId, environmentVariables.key], + set: { + value: encrypted, + updatedAt: new Date(), + }, + }) + } + }) } export const setEnvironmentVariablesServerTool: BaseServerTool<SetEnvironmentVariablesParams, any> = @@ -47,74 +134,74 @@ export const setEnvironmentVariablesServerTool: BaseServerTool<SetEnvironmentVar params: SetEnvironmentVariablesParams, context?: ServerToolExecutionContext ): Promise<any> { - const logger = createLogger('SetEnvironmentVariablesServerTool') - - if (!context?.userId) { - logger.error( - 'Unauthorized attempt to set environment variables - no authenticated user context' - ) + const parsedPayload = EnvVarSchema.parse(params) + const scopedContext = + parsedPayload.scope === 'workspace' + ? withWorkspaceArgContext(context, parsedPayload) + : context + if (!scopedContext?.userId) { throw new Error('Authentication required') } - const authenticatedUserId = context.userId - const { variables } = params || ({} as SetEnvironmentVariablesParams) - - const workflowScope = await resolveServerWorkflowScope(params, context) - if (workflowScope && !workflowScope.hasAccess) { - const errorMessage = createWorkflowPermissionError('modify environment variables in') - logger.error('Unauthorized attempt to set environment variables', { - workflowId: workflowScope.workflowId, - authenticatedUserId, - }) - throw new Error(errorMessage) + const userId = scopedContext.userId + 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 userId = authenticatedUserId + const variableNames = Object.keys(parsedPayload.variables).sort() + throwIfServerToolAborted(scopedContext) - const normalized = normalizeEnvVarInput(variables || {}) - const { variables: validatedVariables } = EnvVarSchema.parse({ variables: normalized }) - const variableEntries = Object.entries(validatedVariables) - throwIfServerToolAborted(context) - - const existingRows = await db - .select({ key: environmentVariables.key }) - .from(environmentVariables) - .where(eq(environmentVariables.userId, userId)) - - const existingKeySet = new Set(existingRows.map((row) => row.key)) - 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( + parsedPayload.scope, + targetId, + variableNames + ) + const reviewBaseStateHash = hashEnvironmentVariableBase( + parsedPayload.scope, + targetId, + 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(), - }, - }) + if (shouldStageServerToolMutationForReview(scopedContext)) { + return { + requiresReview: true, + ...buildEnvironmentVariablesResult( + parsedPayload.scope, + workspaceId, + variableNames, + summary, + 'Review required for' + ), + reviewBaseStateHash, } - }) - - 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, } + + assertAcceptedServerToolReviewBase(scopedContext, reviewBaseStateHash) + const encryptedVariables = await encryptEnvironmentVariables( + parsedPayload.variables, + scopedContext + ) + await writeEncryptedEnvironmentVariables( + parsedPayload.scope, + targetId, + encryptedVariables, + scopedContext + ) + return buildEnvironmentVariablesResult( + parsedPayload.scope, + workspaceId, + variableNames, + summary, + 'Successfully processed' + ) }, } diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/block-output-tools.test.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/block-output-tools.test.ts new file mode 100644 index 000000000..e023cd9f7 --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/block-output-tools.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ToolResultSchemas } from '@/lib/copilot/registry' +import { readBlockOutputsServerTool } from '@/lib/copilot/tools/server/workflow/read-block-outputs' +import { readBlockUpstreamReferencesServerTool } from '@/lib/copilot/tools/server/workflow/read-block-upstream-references' + +const mockLoadWorkflowSnapshotForCopilot = vi.fn() + +vi.mock('@/lib/copilot/tools/server/entities/workflow', () => ({ + loadWorkflowSnapshotForCopilot: (...args: unknown[]) => + mockLoadWorkflowSnapshotForCopilot(...args), +})) + +vi.mock('@/blocks', () => ({ + getBlock: (blockType: string) => { + const registry: Record<string, any> = { + 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..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 @@ -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,9 +71,8 @@ describe('editWorkflowBlockServerTool', () => { subBlocks: { code: 'return { rsi: 50 }', }, - currentWorkflowState: CURRENT_WORKFLOW_STATE, }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) expect(result.workflowState.blocks.fn1.name).toBe('Compute Market Indicators') @@ -77,9 +91,8 @@ describe('editWorkflowBlockServerTool', () => { subBlocks: { madeUpField: 'bad', }, - currentWorkflowState: CURRENT_WORKFLOW_STATE, }, - { 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 30d27c38d..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 @@ -1,11 +1,22 @@ 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 { + serializeWorkflowToTgMermaid, + TG_MERMAID_DOCUMENT_FORMAT, +} from '@/lib/workflows/studio-workflow-mermaid' 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 @@ -14,7 +25,6 @@ interface EditWorkflowBlockParams { name?: string enabled?: boolean subBlocks?: Record<string, unknown> - currentWorkflowState: string } function normalizeOptionalString(value?: string): string | undefined { @@ -41,9 +51,12 @@ function throwInvalidBlockEdit(input: { export const editWorkflowBlockServerTool: BaseServerTool<EditWorkflowBlockParams, any> = { name: 'edit_workflow_block', - async execute(params: EditWorkflowBlockParams): Promise<any> { + async execute( + params: EditWorkflowBlockParams, + context?: ServerToolExecutionContext + ): Promise<any> { 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 +92,7 @@ export const editWorkflowBlockServerTool: BaseServerTool<EditWorkflowBlockParams subBlockCount: Object.keys(nextSubBlocks).length, }) - const baseWorkflowState = await loadBaseWorkflowState(workflowId, currentWorkflowState) + const baseWorkflowState = await loadBaseWorkflowState(workflowId, context) const currentBlock = baseWorkflowState.blocks[blockId] if (!currentBlock) { @@ -171,12 +184,14 @@ export const editWorkflowBlockServerTool: BaseServerTool<EditWorkflowBlockParams }) try { - return buildWorkflowMutationResult({ + const result = buildWorkflowMutationResult({ workflowId, baseWorkflowState, nextWorkflowState, - requestedDirection: baseWorkflowState.direction, + renderEntityDocument: serializeWorkflowToTgMermaid, + documentFormat: TG_MERMAID_DOCUMENT_FORMAT, }) + return resolveWorkflowMutationResultForExecution(result, context) } catch (error) { const message = error instanceof Error ? error.message : String(error) if (!message.startsWith('Invalid edited workflow:')) { diff --git a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.test.ts b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.test.ts index 4b695297d..de5fe3f12 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.test.ts @@ -1,7 +1,17 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow' import { WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' +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, @@ -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,9 +80,8 @@ describe('editWorkflowServerTool', () => { ' n2["Compute Indicators<br/>id: fn1<br/>type: function"]', ' n1 --> n2', ]), - currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) expect(result.workflowState.blocks.fn1.name).toBe('Compute Indicators') @@ -84,6 +98,45 @@ describe('editWorkflowServerTool', () => { expect(result.entityDocument).toContain('Compute Indicators') }) + it('re-lays out existing blocks when the graph direction changes', async () => { + mockLoadBaseWorkflowState.mockResolvedValueOnce({ + ...BASE_WORKFLOW_STATE, + direction: 'LR', + blocks: { + input1: { + ...BASE_WORKFLOW_STATE.blocks.input1, + horizontalHandles: true, + position: { x: 0, y: 0 }, + }, + fn1: { + ...BASE_WORKFLOW_STATE.blocks.fn1, + horizontalHandles: true, + position: { x: 550, y: 0 }, + }, + }, + }) + + const result = await editWorkflowServerTool.execute( + { + entityId: 'wf-1', + entityDocument: graph([ + 'flowchart TD', + ' n1["Input Form<br/>id: input1<br/>type: input_trigger"]', + ' n2["Compute Indicators<br/>id: fn1<br/>type: function"]', + ' n1 --> n2', + ]), + }, + { userId: 'user-1', accessLevel: 'limited' } + ) + + expect(result.workflowState.direction).toBe('TD') + expect(result.workflowState.blocks.input1.horizontalHandles).toBe(false) + expect(result.workflowState.blocks.fn1.horizontalHandles).toBe(false) + expect(result.workflowState.blocks.fn1.position.y).toBeGreaterThan( + result.workflowState.blocks.input1.position.y + ) + }) + it('rejects existing block label renames instead of ignoring them', async () => { await expect( editWorkflowServerTool.execute( @@ -95,9 +148,8 @@ describe('editWorkflowServerTool', () => { ' n2["Compute<br/>id: fn1<br/>type: function"]', ' n1 --> n2', ]), - currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) ).rejects.toThrow('Use edit_workflow_block to rename existing blocks.') @@ -111,9 +163,8 @@ describe('editWorkflowServerTool', () => { ' fn1["Compute"]', ' input1 --> fn1', ]), - currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) ).rejects.toThrow('Use edit_workflow_block to rename existing blocks.') }) @@ -129,9 +180,8 @@ describe('editWorkflowServerTool', () => { ' n2["Compute<br/>id: fn1<br/>type: agent"]', ' n1 --> n2', ]), - currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, - { 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.' @@ -139,6 +189,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,12 +203,8 @@ describe('editWorkflowServerTool', () => { ' n2["id: fn2<br/>type: function"]', ' n1 --> n2', ]), - currentWorkflowState: JSON.stringify({ - ...BASE_WORKFLOW_STATE, - blocks: { input1: BASE_WORKFLOW_STATE.blocks.input1 }, - }), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) expect(result.workflowState.blocks.fn2).toMatchObject({ @@ -183,44 +234,44 @@ describe('editWorkflowServerTool', () => { ' n1["Input Form<br/>id: input1<br/>type: input_trigger"]', ' n3["Compute Indicators<br/>id: fn1<br/>type: function"]', ]), - currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) expect(result.workflowState.blocks.fn2.position).toEqual({ x: 0, y: 360 }) }) 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', entityDocument: graph([ - 'flowchart LR', + 'flowchart TD', ' subgraph sg_loop1["Loop<br/>id: loop1<br/>type: loop"]', ' n1["Compute Indicators<br/>id: fn1<br/>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' } + { userId: 'user-1', accessLevel: 'limited' } ) expect(result.workflowState.blocks.fn1.data).toMatchObject({ @@ -239,9 +290,8 @@ describe('editWorkflowServerTool', () => { 'flowchart TD', ' n1["Input Form<br/>id: input1<br/>type: input_trigger<br/>enabled: false<br/>outputs: {}<br/>data.foo: bar<br/>subBlocks.code: return 1"]', ]), - currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, - { 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).' @@ -257,9 +307,8 @@ describe('editWorkflowServerTool', () => { 'flowchart TD', ' n1["Input Form<br/>id: input1<br/>type: input_trigger"]', ]), - currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) ).rejects.toThrow( 'Existing block ids omitted from edit_workflow entityDocument without removedBlockIds: fn1' @@ -267,32 +316,33 @@ 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' } + { userId: 'user-1', accessLevel: 'limited' } ) expect(result.workflowState.blocks).toHaveProperty('input1') @@ -312,9 +362,8 @@ describe('editWorkflowServerTool', () => { ' n2["Compute<br/>id: fn1<br/>type: function"]', ]), removedBlockIds: ['fn1'], - currentWorkflowState: JSON.stringify(BASE_WORKFLOW_STATE), }, - { userId: 'user-1' } + { userId: 'user-1', accessLevel: 'limited' } ) ).rejects.toThrow('removedBlockIds still appear in edit_workflow entityDocument: fn1') }) @@ -329,9 +378,8 @@ 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' } + { 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 ef63d8d94..bffdc890e 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/edit-workflow.ts @@ -1,22 +1,32 @@ 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 { applyAutoLayout } from '@/lib/workflows/autolayout' import { resolveBlockRuntimeState } from '@/lib/workflows/block-outputs' import { WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' -import { parseGraphOnlyWorkflowMermaid } from '@/lib/workflows/studio-workflow-mermaid' +import { + parseGraphOnlyWorkflowMermaid, + serializeWorkflowToGraphMermaid, +} from '@/lib/workflows/studio-workflow-mermaid' import { buildInitialSubBlockStates } from '@/lib/workflows/subblock-values' import { getAbsoluteBlockPosition } from '@/lib/workflows/workflow-direction' import { createWorkflowSnapshot, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' 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 entityDocument: string removedBlockIds?: string[] - currentWorkflowState: string } function buildStableEdgeId(edge: { @@ -136,6 +146,22 @@ function setParent( return { ...block, position: nextPosition, data: nextData } } +function applyWorkflowDirectionLayout( + blocks: Record<string, BlockState>, + edges: WorkflowSnapshot['edges'], + direction: 'TD' | 'LR' +): Record<string, BlockState> { + const horizontalHandles = direction === 'LR' + const orientedBlocks = Object.fromEntries( + Object.entries(blocks).map(([blockId, block]) => [blockId, { ...block, horizontalHandles }]) + ) + const layout = applyAutoLayout(orientedBlocks, edges) + if (!layout.success) { + throw new Error(`Invalid edited workflow: failed to apply ${direction} layout.`) + } + return layout.blocks +} + function applyGraphMermaidToWorkflow( baseWorkflowState: WorkflowSnapshot, entityDocument: string, @@ -225,22 +251,26 @@ function applyGraphMermaidToWorkflow( type: 'default', data: {}, })) + const graphDirectionChanged = graph.direction !== (baseWorkflowState.direction ?? 'TD') + const finalBlocks = graphDirectionChanged + ? applyWorkflowDirectionLayout(blocks, edges, graph.direction) + : blocks return createWorkflowSnapshot({ ...baseWorkflowState, direction: graph.direction, - blocks, + blocks: finalBlocks, edges, - loops: generateLoopBlocks(blocks), - parallels: generateParallelBlocks(blocks), + loops: generateLoopBlocks(finalBlocks), + parallels: generateParallelBlocks(finalBlocks), }) as WorkflowSnapshot & { direction: 'TD' | 'LR' } } export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, any> = { name: 'edit_workflow', - async execute(params: EditWorkflowParams): Promise<any> { + async execute(params: EditWorkflowParams, context?: ServerToolExecutionContext): Promise<any> { 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 +282,7 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, any> = { documentLength: entityDocument.length, }) - const baseWorkflowState = await loadBaseWorkflowState(workflowId, currentWorkflowState) + const baseWorkflowState = await loadBaseWorkflowState(workflowId, context) const nextWorkflowState = applyGraphMermaidToWorkflow( baseWorkflowState, entityDocument, @@ -262,17 +292,17 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, any> = { workflowId, baseWorkflowState, nextWorkflowState, - requestedDirection: nextWorkflowState.direction, + renderEntityDocument: serializeWorkflowToGraphMermaid, 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, warningCount: result.preview?.warnings.length ?? 0, }) - return result + return resolveWorkflowMutationResultForExecution(result, context) }, } 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<string>() + const containingParallelIds = new Set<string>() + + Object.values(loops as Record<string, Loop>).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<string, Parallel>).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<string>(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..1b3b6f77b --- /dev/null +++ b/apps/tradinggoose/lib/copilot/tools/server/workflow/workflow-mutation-utils.test.ts @@ -0,0 +1,120 @@ +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 { setVariables, 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, + variables: Record<string, any> = {} +): 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() + } +} + +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, { + token: { id: 'token', name: 'token', value: 'secret' }, + }), + 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') + expect(result.variables.token).toMatchObject({ + id: 'token', + name: 'token', + value: 'secret', + }) + }) + + 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..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 @@ -1,44 +1,152 @@ -import { findIntroducedNonCanonicalSubBlocks } from '@/lib/workflows/block-config-canonicalization' -import { WORKFLOW_GRAPH_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' +import * as Y from 'yjs' import { - buildWorkflowDocumentPreviewDiff, - serializeWorkflowToGraphMermaid, - serializeWorkflowToTgMermaid, - TG_MERMAID_DOCUMENT_FORMAT, -} from '@/lib/workflows/studio-workflow-mermaid' + assertAcceptedServerToolReviewBase, + hashServerToolReviewBase, + 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 { validateWorkflowState } from '@/lib/workflows/validation' -import { normalizeWorkflowStateToMermaidDirection } from '@/lib/workflows/workflow-direction' -import { createWorkflowSnapshot, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' -import type { WorkflowDirection } from '@/stores/workflows/workflow/types' +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' -function parseCurrentWorkflowState(currentWorkflowState: string): WorkflowSnapshot { - try { - return createWorkflowSnapshot(JSON.parse(currentWorkflowState)) - } catch { - throw new Error('Invalid currentWorkflowState format') +function buildWorkflowDocumentPreviewDiff( + currentWorkflowState: WorkflowSnapshot | undefined, + nextWorkflowState: WorkflowSnapshot +): { + blockDiff: { added: string[]; removed: string[]; updated: string[] } + edgeDiff: { + added: Array< + Pick<WorkflowSnapshot['edges'][number], 'source' | 'target' | 'sourceHandle' | 'targetHandle'> + > + removed: Array< + Pick<WorkflowSnapshot['edges'][number], 'source' | 'target' | 'sourceHandle' | 'targetHandle'> + > + } + 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, - currentWorkflowState: string -): Promise<WorkflowSnapshot> { - if (!currentWorkflowState) { + context?: ServerToolExecutionContext +): Promise<WorkflowSnapshot & { variables: Record<string, any> }> { + 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)), + variables: getVariablesSnapshot(doc), + } + } finally { + doc.destroy() + } } export function buildWorkflowMutationResult(params: { workflowId: string - baseWorkflowState: WorkflowSnapshot + baseWorkflowState: WorkflowSnapshot & { variables: Record<string, any> } 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 @@ -53,34 +161,24 @@ export function buildWorkflowMutationResult(params: { throw new Error(`Invalid edited workflow: ${validation.errors.join('; ')}`) } - let finalWorkflowState = createWorkflowSnapshot( + const finalWorkflowState = createWorkflowSnapshot( (validation.sanitizedState as Partial<WorkflowSnapshot> | 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, success: true, entityKind: 'workflow' as const, entityId: workflowId, entityDocument, - documentFormat, + documentFormat: params.documentFormat, workflowState: finalWorkflowState, + variables: params.baseWorkflowState.variables, + reviewBaseStateHash: hashServerToolReviewBase(baseWorkflowState), preview: { ...preview, warnings, @@ -91,3 +189,27 @@ export function buildWorkflowMutationResult(params: { }, } } + +export async function resolveWorkflowMutationResultForExecution( + result: ReturnType<typeof buildWorkflowMutationResult>, + context?: ServerToolExecutionContext +) { + if (shouldStageServerToolMutationForReview(context)) { + return result + } + + assertAcceptedServerToolReviewBase(context, result.reviewBaseStateHash) + await applyWorkflowState( + result.entityId, + createWorkflowSnapshot(result.workflowState as Partial<WorkflowSnapshot>), + result.variables + ) + + const { + requiresReview: _requiresReview, + preview: _preview, + reviewBaseStateHash: _reviewBaseStateHash, + ...appliedResult + } = result + return appliedResult +} 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<typeof GetBlocksMetadataResult // get_agent_accessory_catalog export const GetAgentAccessoryCatalogInput = z .object({ - entityId: z.string().trim().min(1).optional(), + workspaceId: z.string().trim().min(1), }) .strict() @@ -298,43 +298,6 @@ export const GetIndicatorMetadataResult = z.object({ }) export type GetIndicatorMetadataResultType = z.infer<typeof GetIndicatorMetadataResult> -// 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<typeof KnowledgeBaseArgsSchema> - -export const KnowledgeBaseResultSchema = z.object({ - success: z.boolean(), - message: z.string(), - data: z.any().optional(), -}) -export type KnowledgeBaseResult = z.infer<typeof KnowledgeBaseResultSchema> - export const ReadBlockOutputsInput = z.object({ blockIds: z .array(z.string()) diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/block-output-utils.ts b/apps/tradinggoose/lib/copilot/workflow/block-output-utils.ts similarity index 77% rename from apps/tradinggoose/lib/copilot/tools/client/workflow/block-output-utils.ts rename to apps/tradinggoose/lib/copilot/workflow/block-output-utils.ts index 0ccc62ca7..773596ab8 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/block-output-utils.ts +++ b/apps/tradinggoose/lib/copilot/workflow/block-output-utils.ts @@ -1,6 +1,4 @@ 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' @@ -24,56 +22,26 @@ export interface BlockOutputReference { 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<string, any> ): Record<string, Record<string, any>> { const result: Record<string, Record<string, any>> = {} for (const [blockId, block] of Object.entries(blocks)) { - if (block?.subBlocks) { - const blockValues: Record<string, any> = {} - for (const [subId, sub] of Object.entries(block.subBlocks as Record<string, any>)) { - if (sub && typeof sub === 'object' && 'value' in sub) { - blockValues[subId] = sub.value - } + if (!block?.subBlocks) { + continue + } + + const blockValues: Record<string, any> = {} + for (const [subId, sub] of Object.entries(block.subBlocks as Record<string, any>)) { + if (sub && typeof sub === 'object' && 'value' in sub) { + blockValues[subId] = sub.value } - result[blockId] = blockValues } + 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<string, any> } -): Record<string, Record<string, any>> { - 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<string, BlockState>, subBlockValues: Record<string, Record<string, any>>, diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index cc65cc8d0..d2016f010 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -1,21 +1,24 @@ 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 { applySavedEntityYjsStateToRows, savedEntityRowToFields } from '@/lib/yjs/entity-state' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { + applySavedEntityState, + publishCreatedSavedEntityListMembers, +} from '@/lib/yjs/server/apply-entity-state' +import { requireSavedEntityRealtimeListFields } from '@/lib/yjs/server/bootstrap-review-target' const logger = createLogger('CustomToolsOperations') -interface UpsertCustomToolsParams { +interface CreateCustomToolsParams { tools: Array<{ - id?: string title: string schema: Record<string, any> code: string @@ -25,6 +28,17 @@ interface UpsertCustomToolsParams { requestId?: string } +interface SaveCustomToolParams { + tool: { + id: string + title: string + schema: Record<string, any> + code: string + } + workspaceId: string + requestId?: string +} + interface ImportCustomToolsParams { tools: CustomToolTransferRecord[] workspaceId: string @@ -33,64 +47,50 @@ interface ImportCustomToolsParams { } export async function listCustomTools(params: { workspaceId: string }) { - const rows = await db - .select() - .from(customTools) - .where(eq(customTools.workspaceId, params.workspaceId)) - .orderBy(desc(customTools.createdAt)) - - return applySavedEntityYjsStateToRows('custom_tool', rows) + const entries = await requireSavedEntityRealtimeListFields('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 ?? ''), + })) } -/** - * Create or update custom tools scoped to a workspace. - */ -export async function upsertCustomTools({ +export async function createCustomTools({ tools, workspaceId, userId, requestId = generateRequestId(), -}: UpsertCustomToolsParams) { - const affectedIds: string[] = [] - const result = await db.transaction(async (tx) => { +}: CreateCustomToolsParams) { + if (tools.length === 0) { + return [] + } + + const created = await db.transaction(async (tx) => { + const existingTools = await tx + .select({ + id: customTools.id, + title: customTools.title, + }) + .from(customTools) + .where(eq(customTools.workspaceId, workspaceId)) + + 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 duplicateTitle = await tx - .select({ id: customTools.id }) - .from(customTools) - .where(and(eq(customTools.workspaceId, workspaceId), eq(customTools.title, tool.title))) - .limit(1) - - if (duplicateTitle[0] && duplicateTitle[0].id !== tool.id) { - throw new Error(`A tool with the title "${tool.title}" already exists in this workspace`) - } + const conflictingToolId = plannedTitles.get(tool.title) - 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) { - 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) - continue - } + if (conflictingToolId) { + throw new Error(`A tool with the title "${tool.title}" already exists in this workspace`) } - const toolId = tool.id || nanoid() - await tx.insert(customTools).values({ + const toolId = nanoid() + plannedTitles.set(tool.title, toolId) + insertValues.push({ id: toolId, workspaceId, userId, @@ -100,27 +100,42 @@ export async function upsertCustomTools({ createdAt: nowTime, updatedAt: nowTime, }) - - logger.info(`[${requestId}] Created custom tool ${tool.title}`) - affectedIds.push(toolId) } - return tx - .select() - .from(customTools) - .where(eq(customTools.workspaceId, workspaceId)) - .orderBy(desc(customTools.createdAt)) + const createdTools = await tx.insert(customTools).values(insertValues).returning() + return createdTools }) - await Promise.all( - result - .filter((row) => affectedIds.includes(row.id)) - .map((row) => - applySavedEntityState('custom_tool', row.id, savedEntityRowToFields('custom_tool', row)) - ) + await publishCreatedSavedEntityListMembers( + 'custom_tool', + workspaceId, + created.map((createdTool) => ({ id: createdTool.id, name: createdTool.title })) ) + logger.info(`[${requestId}] Created ${created.length} custom tool(s)`) + return created +} - return applySavedEntityYjsStateToRows('custom_tool', result) +export async function saveCustomTool({ + tool, + workspaceId, + requestId = generateRequestId(), +}: SaveCustomToolParams) { + const [existingTool] = await db + .select({ id: customTools.id }) + .from(customTools) + .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`) + } + + await applySavedEntityState('custom_tool', tool.id, { + title: tool.title, + schemaText: JSON.stringify(tool.schema, null, 2), + codeText: tool.code, + }) + logger.info(`[${requestId}] Saved custom tool ${tool.id}`) + return listCustomTools({ workspaceId }) } export async function importCustomTools({ @@ -129,7 +144,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, @@ -158,15 +173,21 @@ export async function importCustomTools({ const importedTools = await tx.insert(customTools).values(importValues).returning() - logger.info(`[${requestId}] Imported ${importedTools.length} custom tool(s)`, { - workspaceId, - renamedCount, - }) - return { tools: importedTools, importedCount: importedTools.length, renamedCount, } }) + + await publishCreatedSavedEntityListMembers( + '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/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/env.ts b/apps/tradinggoose/lib/env.ts index 19d71907b..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().min(32).optional(), // Dedicated key for encrypting API keys (optional for OSS) + 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/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index a6ac38913..9864d5805 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -1,45 +1,65 @@ 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, 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 { applySavedEntityYjsStateToRows, savedEntityRowToFields } from '@/lib/yjs/entity-state' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { + applySavedEntityState, + publishCreatedSavedEntityListMembers, +} from '@/lib/yjs/server/apply-entity-state' +import { requireSavedEntityRealtimeListFields } from '@/lib/yjs/server/bootstrap-review-target' const logger = createLogger('IndicatorsOperations') export async function listCustomIndicatorRuntimeEntries(workspaceId: string) { - const rows = await db - .select() - .from(pineIndicators) - .where(eq(pineIndicators.workspaceId, workspaceId)) - .then((indicatorRows) => applySavedEntityYjsStateToRows('indicator', indicatorRows)) + const entries = await requireSavedEntityRealtimeListFields('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 requireSavedEntityRealtimeListFields('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), })) } -interface UpsertIndicatorsParams { +interface CreateIndicatorsParams { indicators: Array<{ - id?: string name: string + color?: string pineCode: string - inputMeta?: Record<string, unknown> }> workspaceId: string userId: string requestId?: string } +interface SaveIndicatorParams { + indicator: { + id: string + name: string + pineCode: string + } + workspaceId: string + requestId?: string +} + interface ImportIndicatorsParams { indicators: IndicatorTransferRecord[] workspaceId: string @@ -47,79 +67,73 @@ interface ImportIndicatorsParams { requestId?: string } -export async function upsertIndicators({ +export async function createIndicators({ indicators, workspaceId, userId, requestId = generateRequestId(), -}: UpsertIndicatorsParams) { - const affectedIds: string[] = [] - const result = 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 - - 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) - continue - } - } +}: CreateIndicatorsParams) { + if (indicators.length === 0) { + return [] + } - const indicatorId = indicator.id ?? crypto.randomUUID() - await tx.insert(pineIndicators).values({ + const created = await db.transaction(async (tx) => { + const nowTime = new Date() + const insertValues = [] + + for (const indicator of indicators) { + const indicatorId = crypto.randomUUID() + insertValues.push({ id: indicatorId, workspaceId, userId, name: indicator.name, - color: getStableVibrantColor(indicatorId), + color: indicator.color?.trim() || getStableVibrantColor(indicatorId), pineCode: indicator.pineCode, - inputMeta: indicator.inputMeta ?? null, + inputMeta: inferInputMetaFromPineCode(indicator.pineCode) ?? null, createdAt: nowTime, updatedAt: nowTime, }) - - logger.info(`[${requestId}] Created Indicator ${indicator.name}`) - affectedIds.push(indicatorId) } - return tx - .select() - .from(pineIndicators) - .where(eq(pineIndicators.workspaceId, workspaceId)) - .orderBy(desc(pineIndicators.createdAt)) + const createdIndicators = await tx.insert(pineIndicators).values(insertValues).returning() + return createdIndicators }) - await Promise.all( - result - .filter((row) => affectedIds.includes(row.id)) - .map((row) => - applySavedEntityState('indicator', row.id, savedEntityRowToFields('indicator', row)) - ) + await publishCreatedSavedEntityListMembers( + 'indicator', + workspaceId, + created.map((createdIndicator) => ({ id: createdIndicator.id, name: createdIndicator.name })) ) + logger.info(`[${requestId}] Created ${created.length} indicator(s)`) + return created +} + +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) - return applySavedEntityYjsStateToRows('indicator', result) + if (!existing) { + throw new Error(`Indicator ${indicator.id} was not found`) + } + + await applySavedEntityState('indicator', indicator.id, { + name: indicator.name, + color: existing.color ?? getStableVibrantColor(indicator.id), + pineCode: indicator.pineCode, + }) + logger.info(`[${requestId}] Saved Indicator ${indicator.id}`) + return listIndicators({ workspaceId }) } export async function importIndicators({ @@ -128,7 +142,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) @@ -155,7 +169,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, } @@ -163,15 +177,21 @@ export async function importIndicators({ const importedIndicators = await tx.insert(pineIndicators).values(importValues).returning() - logger.info(`[${requestId}] Imported ${importedIndicators.length} indicator(s)`, { - workspaceId, - renamedCount, - }) - return { indicators: importedIndicators, importedCount: importedIndicators.length, renamedCount, } }) + + await publishCreatedSavedEntityListMembers( + '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/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<typeof IndicatorTransferSchema> export type IndicatorsImportFile = z.infer<typeof IndicatorsImportFileSchema> function normalizeIndicatorForTransfer( - indicator: Pick<IndicatorDefinition, 'name' | 'pineCode' | 'inputMeta'> + indicator: Pick<IndicatorDefinition, 'name' | 'pineCode'> ): 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<Pick<IndicatorDefinition, 'name' | 'pineCode' | 'inputMeta'>> + indicators: Array<Pick<IndicatorDefinition, 'name' | 'pineCode'>> exportedFrom: string }): IndicatorsImportFile { return createTradingGooseExportFile({ @@ -75,7 +69,7 @@ export function exportIndicatorsAsJson({ indicators, exportedFrom, }: { - indicators: Array<Pick<IndicatorDefinition, 'name' | 'pineCode' | 'inputMeta'>> + indicators: Array<Pick<IndicatorDefinition, 'name' | 'pineCode'>> 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<string, InputMeta> diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index a6f9dd199..e0a88162b 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,15 @@ import type { } from '@/lib/knowledge/types' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/permissions/utils' +import { + applySavedEntityState, + publishCreatedSavedEntityListMembers, +} from '@/lib/yjs/server/apply-entity-state' +import { requireSavedEntityRealtimeListFields } from '@/lib/yjs/server/bootstrap-review-target' +import { + deleteYjsSessionInSocketServer, + notifyEntityListMemberRemoved, +} from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('KnowledgeBaseService') @@ -35,33 +45,16 @@ 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 requireSavedEntityRealtimeListFields('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, + workspaceId, })) } @@ -95,11 +88,7 @@ export async function createKnowledgeBase( deletedAt: null, } - await db.insert(knowledgeBase).values(newKnowledgeBase) - - logger.info(`[${requestId}] Created knowledge base: ${data.name} (${kbId})`) - - return { + const created = { id: kbId, name: data.name, description: data.description ?? null, @@ -112,6 +101,14 @@ export async function createKnowledgeBase( workspaceId: data.workspaceId, docCount: 0, } + + await db.insert(knowledgeBase).values(newKnowledgeBase) + await publishCreatedSavedEntityListMembers('knowledge_base', data.workspaceId, [ + { id: created.id, name: created.name }, + ]) + + logger.info(`[${requestId}] Created knowledge base: ${data.name} (${kbId})`) + return created } export async function copyKnowledgeBaseToWorkspace( @@ -176,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, @@ -317,6 +315,31 @@ export async function copyKnowledgeBaseToWorkspace( throw error } + const copied = { + id: newKnowledgeBaseId, + name: copiedName, + description: sourceKnowledgeBase.description, + tokenCount: sourceKnowledgeBase.tokenCount, + embeddingModel: sourceKnowledgeBase.embeddingModel, + embeddingDimension: sourceKnowledgeBase.embeddingDimension, + chunkingConfig: sourceKnowledgeBase.chunkingConfig as ChunkingConfig, + createdAt: now, + updatedAt: now, + workspaceId: targetWorkspaceId, + 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) @@ -333,93 +356,28 @@ export async function copyKnowledgeBaseToWorkspace( `[${requestId}] Copied knowledge base ${sourceKnowledgeBaseId} to workspace ${targetWorkspaceId} as ${newKnowledgeBaseId}` ) - return { - id: newKnowledgeBaseId, - name: `${sourceKnowledgeBase.name} (Copy)`, - description: sourceKnowledgeBase.description, - tokenCount: sourceKnowledgeBase.tokenCount, - embeddingModel: sourceKnowledgeBase.embeddingModel, - embeddingDimension: sourceKnowledgeBase.embeddingDimension, - chunkingConfig: sourceKnowledgeBase.chunkingConfig as ChunkingConfig, - createdAt: now, - updatedAt: now, - workspaceId: targetWorkspaceId, - docCount: sourceDocuments.length, - } + 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<KnowledgeBaseWithCounts> { - 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 } /** @@ -471,6 +429,12 @@ export async function deleteKnowledgeBase( ): Promise<void> { 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({ @@ -479,5 +443,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/knowledge/types.ts b/apps/tradinggoose/lib/knowledge/types.ts index b65083015..09c339d35 100644 --- a/apps/tradinggoose/lib/knowledge/types.ts +++ b/apps/tradinggoose/lib/knowledge/types.ts @@ -12,10 +12,10 @@ export interface KnowledgeBaseWithCounts { embeddingModel: string embeddingDimension: number chunkingConfig: ChunkingConfig - createdAt: Date - updatedAt: Date + createdAt?: Date + updatedAt?: Date workspaceId: string - docCount: number + docCount?: number } export interface CreateKnowledgeBaseData { diff --git a/apps/tradinggoose/lib/mcp/auth.test.ts b/apps/tradinggoose/lib/mcp/auth.test.ts new file mode 100644 index 000000000..f326ff6c5 --- /dev/null +++ b/apps/tradinggoose/lib/mcp/auth.test.ts @@ -0,0 +1,190 @@ +/** + * @vitest-environment node + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { db, mockGetStoredApiKey, mockIsApiKeyFormat } = vi.hoisted(() => ({ + db: { + select: vi.fn(), + insert: vi.fn(), + delete: vi.fn(), + update: vi.fn(), + transaction: vi.fn(), + }, + mockGetStoredApiKey: vi.fn(), + mockIsApiKeyFormat: 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', () => ({ + getStoredApiKey: mockGetStoredApiKey, + isApiKeyFormat: mockIsApiKeyFormat, +})) +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 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 { + 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() + mockGetStoredApiKey.mockReturnValue('stored-api-key') + mockIsApiKeyFormat.mockReturnValue(true) + 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() + }) + + 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(approvedState), + 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') + 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 new file mode 100644 index 000000000..3b10d4cb7 --- /dev/null +++ b/apps/tradinggoose/lib/mcp/auth.ts @@ -0,0 +1,593 @@ +import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' +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 { getStoredApiKey, isApiKeyFormat } 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 + +type PendingDeviceLogin = { + status: 'pending' + createdAt: string + verificationKeyHash: string +} + +type ApprovedDeviceLogin = { + status: 'approved' + createdAt: string + verificationKeyHash: string + approvedAt: string + userId: string + apiKeyHash?: string + deliveredAt?: string +} + +type DeviceLoginState = + | PendingDeviceLogin + | ApprovedDeviceLogin + | { status: 'cancelled'; verificationKeyHash: string } +type DeviceLogin = { + id: string + state: DeviceLoginState + expiresAt: Date +} +type PendingDeviceLoginRecord = DeviceLogin & { state: PendingDeviceLogin } + +export type McpDeviceLoginPollResult = + | { status: 'pending'; intervalSeconds: number; expiresAt: string } + | { status: 'approved'; apiKey: string; expiresAt: string } + | { status: 'invalid' } + | { status: 'expired' } + +export type McpDeviceLoginAckResult = + | { status: 'acknowledged' } + | { status: 'invalid' } + | { status: 'expired' } + +export type McpDeviceLoginApprovalResult = + | { status: 'approved'; expiresAt: string } + | { status: 'expired' } + | { status: 'invalid' } + +export type McpDeviceLoginApprovalChallengeResult = + | { status: 'pending'; expiresAt: string; approvalToken: string } + | { status: 'approved'; expiresAt: string } + | { status: 'expired' } + | { status: 'invalid' } + +export type McpDeviceLoginStartResult = { + code: string + verificationKey: string + expiresAt: string + intervalSeconds: number +} + +function hashValue(value: string) { + return createHash('sha256').update(value).digest('hex') +} + +function hashValueMatches(value: string, expectedHash: string | undefined): boolean { + const actualHash = hashValue(value) + return ( + !!expectedHash && + actualHash.length === expectedHash.length && + timingSafeEqual(Buffer.from(actualHash), Buffer.from(expectedHash)) + ) +} + +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(`${getDeviceLoginDeploymentScope()}:${code}`)}` +} + +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 `sk-tradinggoose-${secret}` +} + +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 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, + verificationKey, +}: { + expiresAt: Date + now: Date + verificationKey: string +}) { + const verificationKeyHash = hashValue(verificationKey) + const unsignedCode = [ + randomBytes(32).toString('base64url'), + String(now.getTime()), + String(expiresAt.getTime()), + getDeviceLoginDeploymentScope(), + verificationKeyHash, + ].join('.') + const code = `${unsignedCode}.${signDeviceLoginCode(unsignedCode)}` + return { + code, + id: buildDeviceLoginId(code), + expiresAt, + state: { + status: 'pending', + createdAt: now.toISOString(), + verificationKeyHash, + } satisfies PendingDeviceLogin, + } +} + +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) + + 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))) +} + +function parseDeviceLoginState(value: string): DeviceLoginState | null { + try { + const parsed = JSON.parse(value) as Record<string, unknown> + if ( + parsed.status === 'pending' && + typeof parsed.createdAt === 'string' && + typeof parsed.verificationKeyHash === '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' && + (parsed.apiKeyHash === undefined || typeof parsed.apiKeyHash === 'string') && + (parsed.deliveredAt === undefined || typeof parsed.deliveredAt === 'string') + ) { + return parsed as ApprovedDeviceLogin + } + if (parsed.status === 'cancelled' && typeof parsed.verificationKeyHash === 'string') { + return parsed as DeviceLoginState + } + return null + } catch { + 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, + value: verification.value, + expiresAt: verification.expiresAt, + }) + .from(verification) + .where(eq(verification.id, login.id)) + .limit(1) + + if (!row) { + return null + } + + const state = parseDeviceLoginState(row.value) + if (!state || row.expiresAt <= now) { + await db.delete(verification).where(eq(verification.id, row.id)) + return null + } + + return { + id: row.id, + state, + expiresAt: row.expiresAt, + } +} + +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 +): Promise<boolean> { + 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 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({ + 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 }) + + return readPersistedDeviceLogin(login, now) +} + +export async function startMcpDeviceLogin(): Promise<McpDeviceLoginStartResult> { + 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 }) + + return { + code: login.code, + verificationKey, + expiresAt: expiresAt.toISOString(), + intervalSeconds: POLL_INTERVAL_SECONDS, + } +} + +export async function createMcpDeviceLoginApprovalChallenge({ + code, + userId, +}: { + code: string + userId: string +}): Promise<McpDeviceLoginApprovalChallengeResult> { + 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) { + return { status: 'invalid' } + } + if (login.state.deliveredAt) { + return { status: 'expired' } + } + return { + status: 'approved', + expiresAt: login.expiresAt.toISOString(), + } + } + if (login.state.status !== 'pending') { + return { status: 'expired' } + } + + return { + status: 'pending', + expiresAt: login.expiresAt.toISOString(), + approvalToken: createDeviceLoginApprovalToken(code, userId), + } +} + +export async function pollMcpDeviceLogin( + code: string, + verificationKey: string +): Promise<McpDeviceLoginPollResult> { + const login = await readDeviceLogin(code) + if (!login) { + return { status: 'expired' } + } + + if (!hashValueMatches(verificationKey, login.state.verificationKeyHash)) { + return { status: 'invalid' } + } + + if (login.state.status === 'approved' && login.state.deliveredAt) { + return { status: 'expired' } + } + + 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 key = createDeviceLoginApiKey(code, verificationKey) + const apiKeyHash = hashValue(key) + if (hashValueMatches(key, login.state.apiKeyHash)) { + return { + status: 'approved', + apiKey: key, + expiresAt: login.expiresAt.toISOString(), + } + } + + const nextState = { + ...login.state, + apiKeyHash, + } 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<McpDeviceLoginAckResult> { + if (!isApiKeyFormat(plainApiKey)) { + return { status: 'invalid' } + } + + const login = await readDeviceLogin(code) + if (!login) { + return { status: 'expired' } + } + + if (!hashValueMatches(verificationKey, login.state.verificationKeyHash)) { + return { status: 'invalid' } + } + + if (login.state.status === 'approved' && login.state.deliveredAt) { + return hashValueMatches(plainApiKey, hashValue(createDeviceLoginApiKey(code, verificationKey))) + ? { status: 'acknowledged' } + : { status: 'invalid' } + } + + if (login.state.status !== 'approved') { + return { status: 'invalid' } + } + if (!hashValueMatches(plainApiKey, login.state.apiKeyHash)) { + return { status: 'invalid' } + } + + const now = new Date() + const { apiKeyHash: _apiKeyHash, ...approvedState } = login.state + const storedKey = getStoredApiKey(plainApiKey) + 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, + }) + .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 Personal API Key (MCP setup) ${now.toISOString()}`, + key: storedKey, + type: 'personal', + createdAt: now, + updatedAt: now, + }) + return true + }) + if (!delivered) { + return { status: 'invalid' } + } + + return { status: 'acknowledged' } +} +export async function approveMcpDeviceLogin({ + approvalToken, + code, + userId, +}: { + approvalToken: string + code: string + userId: string +}): Promise<McpDeviceLoginApprovalResult> { + const login = await readDeviceLogin(code) + if (!login) { + return { status: 'expired' } + } + + if (login.state.status === 'approved') { + if (login.state.userId !== userId || login.state.deliveredAt) { + return { status: 'invalid' } + } + return { + status: 'approved', + expiresAt: login.expiresAt.toISOString(), + } + } + if (login.state.status !== 'pending') { + return { status: 'invalid' } + } + + if (!approvalTokenMatches(code, userId, approvalToken)) { + return { status: 'invalid' } + } + + const now = new Date() + const approvedAt = now.toISOString() + const approvedState = { + status: 'approved', + createdAt: login.state.createdAt, + verificationKeyHash: login.state.verificationKeyHash, + approvedAt, + userId, + } satisfies ApprovedDeviceLogin + + if (!(await updateDeviceLoginState(login, approvedState))) { + return { status: 'invalid' } + } + + return { + status: 'approved', + expiresAt: login.expiresAt.toISOString(), + } +} + +export async function cancelMcpDeviceLogin({ + approvalToken, + code, + userId, +}: { + approvalToken: string + code: string + userId: string +}) { + const login = await readDeviceLogin(code) + if (!login) { + return { status: 'expired' } + } + + if (login.state.status !== 'pending') { + return { status: 'invalid' } + } + + if (!approvalTokenMatches(code, userId, approvalToken)) { + return { status: 'invalid' } + } + + 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 (!updated) { + return { status: 'invalid' } + } + + return { status: 'cancelled' } +} 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 diff --git a/apps/tradinggoose/lib/mcp/install-script.ts b/apps/tradinggoose/lib/mcp/install-script.ts new file mode 100644 index 000000000..6fe547170 --- /dev/null +++ b/apps/tradinggoose/lib/mcp/install-script.ts @@ -0,0 +1,427 @@ +import { MCP_LOCAL_CONFIG_WRITER_SCRIPT } from './local-config-writer-script' + +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 configure MCP auth.') + } +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function postJson(url, body) { + const response = await fetch(url, { + method: 'POST', + headers: { + ...(body ? { 'content-type': 'application/json' } : {}), + }, + ...(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 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') + } + + 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, verificationKey }) + 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 { code, verificationKey, 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 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 login = await authenticate() + await acknowledge(login) + console.log('MCP endpoint:') + console.log(mcpUrl) + console.log('') + console.log('MCP authorization header:') + console.log('Authorization: Bearer ' + login.token) + return + } + + if (command === 'setup') { + if (targets.length === 0) { + fail('setup requires a selected target') + } + + 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) + } + 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 shellSingleQuote(value: string) { + return `'${value.replaceAll("'", "'\"'\"'")}'` +} + +function powerShellSingleQuote(value: string) { + return `'${value.replaceAll("'", "''")}'` +} + +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=${shellSingleQuote(normalizedBaseUrl)} +COMMAND="${options.command}" +TARGETS="${initialTargets}" + +usage() { + cat <<'USAGE' +TradingGoose MCP setup + +Usage: + curl -fsSL <studio-url>/mcp/setup | sh + curl -fsSL <studio-url>/mcp/setup/codex | sh + curl -fsSL <studio-url>/mcp/login | sh + +PowerShell: + irm <studio-url>/mcp/setup | iex + irm <studio-url>/mcp/setup/codex | iex + irm <studio-url>/mcp/login | iex + +Commands: + login Print the MCP endpoint and authorization header, authenticating when needed. + setup Write MCP config, authenticating when needed. + +Options: + -h, --help Show this help. +USAGE +} + +fail() { + echo "tradinggoose-mcp: $*" >&2 + exit 1 +} + +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 an interactive terminal or a target URL such as /mcp/setup/codex." + 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/tty + case "$choice" in + 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 + ;; + *) fail "Invalid setup target: $choice" ;; + esac +} + +run_installer() { + 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 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + *) + fail "Unknown option: $1" + ;; + esac + shift +done + +case "$COMMAND" in + setup) + choose_targets + run_installer + ;; + login) + run_installer + ;; + help|-h|--help) + usage + ;; + *) + fail "Unknown command: $COMMAND" + ;; +esac +` + return script.replaceAll('\\${', '${') +} + +function buildPowerShellInstallScript(baseUrl: string, options: McpInstallScriptOptions) { + const normalizedBaseUrl = baseUrl.replace(/\/+$/, '') + const initialTargets = getInitialPowerShellTargets(options.target) + + return `$ErrorActionPreference = 'Stop' + +$BaseUrl = ${powerShellSingleQuote(normalizedBaseUrl)} +$Command = '${options.command}' +$Targets = ${initialTargets} + +function Show-Usage { + @' +TradingGoose MCP setup + +Usage: + irm <studio-url>/mcp/setup | iex + irm <studio-url>/mcp/setup/codex | iex + irm <studio-url>/mcp/login | iex + +POSIX shell: + curl -fsSL <studio-url>/mcp/setup | sh + curl -fsSL <studio-url>/mcp/setup/codex | sh + curl -fsSL <studio-url>/mcp/login | sh + +Commands: + login Print the MCP endpoint and authorization header, authenticating when needed. + setup Write MCP config, authenticating when needed. + +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 configure 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..c04fb032d --- /dev/null +++ b/apps/tradinggoose/lib/mcp/local-config-writer-script.test.ts @@ -0,0 +1,146 @@ +/** + * @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' + +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], { + cwd: home, + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + }, + input: MCP_LOCAL_CONFIG_WRITER_SCRIPT, + timeout: 5000, + }) + + expect(result.status).toBe(0) + expect(result.stderr).toBe('') + return result.stdout +} + +describe('MCP local config writer script', () => { + 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']) + + const configPath = join(home, '.codex', 'config.toml') + expect(readFileSync(configPath, 'utf8')).toBe( + [ + '[mcp_servers.TradingGoose]', + 'url = "http://localhost:3000/api/copilot/mcp"', + '', + '[mcp_servers.TradingGoose.http_headers]', + 'Authorization = "Bearer mcp-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('[mcp_servers.TradingGoose.http_headers]') + expect(config).toContain('Authorization = "Bearer new-token"') + expect(config).toContain('[mcp_servers.other]') + expect(config).not.toContain('bearer_token_env_var') + }) + + 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' }, + }, + }, + }) + }) + + 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) + }) +}) 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..a87ebdcdc --- /dev/null +++ b/apps/tradinggoose/lib/mcp/local-config-writer-script.ts @@ -0,0 +1,128 @@ +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 mcpUrl = process.argv[3] +const token = process.argv[4] +const mcpServerName = 'TradingGoose' + +function resolvePathFor(candidate) { + 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) +} + +function ensureParent(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) +} + +function writeCodexConfig(filePath) { + ensureParent(filePath) + const block = [ + '[mcp_servers.' + mcpServerName + ']', + 'url = ' + JSON.stringify(mcpUrl), + '', + '[mcp_servers.' + mcpServerName + '.http_headers]', + 'Authorization = ' + JSON.stringify('Bearer ' + token), + '', + ].join('\n') + const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '' + const withoutCurrent = removeTomlMcpServerBlock(current) + fs.writeFileSync( + filePath, + withoutCurrent.trim() ? withoutCurrent.replace(/\s*$/, '') + '\n\n' + block : block, + 'utf8' + ) +} + +function removeTomlMcpServerBlock(current) { + const sectionHeader = '[mcp_servers.' + mcpServerName + ']' + const subPrefix = '[mcp_servers.' + mcpServerName + '.' + let next = current + + 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 = 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) { + 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][mcpServerName] = entry + fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n', 'utf8') +} + +const filePath = resolvePath() +const authHeaders = { Authorization: 'Bearer ' + token } +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/lib/mcp/service.ts b/apps/tradinggoose/lib/mcp/service.ts index a6bebd787..9cde48c22 100644 --- a/apps/tradinggoose/lib/mcp/service.ts +++ b/apps/tradinggoose/lib/mcp/service.ts @@ -1,11 +1,7 @@ -/** - * MCP Service - Clean stateless service for MCP operations - */ - import { db } from '@tradinggoose/db' import { mcpServers } from '@tradinggoose/db/schema' import { and, eq, isNull } from 'drizzle-orm' -import { isTest } from '@/lib/environment' +import { normalizeEntityFields } from '@/lib/copilot/entity-documents' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { createLogger } from '@/lib/logs/console/logger' import { McpClient } from '@/lib/mcp/client' @@ -17,165 +13,31 @@ import type { McpToolResult, McpTransport, } from '@/lib/mcp/types' -import { MCP_CONSTANTS } from '@/lib/mcp/utils' import { generateRequestId } from '@/lib/utils' -import { - applySavedEntityYjsStateToRow, - applySavedEntityYjsStateToRows, -} from '@/lib/yjs/entity-state' +import { savedEntityRowToFields } from '@/lib/yjs/entity-state' +import { publishCreatedSavedEntityListMembers } from '@/lib/yjs/server/apply-entity-state' const logger = createLogger('McpService') -interface ToolCache { - tools: McpTool[] - expiry: Date - lastAccessed: Date -} +export class McpServerNotFoundError extends Error { + readonly status = 404 -interface CacheStats { - totalEntries: number - activeEntries: number - expiredEntries: number - maxCacheSize: number - cacheHitRate: number - memoryUsage: { - approximateBytes: number - entriesEvicted: number + constructor(serverId: string) { + super(`Server ${serverId} not found or not accessible`) + this.name = 'McpServerNotFoundError' } } -class McpService { - private toolCache = new Map<string, ToolCache>() - 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)) +export class McpServerConfigError extends Error { + readonly status = 400 - 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') + constructor(message: string) { + super(message) + this.name = 'McpServerConfigError' } +} +class McpService { /** * Resolve environment variables in strings */ @@ -201,7 +63,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.` ) } @@ -240,9 +102,70 @@ class McpService { } } - /** - * Get server configuration from database - */ + private toServerConfig(serverId: string, fields: Record<string, unknown>): McpServerConfig { + return { + id: serverId, + name: String(fields.name ?? ''), + description: String(fields.description ?? '') || undefined, + transport: fields.transport as McpTransport, + url: String(fields.url ?? '') || undefined, + headers: fields.headers as Record<string, string>, + timeout: Number(fields.timeout ?? 30000), + retries: Number(fields.retries ?? 3), + enabled: fields.enabled !== false, + } + } + + async createWorkspaceServer(input: { + userId: string + workspaceId: string + fields: Record<string, unknown> + }): Promise<{ entityId: string; fields: Record<string, unknown> }> { + let normalized: Record<string, unknown> + 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') + } + + await publishCreatedSavedEntityListMembers('mcp_server', input.workspaceId, [ + { + id: entityId, + name: String(normalized.name ?? ''), + enabled: normalized.enabled !== false, + }, + ]) + + return { entityId, fields: savedEntityRowToFields('mcp_server', row) } + } + private async getServerConfig( serverId: string, workspaceId: string @@ -258,59 +181,27 @@ class McpService { ) ) .limit(1) - if (!server) { return null } - const config = await applySavedEntityYjsStateToRow('mcp_server', server) - if (!config.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<string, string>) || {}, - timeout: config.timeout || 30000, - retries: config.retries || 3, - enabled: config.enabled, - createdAt: config.createdAt.toISOString(), - updatedAt: config.updatedAt.toISOString(), - } + const fields = normalizeEntityFields('mcp_server', savedEntityRowToFields('mcp_server', server)) + return fields.enabled === false ? null : this.toServerConfig(serverId, fields) } - /** - * Get all enabled servers for a workspace - */ private async getWorkspaceServers(workspaceId: string): Promise<McpServerConfig[]> { - 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 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<string, string>) || {}, - timeout: server.timeout || 30000, - retries: server.retries || 3, - enabled: server.enabled, - createdAt: server.createdAt.toISOString(), - updatedAt: server.updatedAt.toISOString(), - })) + .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) + + return servers.flatMap((server) => { + const fields = normalizeEntityFields( + 'mcp_server', + savedEntityRowToFields('mcp_server', server) + ) + return fields.enabled === false ? [] : [this.toServerConfig(server.id, fields)] + }) } /** @@ -347,7 +238,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) @@ -373,24 +264,10 @@ class McpService { /** * Discover tools from all workspace servers */ - async discoverTools( - userId: string, - workspaceId: string, - forceRefresh = false - ): Promise<McpTool[]> { + async discoverTools(userId: string, workspaceId: string): Promise<McpTool[]> { 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) @@ -428,8 +305,6 @@ class McpService { } }) - this.setCacheEntry(cacheKey, allTools) - logger.info( `[${requestId}] Discovered ${allTools.length} tools from ${servers.length} servers` ) @@ -455,7 +330,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) @@ -525,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/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<T = any> { 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..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 @@ -32,6 +31,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/skills/operations.test.ts b/apps/tradinggoose/lib/skills/operations.test.ts index 2fdf2a0dc..fb80f3bb7 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', () => ({ @@ -23,13 +24,27 @@ 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', () => ({ nanoid: (...args: unknown[]) => mockNanoid(...args), })) +vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ + applySavedEntityState: vi.fn(), + publishCreatedSavedEntityListMembers: mockNotifyEntityListMembersUpserted, +})) + +vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ + requireSavedEntityRealtimeListFields: 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 4c10edcb0..5c7a31027 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, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import { createLogger } from '@/lib/logs/console/logger' import { @@ -10,17 +10,19 @@ import { } from '@/lib/skills/import-export' import { generateRequestId } from '@/lib/utils' import { - applySavedEntityYjsStateToRows, - savedEntityRowToFields, -} from '@/lib/yjs/entity-state' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' -import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' + applySavedEntityState, + publishCreatedSavedEntityListMembers, +} from '@/lib/yjs/server/apply-entity-state' +import { requireSavedEntityRealtimeListFields } from '@/lib/yjs/server/bootstrap-review-target' +import { + deleteYjsSessionInSocketServer, + notifyEntityListMemberRemoved, +} from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('SkillsOperations') -interface UpsertSkillsParams { +interface CreateSkillsParams { skills: Array<{ - id?: string name: string description: string content: string @@ -30,6 +32,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 @@ -38,107 +51,81 @@ interface ImportSkillsParams { } export async function listSkills(params: { workspaceId: string }) { - const rows = await db - .select() - .from(skill) - .where(eq(skill.workspaceId, params.workspaceId)) - .orderBy(desc(skill.createdAt)) - - return applySavedEntityYjsStateToRows('skill', rows) + const entries = await requireSavedEntityRealtimeListFields('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 ?? ''), + })) } export async function deleteSkill(params: { skillId: string workspaceId: string }): Promise<boolean> { - const existingSkill = await db + const [existingSkill] = await db .select({ id: skill.id }) .from(skill) .where(and(eq(skill.id, params.skillId), eq(skill.workspaceId, params.workspaceId))) .limit(1) - if (existingSkill.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))) + await Promise.allSettled([ + deleteYjsSessionInSocketServer(params.skillId), + notifyEntityListMemberRemoved('skill', params.workspaceId, params.skillId), + ]) + logger.info(`Deleted skill ${params.skillId}`) return true } -export async function upsertSkills({ +export async function createSkills({ skills, workspaceId, userId, requestId = generateRequestId(), -}: UpsertSkillsParams) { - const affectedIds: string[] = [] - const result = await db.transaction(async (tx) => { - for (const currentSkill of skills) { - const nowTime = new Date() - - 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` - ) - } - } - - 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) - continue - } - } +}: CreateSkillsParams) { + if (skills.length === 0) { + return [] + } - const duplicateName = await tx - .select({ id: skill.id }) - .from(skill) - .where(and(eq(skill.workspaceId, workspaceId), eq(skill.name, currentSkill.name))) - .limit(1) + const created = await db.transaction(async (tx) => { + const existingSkills = await tx + .select({ + id: skill.id, + name: skill.name, + }) + .from(skill) + .where(eq(skill.workspaceId, workspaceId)) - if (duplicateName.length > 0) { + const plannedNames = new Map( + existingSkills.map((currentSkill) => [currentSkill.name, currentSkill.id]) + ) + const nowTime = new Date() + const insertValues = [] + + for (const currentSkill of skills) { + const conflictingSkillId = plannedNames.get(currentSkill.name) + + if (conflictingSkillId) { throw new Error( `A skill with the name "${currentSkill.name}" already exists in this workspace` ) } - const skillId = currentSkill.id || nanoid() - await tx.insert(skill).values({ + const skillId = nanoid() + plannedNames.set(currentSkill.name, skillId) + insertValues.push({ id: skillId, workspaceId, userId, @@ -148,25 +135,42 @@ export async function upsertSkills({ createdAt: nowTime, updatedAt: nowTime, }) - - logger.info(`[${requestId}] Created skill "${currentSkill.name}"`) - affectedIds.push(skillId) } - return tx - .select() - .from(skill) - .where(eq(skill.workspaceId, workspaceId)) - .orderBy(desc(skill.createdAt)) + const createdSkills = await tx.insert(skill).values(insertValues).returning() + return createdSkills }) - await Promise.all( - result - .filter((row) => affectedIds.includes(row.id)) - .map((row) => applySavedEntityState('skill', row.id, savedEntityRowToFields('skill', row))) + await publishCreatedSavedEntityListMembers( + 'skill', + workspaceId, + created.map((createdSkill) => ({ id: createdSkill.id, name: createdSkill.name })) ) + logger.info(`[${requestId}] Created ${created.length} skill(s)`) + return created +} - return applySavedEntityYjsStateToRows('skill', result) +export async function saveSkill({ + skill: currentSkill, + workspaceId, + requestId = generateRequestId(), +}: SaveSkillParams) { + const [existingSkill] = await db + .select({ id: skill.id }) + .from(skill) + .where(and(eq(skill.id, currentSkill.id), eq(skill.workspaceId, workspaceId))) + .limit(1) + if (!existingSkill) { + throw new Error(`Skill ${currentSkill.id} was not found`) + } + + await applySavedEntityState('skill', currentSkill.id, { + name: currentSkill.name, + description: currentSkill.description, + content: currentSkill.content, + }) + logger.info(`[${requestId}] Saved skill ${currentSkill.id}`) + return listSkills({ workspaceId }) } export async function importSkills({ @@ -175,7 +179,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) @@ -216,11 +220,6 @@ export async function importSkills({ const persistedSkills = await tx.insert(skill).values(insertValues).returning() - logger.info(`[${requestId}] Imported ${persistedSkills.length} skill(s)`, { - workspaceId, - renamedCount, - }) - return { skills: persistedSkills, importedSkills, @@ -228,4 +227,15 @@ export async function importSkills({ renamedCount, } }) + + await publishCreatedSavedEntityListMembers( + '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/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<string, any> - } - } - 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<string, CustomTool>() - - 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/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index 40e0d3f31..57d77cfc6 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(), @@ -21,8 +21,12 @@ const mockDb = { const mockWorkflowTable = { id: 'id', + name: 'name', variables: 'variables', lastSynced: 'lastSynced', + updatedAt: 'updatedAt', + isDeployed: 'isDeployed', + deployedAt: 'deployedAt', userId: 'userId', } @@ -106,19 +110,10 @@ vi.doMock('@/lib/logs/console/logger', () => ({ })) const mockReconcilePublishedChatsForDeploymentTx = vi.fn() -const mockGetYjsSnapshot = vi.fn() -class MockSocketServerBridgeError extends Error { - constructor( - public status: number, - public body: string - ) { - super(body) - this.name = 'SocketServerBridgeError' - } -} -vi.doMock('@/lib/yjs/server/snapshot-bridge', () => ({ - getYjsSnapshot: mockGetYjsSnapshot, - SocketServerBridgeError: MockSocketServerBridgeError, +const mockReadBootstrappedReviewTargetSnapshot = vi.fn() + +vi.doMock('@/lib/yjs/server/bootstrap-review-target', () => ({ + readBootstrappedReviewTargetSnapshot: mockReadBootstrappedReviewTargetSnapshot, })) vi.doMock('@/lib/chat/published-deployment', () => ({ @@ -146,6 +141,20 @@ function buildWorkflowSnapshotResponse(update: Uint8Array) { } } +function buildWorkflowSnapshotResponseFromState( + workflowState: Parameters<typeof setWorkflowState>[1], + variables: Record<string, any> = {} +) { + 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', @@ -278,7 +287,9 @@ const mockWorkflowState: WorkflowState = { deploymentStatuses: {}, } -const createMockTx = (overrides: Partial<Record<'delete' | 'execute' | 'insert' | 'update', any>> = {}) => ({ +const createMockTx = ( + overrides: Partial<Record<'delete' | 'execute' | 'insert' | 'update', any>> = {} +) => ({ execute: overrides.execute ?? vi.fn().mockResolvedValue([]), update: overrides.update ?? @@ -316,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({ @@ -409,7 +419,7 @@ describe('Database Helpers', () => { }) }) - it('should return null when no blocks are found', async () => { + it('should load an empty workflow state when no normalized rows are found', async () => { // Mock empty results from all queries mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ @@ -419,10 +429,16 @@ describe('Database Helpers', () => { const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) - expect(result).toBeNull() + expect(result).toEqual({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + isFromNormalizedTables: true, + }) }) - it('should return null when database query fails', async () => { + it('should throw when database query fails', async () => { // Mock database error mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ @@ -430,9 +446,9 @@ describe('Database Helpers', () => { }), }) - const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) - - expect(result).toBeNull() + await expect(dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId)).rejects.toThrow( + 'Database connection failed' + ) }) it('should handle unknown subflow types gracefully', async () => { @@ -515,9 +531,9 @@ describe('Database Helpers', () => { expect(result?.blocks['block-1'].name).toBeNull() }) - it('should handle database connection errors gracefully', async () => { + it('should throw database connection errors', async () => { const connectionError = new Error('Connection refused') - ; (connectionError as any).code = 'ECONNREFUSED' + ;(connectionError as any).code = 'ECONNREFUSED' // Mock database connection error mockDb.select.mockReturnValue({ @@ -526,9 +542,9 @@ describe('Database Helpers', () => { }), }) - const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) - - expect(result).toBeNull() + await expect(dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId)).rejects.toThrow( + 'Connection refused' + ) }) }) @@ -538,19 +554,35 @@ describe('Database Helpers', () => { }) it('should successfully save workflow data to normalized tables', async () => { + const variables = { + 'var-1': { id: 'var-1', workflowId: mockWorkflowId, name: 'risk', value: '1' }, + } + let capturedWorkflowUpdate: Record<string, unknown> | undefined const mockTransaction = vi.fn().mockImplementation(async (callback) => - callback(createMockTx()) + callback( + createMockTx({ + update: vi.fn((table) => ({ + set: vi.fn((data: Record<string, unknown>) => ({ + 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) @@ -567,9 +599,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 +628,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 @@ -678,6 +710,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[] = [] @@ -777,123 +876,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() @@ -932,9 +914,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 @@ -948,44 +930,39 @@ 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 = { + it('should deploy the current workflow state', async () => { + const savedVariables = { + 'var-db': { + id: 'var-db', + name: 'Persisted variable', + type: 'plain', + value: 'latest', + }, + } + const currentState = { blocks: { - 'block-yjs': { - id: 'block-yjs', - type: 'api', - name: 'Persisted block', - position: { x: 10, y: 20 }, + 'block-1': { + id: 'block-1', + type: 'input_trigger', + name: 'Trigger Block', + position: { x: 100, y: 100 }, subBlocks: {}, outputs: {}, enabled: true, }, }, - edges: [], + edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2' }], loops: {}, parallels: {}, - lastSaved: new Date().toISOString(), - } - const yjsVariables = { - 'var-yjs': { - id: 'var-yjs', - name: 'Persisted variable', - type: 'plain', - value: 'latest', - }, + lastSaved: '2026-04-06T00:00:00.000Z', + isDeployed: false, } - setWorkflowState(doc, yjsState, 'test') - setVariables(doc, yjsVariables, 'test') - - mockGetYjsSnapshot.mockResolvedValue( - buildWorkflowSnapshotResponse(Y.encodeStateAsUpdate(doc)) + mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( + buildWorkflowSnapshotResponseFromState(currentState, savedVariables) ) - const updateCalls: Array<{ table: unknown; data: Record<string, unknown> }> = [] const insertCalls: Array<{ table: unknown; data: Record<string, unknown> }> = [] - const workflowLastSaved = new Date('2026-04-06T00:00:00.000Z') const tx = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ @@ -1009,19 +986,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', @@ -1029,368 +993,110 @@ describe('Database Helpers', () => { }) expect(result.success).toBe(true) - expect(mockGetYjsSnapshot).toHaveBeenCalledWith( - mockWorkflowId, - expect.objectContaining({ - targetKind: 'workflow', - sessionId: mockWorkflowId, - workflowId: mockWorkflowId, - entityKind: 'workflow', - entityId: mockWorkflowId, - }) - ) - expect(mockDb.select).toHaveBeenCalledTimes(1) + expect(mockReadBootstrappedReviewTargetSnapshot).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, }), }) ) }) }) - describe('loadWorkflowStateFromYjs', () => { - it('should decode the workflow state from the socket-server bridge snapshot', async () => { - const doc = new Y.Doc() + describe('requireWorkflowRealtimeState', () => { + it('loads workflow state through a bootstrapped Yjs session', async () => { const yjsState = { - blocks: { - 'block-yjs': { - id: 'block-yjs', - type: 'api', - name: 'Live block', - position: { x: 10, y: 20 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, + direction: 'LR' as const, + blocks: {}, edges: [], loops: {}, parallels: {}, lastSaved: new Date().toISOString(), } const yjsVariables = { - 'var-yjs': { - id: 'var-yjs', - name: 'Live variable', - type: 'plain', - value: 'latest', - }, + 'var-yjs': { id: 'var-yjs', value: 'latest' }, } - setWorkflowState(doc, yjsState, 'test') - setVariables(doc, yjsVariables, 'test') - mockGetYjsSnapshot.mockResolvedValue( - buildWorkflowSnapshotResponse(Y.encodeStateAsUpdate(doc)) + mockReadBootstrappedReviewTargetSnapshot.mockResolvedValue( + buildWorkflowSnapshotResponseFromState(yjsState, yjsVariables) ) - const result = await dbHelpers.loadWorkflowStateFromYjs(mockWorkflowId) + const result = await dbHelpers.requireWorkflowRealtimeState(mockWorkflowId) - expect(mockGetYjsSnapshot).toHaveBeenCalledWith( - mockWorkflowId, - expect.objectContaining({ - targetKind: 'workflow', - sessionId: mockWorkflowId, - workflowId: mockWorkflowId, - entityKind: 'workflow', - entityId: mockWorkflowId, - }) - ) - expect(result).toMatchObject({ - blocks: yjsState.blocks, - edges: yjsState.edges, - loops: yjsState.loops, - parallels: yjsState.parallels, - variables: yjsVariables, + expect(mockReadBootstrappedReviewTargetSnapshot).toHaveBeenCalledWith({ + workspaceId: null, + entityKind: 'workflow', + entityId: mockWorkflowId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: mockWorkflowId, }) - }) - }) - - describe('loadWorkflowState', () => { - it('returns the Yjs state without a workflow-row query when lastSynced is provided', 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') - mockGetYjsSnapshot.mockResolvedValue( - buildWorkflowSnapshotResponse(Y.encodeStateAsUpdate(doc)) - ) - - const result = await dbHelpers.loadWorkflowState( - mockWorkflowId, - new Date('2026-04-06T00:00:00.000Z') - ) - expect(result).toMatchObject({ - blocks: yjsState.blocks, - edges: yjsState.edges, - loops: yjsState.loops, - parallels: yjsState.parallels, + direction: 'LR', variables: yjsVariables, - source: 'yjs', }) expect(mockDb.select).not.toHaveBeenCalled() }) - it('queries the workflow row for staleness when lastSynced is omitted and the Yjs snapshot is fresh', 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, - }, + 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, }, - edges: [], - loops: {}, - parallels: {}, - lastSaved: '2026-04-06T00:05:00.000Z', - } - const yjsVariables = { - 'var-yjs': { - id: 'var-yjs', - name: 'Live variable', - type: 'plain', - value: 'latest', + runtime: { + docState: 'expired', + replaySafe: false, + reseededFromCanonical: false, }, - } - - setWorkflowState(doc, yjsState, 'test') - setVariables(doc, yjsVariables, 'test') - 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) + const result = await dbHelpers.requireWorkflowRealtimeState(mockWorkflowId) - expect(result).toMatchObject({ - blocks: yjsState.blocks, - edges: yjsState.edges, - loops: yjsState.loops, - parallels: yjsState.parallels, - variables: yjsVariables, - source: 'yjs', - }) - expect(mockDb.select).toHaveBeenCalledTimes(1) + expect(result).toBeNull() + expect(mockDb.select).not.toHaveBeenCalled() }) - it('loads normalized tables 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', - }) - }) + it('requires the live Yjs bridge for editable workflow state', async () => { + mockReadBootstrappedReviewTargetSnapshot.mockRejectedValue(new Error('bridge unavailable')) - it('loads normalized tables when the stored Yjs snapshot is older than workflow lastSynced', 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' + await expect(dbHelpers.requireWorkflowRealtimeState(mockWorkflowId)).rejects.toThrow( + 'bridge unavailable' ) - mockGetYjsSnapshot.mockResolvedValue( - 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', - }), - }), - source: 'normalized', - }) - 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..ccb8769a3 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.ts @@ -12,18 +12,11 @@ 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 { 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' +import { inferWorkflowDirectionFromState } from '@/lib/workflows/workflow-direction' +import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' +import { extractPersistedStateFromDoc, setWorkflowState } from '@/lib/yjs/workflow-session' import type { BlockState, Loop, @@ -35,6 +28,13 @@ import { SUBFLOW_TYPES } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowDBHelpers') +type PersistableWorkflowState = WorkflowState & { + name?: string + description?: string | null + folderId?: string | null + variables?: Record<string, any> +} + const resolveLockedFromBlockData = (data: unknown): boolean => { if (!data || typeof data !== 'object' || Array.isArray(data)) { return false @@ -81,6 +81,9 @@ const sanitizeBlockLayout = (layout: unknown): BlockState['layout'] => { } export type PersistedWorkflowState = { + name?: string | null + description?: string | null + folderId?: string | null direction?: WorkflowDirection blocks: Record<string, any> edges: any[] @@ -90,191 +93,102 @@ 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. - * - * 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 -): Promise<PersistedWorkflowState | null> { - try { - const snapshot = await getYjsSnapshot( - workflowId, - serializeYjsTransportEnvelope( - buildYjsTransportEnvelope({ - workspaceId: null, - entityKind: 'workflow', - entityId: workflowId, - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: workflowId, - }) - ) - ) +export const WORKFLOW_REALTIME_REQUIRED_CODE = 'WORKFLOW_REALTIME_REQUIRED' - if (!snapshot.snapshotBase64) { - return null - } +export class WorkflowRealtimeRequiredError extends Error { + readonly code = WORKFLOW_REALTIME_REQUIRED_CODE - const doc = new Y.Doc() - try { - Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) - return extractPersistedStateFromDoc(doc) - } finally { - doc.destroy() - } - } catch (error) { - if (error instanceof SocketServerBridgeError && error.status === 404) { - return null - } - throw error + constructor(cause: unknown) { + super( + cause instanceof Error + ? cause.message + : 'Editable workflow realtime orchestration is required' + ) + this.name = 'WorkflowRealtimeRequiredError' } } -export type WorkflowStateWithSource = PersistedWorkflowState & { - source: 'yjs' | 'normalized' +export const isWorkflowRealtimeRequiredError = ( + error: unknown +): error is WorkflowRealtimeRequiredError => error instanceof WorkflowRealtimeRequiredError + +function decodeWorkflowSnapshot(snapshotBase64: string): PersistedWorkflowState | null { + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, Buffer.from(snapshotBase64, 'base64')) + return extractPersistedStateFromDoc(doc) + } finally { + doc.destroy() + } } /** - * 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. + * 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. Bridge + * failures intentionally surface instead of falling back to stale saved tables. */ -export async function loadWorkflowState( - workflowId: string, - lastSynced?: Date -): Promise<WorkflowStateWithSource | null> { - 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 +export async function requireWorkflowRealtimeState( + workflowId: string +): Promise<PersistedWorkflowState | null> { + 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, + }).catch((error) => { + throw new WorkflowRealtimeRequiredError(error) + }) + if (!snapshot.snapshotBase64) { + return null } - 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(), - } - ) - } - } catch (error) { - logger.warn( - `Failed to load authoritative Yjs state for workflow ${workflowId}; loading normalized state`, - error - ) - } + const state = decodeWorkflowSnapshot(snapshot.snapshotBase64) + return state +} - // Load normalized tables and workflow variables in parallel - const [normalizedData, resolvedWorkflowRow] = await Promise.all([ +export async function loadWorkflowBootstrapStateFromDb( + workflowId: string +): Promise<PersistedWorkflowState | null> { + const [workflowRow, normalizedState] = await Promise.all([ + db + .select({ + name: workflow.name, + description: workflow.description, + folderId: workflow.folderId, + variables: workflow.variables, + updatedAt: workflow.updatedAt, + }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1), loadWorkflowFromNormalizedTables(workflowId), - loadWorkflowRow(), ]) - - if (!normalizedData) { + const row = workflowRow[0] + if (!row) { 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', + const savedState = { + name: row.name, + description: row.description, + folderId: row.folderId, + blocks: normalizedState.blocks, + edges: normalizedState.edges, + loops: normalizedState.loops, + parallels: normalizedState.parallels, + variables: (row.variables as Record<string, any>) ?? {}, + lastSaved: row.updatedAt?.getTime() ?? Date.now(), } -} -/** - * 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() -} - -/** - * 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<string, Variable>, - newWorkflowId: string -): Record<string, Variable> { - const remapped: Record<string, Variable> = {} - - for (const variable of Object.values(sourceVariables)) { - const newVarId = crypto.randomUUID() - remapped[newVarId] = { - ...variable, - id: newVarId, - workflowId: newWorkflowId, - } + return { + ...savedState, + direction: inferWorkflowDirectionFromState(savedState), } - - return remapped } export async function ensureUniqueBlockIds( @@ -347,6 +261,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, } }) @@ -482,6 +406,7 @@ export interface NormalizedWorkflowData { edges: Edge[] loops: Record<string, Loop> parallels: Record<string, Parallel> + variables?: Record<string, any> isFromNormalizedTables: boolean // Flag to indicate source (true = normalized tables, false = deployed state) } @@ -646,13 +571,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<string, any> } return { blocks: state.blocks || {}, edges: state.edges || [], loops: state.loops || {}, parallels: state.parallels || {}, + variables: state.variables || {}, isFromNormalizedTables: false, } } catch (error) { @@ -663,120 +589,104 @@ export async function loadDeployedWorkflowState( /** * Load workflow state from normalized tables - * Returns null if no normalized data exists. */ export async function loadWorkflowFromNormalizedTables( workflowId: string -): Promise<NormalizedWorkflowData | null> { - try { - // Load all components in parallel - const [blocks, edges, subflows] = await Promise.all([ - db.select().from(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)), - db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, workflowId)), - db.select().from(workflowSubflows).where(eq(workflowSubflows.workflowId, workflowId)), - ]) - - // If no blocks found, assume this workflow hasn't been migrated yet - if (blocks.length === 0) { - return null +): Promise<NormalizedWorkflowData> { + const [blocks, edges, subflows] = await Promise.all([ + db.select().from(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)), + db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, workflowId)), + db.select().from(workflowSubflows).where(eq(workflowSubflows.workflowId, workflowId)), + ]) + + const blocksMap: Record<string, BlockState> = {} + blocks.forEach((block) => { + const blockLocked = resolveLockedFromBlockData(block.data) + const blockData = upsertLockedInBlockData(block.data || {}, blockLocked) + + const assembled: BlockState = { + id: block.id, + type: block.type, + name: block.name, + position: { + x: Number(block.positionX), + y: Number(block.positionY), + }, + enabled: block.enabled, + horizontalHandles: block.horizontalHandles, + isWide: block.isWide, + advancedMode: block.advancedMode, + triggerMode: block.triggerMode, + height: Number(block.height), + locked: blockLocked, + subBlocks: (block.subBlocks as BlockState['subBlocks']) || {}, + outputs: (block.outputs as BlockState['outputs']) || {}, + data: blockData, + layout: sanitizeBlockLayout(block.layout), } - // Convert blocks to the expected format - const blocksMap: Record<string, BlockState> = {} - blocks.forEach((block) => { - const blockLocked = resolveLockedFromBlockData(block.data) - const blockData = upsertLockedInBlockData(block.data || {}, blockLocked) - - const assembled: BlockState = { - id: block.id, - type: block.type, - name: block.name, - position: { - x: Number(block.positionX), - y: Number(block.positionY), - }, - enabled: block.enabled, - horizontalHandles: block.horizontalHandles, - isWide: block.isWide, - advancedMode: block.advancedMode, - triggerMode: block.triggerMode, - height: Number(block.height), - locked: blockLocked, - subBlocks: (block.subBlocks as BlockState['subBlocks']) || {}, - outputs: (block.outputs as BlockState['outputs']) || {}, - data: blockData, - layout: sanitizeBlockLayout(block.layout), - } + blocksMap[block.id] = assembled + }) - blocksMap[block.id] = assembled - }) + const { blocks: sanitizedBlocks } = sanitizeAgentToolsInBlocks(blocksMap) - // Sanitize any invalid custom tools in agent blocks to prevent client crashes - const { blocks: sanitizedBlocks } = sanitizeAgentToolsInBlocks(blocksMap) - - // Convert edges to the expected format - const edgesArray: Edge[] = edges.map((edge) => ({ - id: edge.id, - source: edge.sourceBlockId, - target: edge.targetBlockId, - sourceHandle: edge.sourceHandle ?? undefined, - targetHandle: edge.targetHandle ?? undefined, - type: 'default', - data: {}, - })) - - // Convert subflows to loops and parallels - const loops: Record<string, Loop> = {} - const parallels: Record<string, Parallel> = {} - - subflows.forEach((subflow) => { - const config = (subflow.config ?? {}) as Partial<Loop & Parallel> - - if (subflow.type === SUBFLOW_TYPES.LOOP) { - const loop: Loop = { - id: subflow.id, - nodes: Array.isArray((config as Loop).nodes) ? (config as Loop).nodes : [], - iterations: - typeof (config as Loop).iterations === 'number' ? (config as Loop).iterations : 1, - loopType: - (config as Loop).loopType === 'for' || - (config as Loop).loopType === 'forEach' || - (config as Loop).loopType === 'while' || - (config as Loop).loopType === 'doWhile' - ? (config as Loop).loopType - : 'for', - forEachItems: (config as Loop).forEachItems ?? '', - whileCondition: (config as Loop).whileCondition ?? undefined, - } - loops[subflow.id] = loop - } else if (subflow.type === SUBFLOW_TYPES.PARALLEL) { - const parallel: Parallel = { - id: subflow.id, - nodes: Array.isArray((config as Parallel).nodes) ? (config as Parallel).nodes : [], - count: typeof (config as Parallel).count === 'number' ? (config as Parallel).count : 2, - distribution: (config as Parallel).distribution ?? '', - parallelType: - (config as Parallel).parallelType === 'count' || - (config as Parallel).parallelType === 'collection' - ? (config as Parallel).parallelType - : 'count', - } - parallels[subflow.id] = parallel - } else { - logger.warn(`Unknown subflow type: ${subflow.type} for subflow ${subflow.id}`) - } - }) + const edgesArray: Edge[] = edges.map((edge) => ({ + id: edge.id, + source: edge.sourceBlockId, + target: edge.targetBlockId, + sourceHandle: edge.sourceHandle ?? undefined, + targetHandle: edge.targetHandle ?? undefined, + type: 'default', + data: {}, + })) - return { - blocks: sanitizedBlocks, - edges: edgesArray, - loops, - parallels, - isFromNormalizedTables: true, + const loops: Record<string, Loop> = {} + const parallels: Record<string, Parallel> = {} + + subflows.forEach((subflow) => { + const config = (subflow.config ?? {}) as Partial<Loop & Parallel> + + if (subflow.type === SUBFLOW_TYPES.LOOP) { + const loop: Loop = { + id: subflow.id, + nodes: Array.isArray((config as Loop).nodes) ? (config as Loop).nodes : [], + iterations: + typeof (config as Loop).iterations === 'number' ? (config as Loop).iterations : 1, + loopType: + (config as Loop).loopType === 'for' || + (config as Loop).loopType === 'forEach' || + (config as Loop).loopType === 'while' || + (config as Loop).loopType === 'doWhile' + ? (config as Loop).loopType + : 'for', + forEachItems: (config as Loop).forEachItems ?? '', + whileCondition: (config as Loop).whileCondition ?? undefined, + } + loops[subflow.id] = loop + } else if (subflow.type === SUBFLOW_TYPES.PARALLEL) { + const parallel: Parallel = { + id: subflow.id, + nodes: Array.isArray((config as Parallel).nodes) ? (config as Parallel).nodes : [], + count: typeof (config as Parallel).count === 'number' ? (config as Parallel).count : 2, + distribution: (config as Parallel).distribution ?? '', + parallelType: + (config as Parallel).parallelType === 'count' || + (config as Parallel).parallelType === 'collection' + ? (config as Parallel).parallelType + : 'count', + } + parallels[subflow.id] = parallel + } else { + logger.warn(`Unknown subflow type: ${subflow.type} for subflow ${subflow.id}`) } - } catch (error) { - logger.error(`Error loading workflow ${workflowId} from normalized tables:`, error) - return null + }) + + return { + blocks: sanitizedBlocks, + edges: edgesArray, + loops, + parallels, + isFromNormalizedTables: true, } } @@ -785,11 +695,15 @@ export async function loadWorkflowFromNormalizedTables( */ export async function saveWorkflowToNormalizedTables( workflowId: string, - state: WorkflowState + state: PersistableWorkflowState ): Promise<{ success: boolean; error?: string; normalizedState?: WorkflowState }> { try { - const stateWithUniqueBlockIds = await ensureUniqueBlockIds(workflowId, state) - const normalizedState = await ensureUniqueEdgeIds(workflowId, stateWithUniqueBlockIds) + 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)) { @@ -941,6 +855,18 @@ export async function saveWorkflowToNormalizedTables( if (subflowInserts.length > 0) { await tx.insert(workflowSubflows).values(subflowInserts) } + + 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 } @@ -961,51 +887,34 @@ export async function saveWorkflowToNormalizedTables( } } -/** - * Check if a workflow exists in normalized tables - */ -export async function workflowExistsInNormalizedTables(workflowId: string): Promise<boolean> { - 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 +export async function saveWorkflowYjsDocToDb(workflowId: string, doc: Y.Doc): Promise<void> { + const state = extractPersistedStateFromDoc(doc) + const syncedAt = new Date() + 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(), } -} -/** - * 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, - } + const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState) - 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', - } + 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 + ) } } @@ -1039,11 +948,11 @@ export async function deployWorkflow(params: { } = params try { - const stateWithSource = await loadWorkflowState(workflowId) - if (!stateWithSource) { + const editableState = await requireWorkflowRealtimeState(workflowId) + if (!editableState) { return { success: false, error: 'Failed to load workflow state' } } - const currentState: PersistedWorkflowState = stateWithSource + const currentState = editableState const now = new Date() @@ -1158,6 +1067,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 6610a0f81..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(), - loadWorkflowFromNormalizedTables: vi.fn(), + requireWorkflowRealtimeState: vi.fn(), })) vi.mock('@/lib/workflows/triggers', () => ({ @@ -401,4 +401,79 @@ describe('loadWorkflowExecutionBlueprint', () => { expect(loadDeployedWorkflowState).not.toHaveBeenCalled() }) + + it('loads Yjs workflow state for live execution when no snapshot is supplied', async () => { + const { loadDeployedWorkflowState, requireWorkflowRealtimeState } = await import( + '@/lib/workflows/db-helpers' + ) + vi.mocked(requireWorkflowRealtimeState).mockResolvedValueOnce({ + blocks: { trigger: { subBlocks: {} } }, + edges: [{ source: 'trigger', target: 'worker' }], + loops: {}, + parallels: {}, + variables: { risk: { value: 1 } }, + lastSaved: Date.now(), + }) + + 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(requireWorkflowRealtimeState).toHaveBeenCalledWith('workflow-1') + expect(mocks.dbSelect).not.toHaveBeenCalled() + }) + + it('uses variables from the active deployment for deployed execution', async () => { + const { loadDeployedWorkflowState, requireWorkflowRealtimeState } = 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({}) + const selectShape = (mocks.dbSelect.mock.calls as unknown[][])[0]?.[0] as Record< + string, + unknown + > + expect(Object.keys(selectShape)).toEqual(['workspaceId']) + expect(requireWorkflowRealtimeState).not.toHaveBeenCalled() + }) }) diff --git a/apps/tradinggoose/lib/workflows/execution-runner.ts b/apps/tradinggoose/lib/workflows/execution-runner.ts index 4ecdd3b6a..43d4213d2 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, 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' @@ -92,19 +89,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 @@ -260,23 +265,44 @@ export async function loadWorkflowExecutionBlueprint(params: { workflowData?: WorkflowExecutionBlueprint['workflowData'] }): Promise<WorkflowExecutionBlueprint> { const executionTarget = params.executionTarget ?? 'deployed' + const liveWorkflowState = + executionTarget === 'live' && !params.workflowData + ? await requireWorkflowRealtimeState(params.workflowId) + : null const workflowContext = await resolveRequiredWorkflowExecutionContext( params.workflowId, - 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' - ? (params.workflowData ?? (await loadWorkflowFromNormalizedTables(params.workflowId))) + ? (params.workflowData ?? liveWorkflowState) : await loadDeployedWorkflowState(params.workflowId) if (!workflowData) { throw new Error(`Workflow ${params.workflowId} has no ${executionTarget} state`) } + const deployedVariables = + executionTarget === 'deployed' + ? ((workflowData as { variables?: Record<string, any> }).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/workflows/import-export.ts b/apps/tradinggoose/lib/workflows/import-export.ts index 5fd04f285..d6013b4f9 100644 --- a/apps/tradinggoose/lib/workflows/import-export.ts +++ b/apps/tradinggoose/lib/workflows/import-export.ts @@ -10,7 +10,9 @@ import { SkillTransferSchema, } from '@/lib/skills/import-export' 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' @@ -45,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<string, Variable>, + newWorkflowId: string +): Record<string, Variable> { + const remapped: Record<string, Variable> = {} + + 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'), @@ -184,6 +203,7 @@ function validateWorkflowState(input: unknown): { edges: workflowState.edges || [], loops: workflowState.loops || {}, parallels: workflowState.parallels || {}, + variables: normalizeVariables(workflowState.variables), }, errors: [], } @@ -265,7 +285,7 @@ function readWorkflowSkillValues( ) } -function collectWorkflowSkillIds(state: WorkflowState): string[] { +export function collectWorkflowSkillIds(state: WorkflowState): string[] { const orderedSkillIds: string[] = [] const seenSkillIds = new Set<string>() @@ -433,22 +453,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/lib/workflows/import.test.ts b/apps/tradinggoose/lib/workflows/import.test.ts index 8ad10e55b..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,49 +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: {}, - }) - - expect(Object.keys((state as { blocks: Record<string, unknown> }).blocks)).toHaveLength(1) - - const [firstBlock] = Object.values( - (state as { blocks: Record<string, { type: string; name: string }> }).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 () => { @@ -142,6 +127,15 @@ describe('workflow import orchestration', () => { edges: [], loops: {}, parallels: {}, + variables: { + 'var-1': { + id: 'var-1', + workflowId: 'workflow-source', + name: 'risk', + type: 'plain', + value: 'medium', + }, + }, }, }, ], @@ -172,46 +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<string, unknown> + 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 { - blocks: Record< - string, + expect(firstBlock?.subBlocks?.skills?.value).toEqual([ { - subBlocks?: Record< - string, - { - value?: Array<{ skillId: string; name: string }> - } - > - } - > + skillId: 'skill-1', + name: 'Market Research (imported) 1', + }, + { + skillId: 'skill-2', + name: 'Execution Plan', + }, + ]) + return 'workflow-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), @@ -219,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 ec8eae3ef..fc4f73505 100644 --- a/apps/tradinggoose/lib/workflows/import.ts +++ b/apps/tradinggoose/lib/workflows/import.ts @@ -1,10 +1,13 @@ import { createLogger } from '@/lib/logs/console/logger' -import { resolveImportedWorkflowName } from '@/lib/workflows/import-export' +import { + resolveImportedWorkflowName, + type WorkflowTransferRecord, +} from '@/lib/workflows/import-export' import { parseWorkflowJson } from '@/stores/workflows/json/importer' -import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowImport') const normalizeInlineWhitespace = (value: string) => value.trim().replace(/\s+/g, ' ') +type ImportedWorkflowState = WorkflowTransferRecord['state'] type ImportedWorkflowSkill = { skillId: string @@ -15,6 +18,7 @@ type CreateWorkflowParams = { name: string description: string workspaceId: string + initialWorkflowState: ImportedWorkflowState } type ImportWorkflowFromJsonContentParams = { @@ -23,14 +27,13 @@ type ImportWorkflowFromJsonContentParams = { existingWorkflowNames: Iterable<string> importedSkillsBySourceName?: Map<string, ImportedWorkflowSkill> createWorkflow: (params: CreateWorkflowParams) => Promise<string> - persistWorkflowState: (workflowId: string, state: WorkflowState) => Promise<void> } function relinkWorkflowSkillValues( - state: WorkflowState, + state: ImportedWorkflowState, importedSkillsBySourceName: Map<string, ImportedWorkflowSkill> -): WorkflowState { - const clonedState = JSON.parse(JSON.stringify(state)) as WorkflowState +): ImportedWorkflowState { + const clonedState = JSON.parse(JSON.stringify(state)) as ImportedWorkflowState Object.entries(clonedState.blocks).forEach(([blockId, block]) => { const skillSubBlock = block.subBlocks?.skills @@ -91,20 +94,20 @@ export async function importWorkflowFromJsonContent({ existingWorkflowNames, importedSkillsBySourceName, createWorkflow, - persistWorkflowState, }: ImportWorkflowFromJsonContentParams): Promise<string> { if (!workspaceId) { throw new Error('Workspace ID is required to import workflows') } const { data: parsedWorkflowData, errors } = parseWorkflowJson(content, true) - let workflowData = parsedWorkflowData - if (!workflowData || errors.length > 0) { + if (!parsedWorkflowData || errors.length > 0) { const message = errors[0] ?? 'Failed to parse workflow import file' throw new Error(message) } + let workflowData: WorkflowTransferRecord = parsedWorkflowData + if (workflowData.skills.length > 0) { if (!importedSkillsBySourceName || importedSkillsBySourceName.size === 0) { throw new Error('Workflow import includes skills but no imported skills were provided') @@ -121,6 +124,7 @@ export async function importWorkflowFromJsonContent({ name: resolvedName, description: workflowData.description, workspaceId, + initialWorkflowState: workflowData.state, }) logger.info('Created workflow row for imported workflow', { @@ -128,11 +132,5 @@ export async function importWorkflowFromJsonContent({ workflowName: resolvedName, }) - await persistWorkflowState(workflowId, workflowData.state) - - logger.info('Persisted imported workflow state', { - workflowId, - }) - return workflowId } diff --git a/apps/tradinggoose/lib/workflows/json-sanitizer.ts b/apps/tradinggoose/lib/workflows/json-sanitizer.ts index 955216cbf..5c8daa88f 100644 --- a/apps/tradinggoose/lib/workflows/json-sanitizer.ts +++ b/apps/tradinggoose/lib/workflows/json-sanitizer.ts @@ -1,4 +1,5 @@ import type { Edge } from '@xyflow/react' +import type { Variable } from '@/stores/variables/types' import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types' /** @@ -49,6 +50,7 @@ export interface ExportWorkflowState { edges: Edge[] loops: Record<string, Loop> parallels: Record<string, Parallel> + variables: Record<string, Variable> } } @@ -368,7 +370,9 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState { * Sanitize workflow state for export by removing secrets but keeping positions * Users need positions to restore the visual layout when importing */ -export function sanitizeForExport(state: WorkflowState): ExportWorkflowState { +export function sanitizeForExport( + state: WorkflowState & { variables?: Record<string, Variable> } +): ExportWorkflowState { // Deep clone to avoid mutating original state const clonedState = JSON.parse( JSON.stringify({ @@ -376,6 +380,7 @@ export function sanitizeForExport(state: WorkflowState): ExportWorkflowState { edges: state.edges, loops: state.loops || {}, parallels: state.parallels || {}, + variables: state.variables || {}, }) ) diff --git a/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts b/apps/tradinggoose/lib/workflows/studio-workflow-mermaid.test.ts index f1db52c16..c377f4a6b 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, @@ -145,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 = { @@ -646,54 +643,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..ab3ebb1bb 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, @@ -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}<branch>". 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 ?? - inferMermaidDirectionFromWorkflowState(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) @@ -1925,9 +1920,7 @@ export function serializeWorkflowToTgMermaid( toCommentLine(TG_WORKFLOW_PREFIX, { version: TG_MERMAID_DOCUMENT_FORMAT, direction, - ...(workflowState.lastSaved ? { lastSaved: workflowState.lastSaved } : {}), - ...(workflowState.isDeployed !== undefined ? { isDeployed: workflowState.isDeployed } : {}), - ...(workflowState.deployedAt ? { deployedAt: workflowState.deployedAt } : {}), + ...(workflowState.lastSaved ? { lastSaved: String(workflowState.lastSaved) } : {}), } satisfies WorkflowDocumentMetadata), ] @@ -1972,9 +1965,7 @@ export function serializeWorkflowToGraphMermaid( options: { direction?: WorkflowDirection } = {} ): string { const direction = - options.direction ?? - workflowState.direction ?? - inferMermaidDirectionFromWorkflowState(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) @@ -2113,79 +2104,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<Pick<Edge, 'source' | 'target' | 'sourceHandle' | 'targetHandle'>> - removed: Array<Pick<Edge, 'source' | 'target' | 'sourceHandle' | 'targetHandle'>> - } - 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/workspaces/service.ts b/apps/tradinggoose/lib/workspaces/service.ts index aef7c5a75..346f08383 100644 --- a/apps/tradinggoose/lib/workspaces/service.ts +++ b/apps/tradinggoose/lib/workspaces/service.ts @@ -1,24 +1,13 @@ import { db } from '@tradinggoose/db' -import { permissions, workflow, workspace } from '@tradinggoose/db/schema' -import { and, desc, eq, isNull } 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' -import { tryApplyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' -import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' type WorkspaceRecord = typeof workspace.$inferSelect +const DEFAULT_WORKSPACE_BOOTSTRAP_LOCK_NAMESPACE = 1_904_202_615 -export async function getUserWorkspaces({ - userId, - userName, - autoCreate = true, -}: { - userId: string - userName?: string | null - autoCreate?: boolean -}) { +export async function getUserWorkspaces({ userId }: { userId: string }) { const workspaceAccess = buildWorkspaceAccessScope(userId, workspace.id) const userWorkspaces = await db .select({ @@ -30,20 +19,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) { @@ -58,11 +33,36 @@ export async function getUserWorkspaces({ }) } -export async function createWorkspace(userId: string, name: string) { +export async function createDefaultWorkspaceForUser(userId: string, userName?: string | null) { + const firstName = userName?.split(' ')[0] || null + 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) + }) +} + +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, @@ -73,65 +73,9 @@ 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() - 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 seedResult = await tryApplyWorkflowState( - 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, - lastSaved, - isDeployed: false, - }), - undefined, - 'default-agent' - ) - if (!seedResult.success) { - throw seedResult.error instanceof Error - ? seedResult.error - : new Error('Failed to seed 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', @@ -139,27 +83,8 @@ export async function createWorkspace(userId: string, name: string) { } } -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))) +export async function createWorkspace(userId: string, name: string) { + const workspaceDetails = buildWorkspaceRecord(userId, name) + await db.insert(workspace).values(workspaceDetails) + return toOwnedWorkspaceApiRecord(workspaceDetails) } 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/entity-session.ts b/apps/tradinggoose/lib/yjs/entity-session.ts index 938722bf8..49534a7cc 100644 --- a/apps/tradinggoose/lib/yjs/entity-session.ts +++ b/apps/tradinggoose/lib/yjs/entity-session.ts @@ -6,12 +6,15 @@ * * 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 * - 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 */ @@ -33,6 +36,100 @@ export function getEntityMetadataMap(doc: Y.Doc): Y.Map<any> { return doc.getMap('metadata') } +export interface EntityListMember { + entityId: string + entityName: string + enabled?: boolean +} + +export type EntityListMemberMutation = + | { op: 'upsert'; entityId: string; name: string; enabled?: boolean } + | { op: 'remove'; entityId: string } + +function getEntityListMembersMap( + doc: Y.Doc +): Y.Map<{ name: string; enabled?: boolean; deleted?: boolean }> { + return doc.getMap('members') +} + +export function getEntityListMemberFromFields( + entityKind: Exclude<ReviewEntityKind, 'workflow'>, + entityId: string, + fields: Record<string, unknown> +): { 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 }> +): void { + doc.transact(() => { + const listMembers = getEntityListMembersMap(doc) + for (const member of members) { + listMembers.set(member.id, { + name: member.name, + ...(typeof member.enabled === 'boolean' ? { enabled: member.enabled } : {}), + }) + } + }, YJS_ORIGINS.SYSTEM) +} + +function applyEntityListMutation(doc: Y.Doc, mutation: EntityListMemberMutation): void { + doc.transact(() => { + getEntityListMembersMap(doc).set( + mutation.entityId, + mutation.op === 'upsert' + ? { + name: mutation.name, + deleted: false, + ...(typeof mutation.enabled === 'boolean' ? { enabled: mutation.enabled } : {}), + } + : { name: '', deleted: true } + ) + }, YJS_ORIGINS.SYSTEM) +} + +export function applyEntityListMutations( + doc: Y.Doc, + mutations: EntityListMemberMutation | EntityListMemberMutation[] +): void { + for (const mutation of Array.isArray(mutations) ? mutations : [mutations]) { + applyEntityListMutation(doc, mutation) + } +} + +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 : '', + ...(typeof value?.enabled === 'boolean' ? { enabled: value.enabled } : {}), + }) + }) + 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 + * 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 // --------------------------------------------------------------------------- @@ -88,6 +185,19 @@ 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) + 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': fields.set('name', payload.name ?? MCP_SERVER_DEFAULTS.name) fields.set('description', payload.description ?? MCP_SERVER_DEFAULTS.description) @@ -136,6 +246,15 @@ 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') + 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': 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..08dd1351b 100644 --- a/apps/tradinggoose/lib/yjs/entity-state.ts +++ b/apps/tradinggoose/lib/yjs/entity-state.ts @@ -1,43 +1,25 @@ -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 { getEntityFields } from '@/lib/yjs/entity-session' -import { getYjsSnapshot, SocketServerBridgeError } from '@/lib/yjs/server/snapshot-bridge' +import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' export type SavedEntityKind = Exclude<ReviewEntityKind, 'workflow'> -type SavedEntityRow = { +export type SavedEntityRow = { id: string workspaceId: string | null [key: string]: any } -function parseObjectJson(value: unknown, fieldName: string): Record<string, unknown> { - 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 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' } - return parsed as Record<string, unknown> -} -export function buildSavedEntityYjsDescriptor( - entityKind: SavedEntityKind, - entityId: string, - workspaceId: string -): ReviewTargetDescriptor { - return { - workspaceId, - entityKind, - entityId, - draftSessionId: null, - reviewSessionId: null, - yjsSessionId: entityId, + responseBody() { + return { error: this.message, code: this.code, retryable: this.retryable } } } @@ -69,6 +51,15 @@ export function savedEntityRowToFields( ? row.inputMeta : null, } + case 'knowledge_base': + return { + 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 { name: row.name ?? '', @@ -88,110 +79,3 @@ export function savedEntityRowToFields( } } } - -export function applySavedEntityFieldsToRow<T extends SavedEntityRow>( - entityKind: SavedEntityKind, - row: T, - fields: Record<string, unknown> -): 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 '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 readSavedEntityFieldsFromYjs( - entityKind: SavedEntityKind, - entityId: string, - workspaceId: string -): Promise<Record<string, unknown> | null> { - try { - const descriptor = buildSavedEntityYjsDescriptor(entityKind, entityId, workspaceId) - const snapshot = await getYjsSnapshot( - entityId, - serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor)) - ) - - if (!snapshot.snapshotBase64) { - return null - } - - 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) { - return null - } - throw error - } -} - -export async function applySavedEntityYjsStateToRow<T extends SavedEntityRow>( - entityKind: SavedEntityKind, - row: T -): Promise<T> { - if (!row.workspaceId) { - return row - } - - const fields = await readSavedEntityFieldsFromYjs(entityKind, row.id, row.workspaceId) - return fields ? applySavedEntityFieldsToRow(entityKind, row, fields) : row -} - -export async function applySavedEntityYjsStateToRows<T extends SavedEntityRow>( - entityKind: SavedEntityKind, - rows: T[] -): Promise<T[]> { - return Promise.all(rows.map((row) => applySavedEntityYjsStateToRow(entityKind, row))) -} 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<string> { const res = await fetch('/api/auth/socket-token', { @@ -59,7 +59,7 @@ async function fetchSnapshot( return res.json() } -export function waitForYjsWriteSync(provider: WebsocketProvider): Promise<void> { +export function waitForYjsSync(provider: WebsocketProvider): Promise<void> { if (provider.synced) { return Promise.resolve() } @@ -85,10 +85,10 @@ export function waitForYjsWriteSync(provider: WebsocketProvider): Promise<void> } 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 new file mode 100644 index 000000000..22b0d25ae --- /dev/null +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts @@ -0,0 +1,216 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' + +const { + events, + mockApplyEntityStateInSocketServer, + mockDbUpdate, + mockUpdateReturning, + mockUpdateSet, + mockUpdateWhere, +} = vi.hoisted(() => ({ + events: [] as string[], + mockApplyEntityStateInSocketServer: vi.fn(), + mockDbUpdate: 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', 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 })), +})) + +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, +})) + +function buildDoc(fields: Record<string, unknown>, 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 +} + +describe('applySavedEntityState', () => { + beforeEach(() => { + vi.clearAllMocks() + events.length = 0 + mockApplyEntityStateInSocketServer.mockImplementation(async () => { + events.push('yjs') + }) + 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 the socket-owned Yjs session without app-side DB materialization', async () => { + const { applySavedEntityState } = await import('./apply-entity-state') + + await applySavedEntityState('skill', 'skill-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(mockDbUpdate).not.toHaveBeenCalled() + expect(events).toEqual(['yjs']) + }) + + 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: 'Draft Indicator', + color: '#ff0000', + pineCode: 'indicator("Draft")', + inputMeta: { stale: true }, + }) + + try { + 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: 'Canonical Indicator', + color: '#ff0000', + pineCode: 'indicator("Draft")', + inputMeta, + updatedAt: expect.any(Date), + }) + expect(mockUpdateWhere).toHaveBeenCalledWith({ + and: [ + { field: 'pineIndicators.id', value: 'indicator-1' }, + { field: 'pineIndicators.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: '' }) + mockUpdateReturning.mockResolvedValueOnce([]) + + try { + await expect(saveSavedEntityYjsDocToDb('skill', 'skill-1', doc)).rejects.toMatchObject({ + status: 404, + }) + } finally { + doc.destroy() + } + }) + + 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('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')) + + await expect( + applySavedEntityState('skill', 'skill-1', { + name: 'Copilot Skill', + description: 'Copilot description', + content: 'Use the Copilot input.', + }) + ).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 98ba1d757..bd1dde1f1 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -1,10 +1,236 @@ +import { db } from '@tradinggoose/db' +import { + customTools, + knowledgeBase, + mcpServers, + pineIndicators, + skill, +} from '@tradinggoose/db/schema' +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, seedEntitySession } 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( + public status: number, + 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<string, unknown> { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record<string, unknown>) + : {} +} + +function isUniqueConstraintViolation(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === '23505' + ) +} + +async function mapUniqueConstraint<T>(operation: Promise<T>, message: string): Promise<T> { + try { + return await operation + } catch (error) { + if (isUniqueConstraintViolation(error)) { + throw new SavedEntityPersistenceError(409, message) + } + throw error + } +} + +function normalizeSavedEntityFields( + entityKind: SavedEntityKind, + fields: Record<string, unknown> +): Record<string, unknown> { + try { + return normalizeEntityFields(entityKind, fields) + } catch (error) { + throw new SavedEntityPersistenceError( + 400, + error instanceof Error ? error.message : 'Invalid saved entity fields' + ) + } +} + +export async function publishCreatedSavedEntityListMembers( + entityKind: SavedEntityKind, + workspaceId: string, + members: Array<{ id: string; name: string; enabled?: boolean }>, + afterRollback?: () => Promise<unknown> +): Promise<void> { + 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, + fields: Record<string, unknown>, + workspaceId: string +): Promise<void> { + const now = new Date() + let persisted: Array<{ id: string }> + + switch (entityKind) { + 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(and(eq(skill.id, entityId), eq(skill.workspaceId, workspaceId))) + .returning({ id: skill.id }), + `A skill with the name "${name}" already exists in this workspace` + ) + break + } + 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(and(eq(customTools.id, entityId), eq(customTools.workspaceId, workspaceId))) + .returning({ id: customTools.id }), + `A tool with the title "${title}" already exists in this workspace` + ) + 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(and(eq(pineIndicators.id, entityId), eq(pineIndicators.workspaceId, workspaceId))) + .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(and(eq(knowledgeBase.id, entityId), eq(knowledgeBase.workspaceId, workspaceId))) + .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(and(eq(mcpServers.id, entityId), eq(mcpServers.workspaceId, workspaceId))) + .returning({ id: mcpServers.id }) + break + } + + if (persisted.length === 0) { + throw new SavedEntityPersistenceError( + 404, + `Saved ${entityKind} ${entityId} was not found while materializing Yjs state` + ) + } +} export async function applySavedEntityState( entityKind: SavedEntityKind, entityId: string, fields: Record<string, unknown> ): Promise<void> { - await applyEntityStateInSocketServer(entityId, entityKind, fields) + const normalizedFields = normalizeSavedEntityFields(entityKind, fields) + 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' + ) + } +} + +export async function saveSavedEntityYjsDocToDb( + entityKind: SavedEntityKind, + entityId: string, + doc: Y.Doc +): Promise<void> { + const yjsFields = normalizeSavedEntityFields(entityKind, getEntityFields(doc, entityKind)) + 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) + seedEntitySession(doc, { entityKind, payload: 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 new file mode 100644 index 000000000..0c0a5eaba --- /dev/null +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts @@ -0,0 +1,169 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockApplyWorkflowPatchInSocketServer, + mockDbUpdate, + mockDbSelect, + mockEnsureUniqueBlockIds, + mockEnsureUniqueEdgeIds, + mockSelectFrom, + mockSelectLimit, + mockSelectWhere, +} = vi.hoisted(() => { + return { + mockApplyWorkflowPatchInSocketServer: vi.fn(), + mockDbUpdate: vi.fn(), + mockDbSelect: vi.fn(), + mockEnsureUniqueBlockIds: vi.fn(), + mockEnsureUniqueEdgeIds: vi.fn(), + mockSelectFrom: vi.fn(), + mockSelectLimit: vi.fn(), + mockSelectWhere: vi.fn(), + } +}) + +vi.mock('@tradinggoose/db', () => ({ + db: { + select: mockDbSelect, + update: mockDbUpdate, + }, + workflow: { + id: 'workflow.id', + }, +})) + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((field, value) => ({ field, value })), +})) + +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 + : 'Editable workflow realtime orchestration is required' + ) + this.name = 'WorkflowRealtimeRequiredError' + } + }, +})) + +vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ + applyWorkflowPatchInSocketServer: mockApplyWorkflowPatchInSocketServer, +})) + +const emptyWorkflowState = { blocks: {}, edges: [], loops: {}, parallels: {} } + +describe('applyWorkflowState', () => { + beforeEach(() => { + vi.clearAllMocks() + mockApplyWorkflowPatchInSocketServer.mockResolvedValue(undefined) + mockEnsureUniqueBlockIds.mockImplementation(async (_workflowId, state) => state) + mockEnsureUniqueEdgeIds.mockImplementation(async (_workflowId, state) => state) + mockSelectLimit.mockResolvedValue([{ id: 'workflow-1', name: 'Renamed Workflow' }]) + mockSelectWhere.mockReturnValue({ limit: mockSelectLimit }) + mockSelectFrom.mockReturnValue({ where: mockSelectWhere }) + mockDbSelect.mockReturnValue({ from: mockSelectFrom }) + }) + + it('updates workflow entity metadata through the socket-owned Yjs document', async () => { + const { applyWorkflowMetadata } = await import('./apply-workflow-state') + + const updatedWorkflow = await applyWorkflowMetadata('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(mockDbUpdate).not.toHaveBeenCalled() + expect(mockDbSelect.mock.invocationCallOrder[0]).toBeGreaterThan( + mockApplyWorkflowPatchInSocketServer.mock.invocationCallOrder[0] + ) + expect(updatedWorkflow).toMatchObject({ id: 'workflow-1', name: 'Renamed Workflow' }) + }) + + it('publishes normalized workflow state to the socket-owned Yjs document', async () => { + 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') + + await applyWorkflowState( + 'workflow-1', + { + blocks: { + 'input-block': { + id: 'input-block', + type: 'agent', + name: 'Input Agent', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }, + {}, + { name: 'Workflow Name' } + ) + + expect(mockApplyWorkflowPatchInSocketServer).toHaveBeenCalledWith( + 'workflow-1', + expect.objectContaining({ + 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 () => { + mockApplyWorkflowPatchInSocketServer.mockRejectedValueOnce(new TypeError('fetch failed')) + + const { applyWorkflowState } = await import('./apply-workflow-state') + + await expect(applyWorkflowState('workflow-1', emptyWorkflowState, {})).rejects.toThrow( + 'fetch failed' + ) + + 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 cf3a81986..bcc6a3c87 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -1,45 +1,67 @@ -import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' -import { createLogger } from '@/lib/logs/console/logger' -import { applyWorkflowStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { db, workflow } from '@tradinggoose/db' +import { eq } from 'drizzle-orm' +import { + ensureUniqueBlockIds, + ensureUniqueEdgeIds, + WorkflowRealtimeRequiredError, +} from '@/lib/workflows/db-helpers' +import { applyWorkflowPatchInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { + createWorkflowSnapshot, + type WorkflowMetadataPatch, + type WorkflowSnapshot, +} from '@/lib/yjs/workflow-session' -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, - * 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, variables?: Record<string, any>, - entityName?: string + metadata?: WorkflowMetadataPatch ): Promise<void> { - await applyWorkflowStateInSocketServer(workflowId, workflowState, variables, entityName) + const syncedAt = new Date() + const appliedWorkflowState = createWorkflowSnapshot({ + ...workflowState, + lastSaved: syncedAt.toISOString(), + }) + + const normalizedWorkflowState = await ensureUniqueEdgeIds( + workflowId, + await ensureUniqueBlockIds(workflowId, appliedWorkflowState) + ) + const storedWorkflowState = createWorkflowSnapshot({ + ...normalizedWorkflowState, + lastSaved: syncedAt.toISOString(), + }) + + try { + await applyWorkflowPatchInSocketServer(workflowId, { + workflowState: storedWorkflowState, + ...(variables === undefined ? {} : { variables }), + ...(metadata ? { metadata } : {}), + }) + } catch (error) { + throw new WorkflowRealtimeRequiredError(error) + } } -/** - * 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( +export async function applyWorkflowMetadata( workflowId: string, - workflowState: WorkflowSnapshot, - variables?: Record<string, any>, - entityName?: string -): Promise<{ success: boolean; error?: unknown }> { + metadata: WorkflowMetadataPatch +): Promise<typeof workflow.$inferSelect> { try { - await applyWorkflowState(workflowId, workflowState, variables, entityName) - return { success: true } + await applyWorkflowPatchInSocketServer(workflowId, { metadata }) } catch (error) { - logger.warn('Failed to apply workflow state to Yjs doc (non-fatal)', { - workflowId, - error, - }) - return { success: false, error } + throw new WorkflowRealtimeRequiredError(error) } + + const [updatedWorkflow] = await db + .select() + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + 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 d1d35622b..412db1f37 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -1,33 +1,38 @@ -import { db } from '@tradinggoose/db' -import { workflow } from '@tradinggoose/db/schema' -import { eq } from 'drizzle-orm' import * as Y from 'yjs' import { + buildEntityListDescriptor, + buildSavedEntityDescriptor, 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 { seedEntitySession } from '@/lib/yjs/entity-session' +import { loadWorkflowBootstrapStateFromDb } from '@/lib/workflows/db-helpers' import { - getMetadataMap as readWorkflowMetadataMap, + type EntityListMember, + getEntityFields, + getEntityListMembers, + seedEntityListSession, + seedEntitySession, +} from '@/lib/yjs/entity-session' +import { type SavedEntityKind, SavedEntityRealtimeRequiredError } 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 { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' +import { + createWorkflowSnapshot, + getMetadataMap, 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 { status: number @@ -39,12 +44,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) } @@ -59,294 +58,186 @@ 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 - } - } - - const resolved = await bootstrapReviewTarget(descriptor) - if (!resolved.runtime) { - throw new ReviewTargetBootstrapError(500, 'Bootstrap runtime missing') - } - - if (resolved.runtime.docState === 'expired') { - return { - snapshotBase64: '', - descriptor: resolved.descriptor, - runtime: resolved.runtime, - } - } - - const state = await getPersistedYjsState(resolved.descriptor.yjsSessionId) - if (!state) { - throw new ReviewTargetBootstrapError(500, 'Snapshot not available after bootstrap') - } - - return { - snapshotBase64: Buffer.from(state).toString('base64'), - descriptor: resolved.descriptor, - runtime: resolved.runtime, +function mapSavedEntitySnapshotError(error: unknown): never { + if (error instanceof SocketServerBridgeError && error.status < 500) { + throw new ReviewTargetBootstrapError(error.status, error.message) } + throw new SavedEntityRealtimeRequiredError() } -async function getExistingYjsState(sessionId: string): Promise<Uint8Array | null> { - 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 getBootstrapDoc(sessionId: string): Promise<Y.Doc> { - 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<void> { - const { storeState } = await import('@/socket-server/yjs/persistence') - await storeState(sessionId, Y.encodeStateAsUpdate(doc)) -} - -async function resolveExistingReviewTarget( - descriptor: ReviewTargetDescriptor -): Promise<ResolvedReviewTarget | null> { - const existingState = await getExistingYjsState(descriptor.yjsSessionId) - if (!existingState) { - return null - } - - return { - descriptor, - runtime: getRuntimeStateFromUpdate(existingState), - } +export async function readBootstrappedReviewTargetSnapshot(descriptor: ReviewTargetDescriptor) { + const bridgeParams = serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor)) + return getYjsSnapshot(descriptor.yjsSessionId, bridgeParams) } -/** - * 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. - */ -export async function bootstrapReviewTarget( - descriptor: ReviewTargetDescriptor -): Promise<ResolvedReviewTarget> { - const existing = await resolveExistingReviewTarget(descriptor) - if (existing) { - return existing - } - - if (descriptor.entityKind === 'workflow') { - return bootstrapWorkflowTarget(descriptor) - } - - if (descriptor.entityId) { - return bootstrapSavedEntityTarget(descriptor) +export async function requireSavedEntityRealtimeListMembers( + entityKind: SavedEntityKind, + workspaceId: string +): Promise<EntityListMember[]> { + const snapshot = await readBootstrappedReviewTargetSnapshot( + buildEntityListDescriptor(entityKind, workspaceId) + ).catch(mapSavedEntitySnapshotError) + if (!snapshot.snapshotBase64) { + return [] } - return { - descriptor, - runtime: { - docState: 'expired', - replaySafe: false, - reseededFromCanonical: false, - }, + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) + const activeEntityIds = new Set( + (await readEntityListMembersFromDb(entityKind, workspaceId)).map((member) => member.id) + ) + return getEntityListMembers(doc).filter((member) => activeEntityIds.has(member.entityId)) + } finally { + doc.destroy() } } -async function bootstrapWorkflowTarget( - descriptor: ReviewTargetDescriptor -): Promise<ResolvedReviewTarget> { - 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, +export async function requireSavedEntityRealtimeListFields( + entityKind: SavedEntityKind, + workspaceId: string +): Promise<Array<EntityListMember & { fields: Record<string, unknown> }>> { + const members = await requireSavedEntityRealtimeListMembers(entityKind, workspaceId) + 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 + } }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) + ) - if (!workflowRow) { - throw new ReviewTargetBootstrapError(404, 'Workflow target no longer exists') - } + return entries.filter( + (entry): entry is EntityListMember & { fields: Record<string, unknown> } => entry !== null + ) +} - 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(), +export async function readBootstrappedSavedEntityFields( + entityKind: SavedEntityKind, + entityId: string, + workspaceId: string +): Promise<Record<string, unknown>> { + const snapshot = await readBootstrappedReviewTargetSnapshot( + buildSavedEntityDescriptor(entityKind, entityId, workspaceId) + ).catch(mapSavedEntitySnapshotError) + if (!snapshot.snapshotBase64) { + throw new ReviewTargetBootstrapError(404, `Saved ${entityKind} ${entityId} state is missing`) } - const doc = await getBootstrapDoc(workflowId) - setWorkflowState(doc, workflowSnapshot, 'bootstrap') - setVariables(doc, ((workflowRow.variables as Record<string, any> | null) ?? {}) as Record<string, any>, '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, + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) + return getEntityFields(doc, entityKind) + } finally { + doc.destroy() } } -async function bootstrapSavedEntityTarget( +export async function createSavedReviewTargetBootstrapUpdate( descriptor: ReviewTargetDescriptor -): Promise<ResolvedReviewTarget> { +): Promise<ResolvedReviewTarget & { state: Uint8Array }> { if (!descriptor.entityId) { - throw new ReviewTargetBootstrapError(409, 'Saved entity target is missing entityId') + throw new ReviewTargetBootstrapError(404, 'Saved entity id is required') } - if (!descriptor.workspaceId) { - throw new ReviewTargetBootstrapError(409, 'Saved entity target is missing workspaceId') - } - - const canonical = await loadCanonicalEntitySeed(descriptor) - const doc = await getBootstrapDoc(descriptor.entityId) - - seedEntitySession(doc, { - entityKind: descriptor.entityKind, - payload: canonical.payload, - }) + const doc = new Y.Doc() + try { + 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) { + throw new ReviewTargetBootstrapError(404, 'Workflow not found') + } + workflowName = workflowState.name + workflowDescription = workflowState.description + workflowFolderId = workflowState.folderId + + 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') + } + resolvedWorkspaceId = workspaceId - doc.transact(() => { - doc.getMap('metadata').set('reseededFromCanonical', true) - }, 'bootstrap') + seedEntitySession(doc, { + entityKind, + payload: await readSavedEntityFieldsFromDb(entityKind, descriptor.entityId, workspaceId), + }) + } - await persistDoc(descriptor.entityId, doc) + const metadata = getMetadataMap(doc) + metadata.set('bootstrap-touch', Date.now()) + metadata.set('entityKind', descriptor.entityKind) + metadata.set('entityId', descriptor.entityId) + metadata.set('workspaceId', resolvedWorkspaceId) + metadata.set('draftSessionId', descriptor.draftSessionId) + metadata.set('reviewSessionId', descriptor.reviewSessionId) + metadata.set('reseededFromCanonical', true) + 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 { - descriptor: { - ...descriptor, - workspaceId: canonical.workspaceId, - entityId: descriptor.entityId, - reviewSessionId: null, - yjsSessionId: descriptor.entityId, - }, - runtime: ACTIVE_RESEEDED_RUNTIME, + return { + descriptor, + runtime: getRuntimeStateFromUpdate(state), + state, + } + } finally { + doc.destroy() } } -async function loadCanonicalEntitySeed(descriptor: ReviewTargetDescriptor): Promise<{ +export async function createEntityListBootstrapUpdate( + entityKind: SavedEntityKind, workspaceId: string - payload: Record<string, unknown> -}> { - 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') - } +): Promise<ResolvedReviewTarget & { state: Uint8Array }> { + const descriptor = buildEntityListDescriptor(entityKind, workspaceId) + const doc = new Y.Doc() + try { + seedEntityListSession(doc, await readEntityListMembersFromDb(entityKind, workspaceId)) - return { - workspaceId: row.workspaceId, - payload: { - name: row.name, - color: row.color, - pineCode: row.pineCode, - inputMeta: row.inputMeta, - }, - } - } - case 'mcp_server': { - const row = await loadMcpServer(descriptor.entityId!, descriptor.workspaceId!) - if (!row) { - throw new ReviewTargetBootstrapError(404, 'MCP server target no longer exists') - } + const metadata = getMetadataMap(doc) + metadata.set('reseededFromCanonical', true) + const state = Y.encodeStateAsUpdate(doc) - 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, - }, - } + return { + descriptor, + runtime: getRuntimeStateFromUpdate(state), + state, } - case 'workflow': - throw new ReviewTargetBootstrapError(409, 'Workflow targets must use workflow bootstrap') - default: - throw new ReviewTargetBootstrapError(409, 'Unsupported review target') + } finally { + doc.destroy() } } diff --git a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts index 9bc143c60..6af4af6a3 100644 --- a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts +++ b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts @@ -1,90 +1,101 @@ import { db } from '@tradinggoose/db' -import { customTools, mcpServers, pineIndicators, skill } from '@tradinggoose/db/schema' -import { and, eq, isNull } from 'drizzle-orm' -import type { SavedEntityKind } from '@/lib/yjs/entity-state' +import { + customTools, + knowledgeBase, + mcpServers, + pineIndicators, + skill, +} from '@tradinggoose/db/schema' +import { and, eq, isNull, type SQL } from 'drizzle-orm' +import { + type SavedEntityKind, + type SavedEntityRow, + savedEntityRowToFields, +} from '@/lib/yjs/entity-state' + +const ENTITY_TABLES = { + 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 entityConfig(entityKind: SavedEntityKind) { + return ENTITY_TABLES[entityKind] as any +} + +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 { + status = 404 + + constructor(message: string) { + super(message) + this.name = 'SavedEntityLoadError' + } +} export async function resolveEntityWorkspaceId( entityKind: SavedEntityKind, entityId: string ): Promise<string | null> { - 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 '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 - } - } -} - -export async function loadSkill(entityId: string, workspaceId: string) { + const { table } = entityConfig(entityKind) const [row] = await db - .select() - .from(skill) - .where(and(eq(skill.id, entityId), eq(skill.workspaceId, workspaceId))) + .select({ workspaceId: table.workspaceId }) + .from(table) + .where(entityCondition(entityKind, [eq(table.id, entityId)])) .limit(1) - - return row ?? null + return row?.workspaceId ?? 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) +export async function readEntityListMembersFromDb( + entityKind: SavedEntityKind, + workspaceId: string +): Promise<Array<{ id: string; name: string; enabled?: boolean }>> { + 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 row ?? null -} + return rows.map((row) => ({ + id: row.id, + name: row.name ?? '', + enabled: row.enabled !== false, + })) + } -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) + 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 row ?? null + return rows.map((row) => ({ id: row.id, name: row.name ?? '' })) } -export async function loadMcpServer(entityId: string, workspaceId: string) { +export async function readSavedEntityFieldsFromDb( + entityKind: SavedEntityKind, + entityId: string, + workspaceId: string +): Promise<Record<string, unknown>> { + const { table } = entityConfig(entityKind) const [row] = await db .select() - .from(mcpServers) + .from(table) .where( - and( - eq(mcpServers.id, entityId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) + entityCondition(entityKind, [eq(table.id, entityId), eq(table.workspaceId, workspaceId)]) ) .limit(1) - return row ?? null + if (!row) { + throw new SavedEntityLoadError(`Saved ${entityKind} ${entityId} was not found`) + } + + 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 e947a9648..d0f032c25 100644 --- a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts +++ b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts @@ -1,14 +1,23 @@ +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 type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { type SavedEntityKind, SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' +import type { WorkflowMetadataPatch, WorkflowSnapshot } from '@/lib/yjs/workflow-session' export interface YjsSnapshotResponse { snapshotBase64: string descriptor: ReviewTargetDescriptor runtime: ReviewTargetRuntimeState + touchedAt?: number | null +} + +type WorkflowPatch = { + workflowState?: WorkflowSnapshot + variables?: Record<string, any> + metadata?: WorkflowMetadataPatch } export class SocketServerBridgeError extends Error { @@ -16,13 +25,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() } @@ -38,23 +57,48 @@ function getInternalSecret(): string { async function fetchFromSocketServer( url: URL, init: RequestInit, - timeoutMs = 5000 + timeoutMs = 5000, + attempts = 1 ): Promise<Response> { 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<void> { + await fetchFromSocketServer( + new URL(path, getSocketServerUrl()), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + 10000 + ) } export async function getYjsSnapshot( @@ -71,35 +115,17 @@ 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<YjsSnapshotResponse> } -export async function applyWorkflowStateInSocketServer( +export async function applyWorkflowPatchInSocketServer( workflowId: string, - workflowState: WorkflowSnapshot, - variables?: Record<string, any>, - entityName?: string + patch: WorkflowPatch ): Promise<void> { - 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 + patch ) } @@ -108,52 +134,62 @@ export async function applyEntityStateInSocketServer( entityKind: string, fields: Record<string, unknown> ): Promise<void> { - 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<void> { + await postJsonToSocketServer( + `/internal/yjs/sessions/${encodeURIComponent(sessionId)}/apply-update${search}`, + { updateBase64 } ) } -export async function deleteYjsSessionInSocketServer(sessionId: string): Promise<void> { - const url = new URL( - `/internal/yjs/sessions/${encodeURIComponent(sessionId)}`, - getSocketServerUrl() - ) +async function postEntityListMembersToSocketServer( + entityKind: SavedEntityKind, + workspaceId: string, + body: unknown +): Promise<void> { + const descriptor = buildEntityListDescriptor(entityKind, workspaceId) + 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() + } +} - await fetchFromSocketServer( - url, - { - method: 'DELETE', - }, - 10000 - ) +export async function notifyEntityListMembersUpserted( + entityKind: SavedEntityKind, + workspaceId: string, + members: Array<{ id: string; name: string; enabled?: boolean }> +): Promise<void> { + await postEntityListMembersToSocketServer(entityKind, workspaceId, { members }) } -export async function clearYjsSessionReseededFromCanonicalInSocketServer( - sessionId: string +export async function notifyEntityListMemberRemoved( + entityKind: SavedEntityKind, + workspaceId: string, + entityId: string ): Promise<void> { - const url = new URL( - `/internal/yjs/sessions/${encodeURIComponent(sessionId)}/clear-reseeded`, - getSocketServerUrl() - ) + await postEntityListMembersToSocketServer(entityKind, workspaceId, { remove: entityId }) +} +export async function deleteYjsSessionInSocketServer(sessionId: string): Promise<void> { await fetchFromSocketServer( - url, - { - method: 'POST', - }, + new URL(`/internal/yjs/sessions/${encodeURIComponent(sessionId)}`, getSocketServerUrl()), + { method: 'DELETE' }, 10000 ) } diff --git a/apps/tradinggoose/lib/yjs/use-entity-fields.ts b/apps/tradinggoose/lib/yjs/use-entity-fields.ts index c3f6857de..35681a36b 100644 --- a/apps/tradinggoose/lib/yjs/use-entity-fields.ts +++ b/apps/tradinggoose/lib/yjs/use-entity-fields.ts @@ -8,10 +8,118 @@ * read/write through the collaborative Yjs document when available. */ -import { useCallback, useMemo } from 'react' +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' 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 + result: YjsProviderBootstrapResult | null + error: string | null +} + +export function useSavedEntityYjsSession( + entityKind: SavedEntityKind, + entityId: string | null | undefined, + workspaceId: string | null | undefined +) { + const queryClient = useQueryClient() + const sessionKey = entityId && workspaceId ? `${entityKind}:${workspaceId}:${entityId}` : null + const [state, setState] = useState<SavedEntityYjsSessionState>({ + 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(buildSavedEntityDescriptor(entityKind, entityId, workspaceId)) + .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 + 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 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) { + 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, + } +} /** * Subscribe to a single string field on the entity Yjs doc's `fields` Y.Map. @@ -21,26 +129,49 @@ import { useYjsSubscription } from '@/lib/yjs/use-yjs-subscription' export function useYjsStringField( doc: Y.Doc | null | undefined, key: string, - fallback: string = '' -): [string, (v: string) => void] { + fallback = '' +): [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<any>) => { + // 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<any>) => { 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(handler) - return () => fields.unobserve(handler) + + 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]) 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 +180,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] @@ -111,7 +250,7 @@ export function useYjsField<T>( export function useYjsBooleanField( doc: Y.Doc | null | undefined, key: string, - fallback: boolean = false + fallback = false ): [boolean, (v: boolean) => void] { return useYjsField(doc, key, fallback) } @@ -122,7 +261,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/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<string, SubBlockState> + 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.test.ts b/apps/tradinggoose/lib/yjs/workflow-session.test.ts index 828f32a2f..0789b0397 100644 --- a/apps/tradinggoose/lib/yjs/workflow-session.test.ts +++ b/apps/tradinggoose/lib/yjs/workflow-session.test.ts @@ -1,9 +1,10 @@ -import * as Y from 'yjs' import { describe, expect, it } from 'vitest' +import * as Y from 'yjs' import { createWorkflowTextFieldKey, readWorkflowSnapshot, readWorkflowTextFieldsMap, + setWorkflowEntityMetadata, setWorkflowState, } from './workflow-session' @@ -38,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', () => { @@ -73,9 +76,17 @@ 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') }) + + 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 fe59c73c5..7f75b3406 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 = '::' @@ -90,7 +88,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) } @@ -235,9 +237,19 @@ export interface WorkflowSnapshot { edges: Edge[] loops: Record<string, Loop> parallels: Record<string, Parallel> - lastSaved?: string - isDeployed?: boolean - deployedAt?: string + lastSaved?: string | number +} + +export type WorkflowMetadataPatch = { + name?: string + description?: string | null + folderId?: string | null +} + +export type WorkflowMetadataSnapshot = { + name?: string + description?: string | null + folderId?: string | null } /** @@ -253,8 +265,6 @@ function applySnapshotDefaults(partial: Partial<WorkflowSnapshot>): WorkflowSnap loops: partial.loops ?? {}, parallels: partial.parallels ?? {}, lastSaved: partial.lastSaved, - isDeployed: partial.isDeployed, - deployedAt: partial.deployedAt, } } @@ -262,9 +272,7 @@ function applySnapshotDefaults(partial: Partial<WorkflowSnapshot>): 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> = {} -): WorkflowSnapshot { +export function createWorkflowSnapshot(partial: Partial<WorkflowSnapshot> = {}): WorkflowSnapshot { return applySnapshotDefaults(partial) } @@ -292,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), }) } @@ -319,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'`) */ @@ -342,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) @@ -371,6 +372,52 @@ export function setWorkflowState(doc: Y.Doc, state: WorkflowSnapshot, origin?: s }, origin ?? YJS_ORIGINS.SYSTEM) } +export function replaceWorkflowDocumentState( + doc: Y.Doc, + workflowState: WorkflowSnapshot, + variables?: Record<string, any>, + metadataPatch?: WorkflowMetadataPatch +): void { + setWorkflowState(doc, workflowState, YJS_ORIGINS.SYSTEM) + + if (variables !== undefined) { + setVariables(doc, variables, YJS_ORIGINS.SYSTEM) + } + + doc.transact(() => { + 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 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 (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) +} + // --------------------------------------------------------------------------- // Block mutation helpers // --------------------------------------------------------------------------- @@ -480,6 +527,9 @@ export function setVariables(doc: Y.Doc, variables: Record<string, any>, 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<string, BlockState> edges: Edge[] @@ -491,10 +541,12 @@ 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() return { + ...metadata, ...(snapshot.direction !== undefined ? { direction: snapshot.direction } : {}), blocks: snapshot.blocks || {}, edges: snapshot.edges || [], 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/lib/yjs/workflow-variables.test.ts b/apps/tradinggoose/lib/yjs/workflow-variables.test.ts index 160e6d6c0..7a8373134 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,38 @@ 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, + { workflowId: 'wf-1', name: 'Foo Value', type: 'plain', value: 'hello' }, + 'var-1', + 'test' + ) + + replaceWorkflowVariables( + doc, + { + 'var-1': { + id: 'wrong-id', + workflowId: 'wf-1', + name: 'Bar Value', + type: 'plain', + value: 'hello', + }, + }, + 'test' + ) + + 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 <variable.barvalue> 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..0a2d58052 100644 --- a/apps/tradinggoose/lib/yjs/workflow-variables.ts +++ b/apps/tradinggoose/lib/yjs/workflow-variables.ts @@ -11,11 +11,19 @@ */ 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' -import { getVariablesMap, readWorkflowMap, readWorkflowTextFieldsMap } from './workflow-session' +import { + getMetadataMap, + getVariablesMap, + readWorkflowMap, + readWorkflowTextFieldsMap, +} from './workflow-session' // --------------------------------------------------------------------------- // Name generation @@ -289,6 +297,35 @@ export function deleteWorkflowVariable(doc: Y.Doc, id: string, origin?: string): return true } +export function replaceWorkflowVariables( + doc: Y.Doc, + variables: Record<string, any>, + 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, id: key }) + } + getMetadataMap(doc).delete('reseededFromCanonical') + }, origin ?? 'variable-replace') +} + export function duplicateWorkflowVariable( doc: Y.Doc, id: string, diff --git a/apps/tradinggoose/proxy.test.ts b/apps/tradinggoose/proxy.test.ts index a303bfaf0..13f0c8d5d 100644 --- a/apps/tradinggoose/proxy.test.ts +++ b/apps/tradinggoose/proxy.test.ts @@ -329,6 +329,86 @@ 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( + 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('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 671ecb383..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,11 +70,37 @@ function isCanonicalRouteHandlerPath(pathname: string) { pathname === '/llms.txt' || pathname === '/llms-full.txt' || pathname === '/manifest.webmanifest' || + 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 @@ -232,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', diff --git a/apps/tradinggoose/services/queue/ExecutionLimiter.test.ts b/apps/tradinggoose/services/queue/ExecutionLimiter.test.ts index e2d425f2d..d3e3c0f2a 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) @@ -265,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 2412fe363..0128f1d75 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,17 @@ export class ExecutionLimiter { subscription: SubscriptionInfo | null, triggerType: TriggerType = 'manual', isAsync = false, - billingScope?: BillingScope | null - ): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> { + billingScope?: BillingScope | null, + options: { enforceWithoutBilling?: boolean; failClosedOnError?: boolean } = {} + ): Promise<{ + allowed: boolean + remaining: number + resetAt: Date + error?: string + failureKind?: 'dependency' + }> { try { - if (!(await isBillingEnabledForRuntime())) { + if (!options.enforceWithoutBilling && !(await isBillingEnabledForRuntime())) { return createPermissiveRateLimitResult() } @@ -301,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() } diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 181838a8b..6cc9d790e 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -3,11 +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 * 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, + getEntityListMembers, + seedEntityListSession, + seedEntitySession, +} from '@/lib/yjs/entity-session' import { extractPersistedStateFromDoc, setVariables, @@ -15,14 +22,29 @@ 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 { cleanupAllDocuments, getDocument, getExistingDocument, - setPersistence, + setupWSConnection, } 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<string, unknown> + }>, + savedWorkflowStates: [] as Array<ReturnType<typeof extractPersistedStateFromDoc>>, +})) + vi.mock(import('@/lib/env'), async (importOriginal) => { const actual = await importOriginal() return { @@ -41,17 +63,44 @@ 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) => { + 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, reseededFromCanonical: false, })), - getRuntimeStateFromUpdate: vi.fn(() => ({ - docState: 'active', - replaySafe: false, - reseededFromCanonical: false, - })), })) vi.mock('@/lib/auth', () => ({ @@ -174,6 +223,39 @@ function sendHttpRequestWithOptions( }) } +function createSkillUpdateBase64(payload: Record<string, unknown>): 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 }), + } + ) +} + +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 @@ -186,7 +268,18 @@ describe('Socket Server Index Integration', () => { beforeEach(async () => { cleanupAllDocuments() - cleanupPersistence() + 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() @@ -224,7 +317,6 @@ describe('Socket Server Index Integration', () => { afterEach(async () => { cleanupAllDocuments() - cleanupPersistence() // Properly close servers and wait for them to fully close if (io) { @@ -273,9 +365,164 @@ describe('Socket Server Index Integration', () => { }) it('should apply workflow state through the internal Yjs route', async () => { + 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(body), + }) + + const response = await applyWorkflowPatch({ + workflowState: { + blocks: { + 'block-1': { + id: 'block-1', + type: 'agent', + name: 'Applied Agent', + position: { x: 10, y: 20 }, + subBlocks: { + prompt: { + id: 'prompt', + type: 'long-input', + value: 'Use <variable.token> in this prompt', + }, + }, + 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)) + 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 applyWorkflowPatch({ metadata: { name: 'Renamed Workflow' } }) + + expect(renameResponse.statusCode).toBe(200) + expect(mockSaveWorkflowYjsDocToDb).toHaveBeenCalledTimes(2) + expect(await getExistingDocument('workflow-1')).toBeNull() + + 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 <variable.risklimit> in this prompt' + ) + expect(await getExistingDocument('workflow-1')).toBeNull() + }) + + 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, + '/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(savedEntityStates).toEqual([ + { + entityKind: 'skill', + entityId: 'skill-1', + fields: { + name: 'Canonical Risk Skill', + description: 'Position sizing rules', + content: 'Keep risk below one percent.', + }, + }, + ]) + expect(await getExistingDocument('skill-1')).toBeNull() + expect(getEntityListMembers(listDoc)).toEqual([ + { + entityId: 'skill-1', + entityName: 'Canonical Risk Skill', + }, + ]) + + conn.emit('close') + await new Promise((resolve) => setImmediate(resolve)) + }) + + 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-1/apply-state', + '/internal/yjs/workflows/workflow-failed/apply-state', { method: 'POST', headers: { @@ -284,70 +531,125 @@ describe('Socket Server Index Integration', () => { }, body: JSON.stringify({ workflowState: { - blocks: { - 'block-1': { - id: 'block-1', - type: 'agent', - name: 'Applied Agent', - position: { x: 10, y: 20 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, + blocks: {}, 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(500) + expect(await getExistingDocument('workflow-failed')).toBeNull() + }) + + it('does not mutate a connected live workflow session when persistence fails', async () => { + const { conn, doc: liveDoc } = await connectTestDocument('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, }, }), } ) - expect(response.statusCode).toBe(200) - expect(await getExistingDocument('workflow-1')).toBeNull() + // 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)) + }) - const persisted = await getState('workflow-1') - expect(persisted).toBeTruthy() + it('should discard an idle saved entity document when update materialization fails', async () => { + mockSaveSavedEntityYjsDocToDb.mockRejectedValueOnce(new Error('database unavailable')) + const response = await applySkillSessionUpdate( + PORT, + 'skill-update-failed', + createSkillUpdateBase64({ + name: 'Unsaved Skill', + description: 'Draft', + content: 'Draft content', + }) + ) - const doc = new Y.Doc() - try { - Y.applyUpdate(doc, 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(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, getRuntimeStateFromUpdate } = await import( - '@/lib/yjs/server/bootstrap-review-target' + it('keeps a connected saved entity draft when update materialization fails', async () => { + const { conn, doc: liveDoc } = await connectTestDocument('skill-update-connected') + seedEntitySession(liveDoc, { + entityKind: 'skill', + payload: { + name: 'Original Skill', + description: 'Original', + 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( + PORT, + 'skill-update-connected', + createSkillUpdateBase64({ + name: 'Unsaved Skill', + description: 'Draft', + content: 'Draft content', + }) ) - setPersistence('workflow-state-update', { getState, storeState }) + expect(response.statusCode).toBe(500) + 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 () => { + const { getRuntimeStateFromDoc } = await import('@/lib/yjs/server/bootstrap-review-target') + getDocument('workflow-state-update') const liveDoc = await getExistingDocument('workflow-state-update') @@ -369,14 +671,13 @@ describe('Socket Server Index Integration', () => { loops: {}, parallels: {}, lastSaved: '2026-04-06T00:00:00.000Z', - isDeployed: false, }, 'test' ) 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: { @@ -399,6 +700,7 @@ describe('Socket Server Index Integration', () => { yjsSessionId: 'workflow-state-update', }, runtime: getRuntimeStateFromDoc(liveDoc!), + touchedAt: null, }) const doc = new Y.Doc() @@ -416,13 +718,12 @@ 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', + '/internal/yjs/sessions/missing-workflow/snapshot?targetKind=entity&sessionId=missing-workflow&entityKind=workflow&entityId=missing-workflow', { method: 'GET', headers: { @@ -431,29 +732,16 @@ 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')).toBeNull() }) - 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!)) - + it('should bootstrap a saved entity snapshot into a live Yjs document', async () => { const response = await sendHttpRequestWithOptions( PORT, - '/internal/yjs/sessions/review-session-live/clear-reseeded', + '/internal/yjs/sessions/skill-stale/snapshot?targetKind=entity&sessionId=skill-stale&workspaceId=workspace-1&entityKind=skill&entityId=skill-stale', { - method: 'POST', + method: 'GET', headers: { 'x-internal-secret': INTERNAL_SECRET, }, @@ -461,117 +749,54 @@ describe('Socket Server Index Integration', () => { ) 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() - } + expect(await getExistingDocument('skill-stale')).toBeNull() }) + }) - 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, - }, - } - ) + 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() - expect(response.statusCode).toBe(200) - expect(JSON.parse(response.body)).toEqual({ success: true, updated: true }) - expect(await getExistingDocument('review-session-cold')).toBeNull() + conn.emit('close') + await new Promise((resolve) => setImmediate(resolve)) - 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() - } + expect(onDocumentIdle).not.toHaveBeenCalled() + expect(await getExistingDocument('idle-clean')).toBeNull() }) - it('should delete the live workflow doc and persisted session through the internal Yjs route', async () => { - setPersistence('workflow-2', { getState, storeState }) - getDocument('workflow-2') - const liveDoc = await getExistingDocument('workflow-2') - - setWorkflowState( - liveDoc!, - { - blocks: { - old: { - id: 'old', - type: 'agent', - name: 'Old Agent', - position: { x: 0, y: 0 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - lastSaved: '2026-04-05T00:00:00.000Z', - isDeployed: false, - }, - 'test' - ) - setVariables( - liveDoc!, - { - oldVar: { - id: 'oldVar', - workflowId: 'workflow-2', - name: 'old', - type: 'plain', - value: 'old', - }, - }, - 'test' - ) - await storeState('workflow-2', Y.encodeStateAsUpdate(liveDoc!)) + 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() + const doc = await getExistingDocument('idle-save-failed') + doc?.getMap('metadata').set('entityId', 'changed') - const response = await sendHttpRequestWithOptions( - PORT, - '/internal/yjs/sessions/workflow-2', - { - method: 'DELETE', - headers: { - 'x-internal-secret': INTERNAL_SECRET, - }, - } - ) + conn.emit('close') + await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) - expect(response.statusCode).toBe(200) - expect(await getExistingDocument('workflow-2')).toBeNull() - expect(await getState('workflow-2')).toBeNull() + expect(onDocumentIdle).toHaveBeenCalledWith('idle-save-failed', expect.any(Y.Doc)) + expect(await getExistingDocument('idle-save-failed')).toBeNull() }) }) diff --git a/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts b/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts index 562c908d9..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 { applySavedEntityYjsStateToRows } 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 applySavedEntityYjsStateToRows('indicator', rows) - - indicators.forEach((row) => { + rows.forEach((row) => { definitions.set(`${row.workspaceId}:${row.id}`, { id: row.id, name: row.name, diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index 36c238f26..d0586e590 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -1,26 +1,48 @@ import type { IncomingMessage, ServerResponse } from 'http' import * as Y from 'yjs' import { + buildEntityListDescriptor, buildReviewTargetDescriptorFromEnvelope, + buildSavedEntityDescriptor, + isEntityListSessionId, parseYjsTransportEnvelope, } from '@/lib/copilot/review-sessions/identity' import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' import { env } from '@/lib/env' -import { seedEntitySession } from '@/lib/yjs/entity-session' +import { saveWorkflowYjsDocToDb } from '@/lib/workflows/db-helpers' import { + applyEntityListMutations, + type EntityListMemberMutation, + getEntityFields, + getEntityListMemberFromFields, + getEntityWorkspaceId, + seedEntitySession, +} from '@/lib/yjs/entity-session' +import { + SavedEntityPersistenceError, + saveSavedEntityYjsDocToDb, +} from '@/lib/yjs/server/apply-entity-state' +import { + createEntityListBootstrapUpdate, + createSavedReviewTargetBootstrapUpdate, getRuntimeStateFromDoc, - getRuntimeStateFromUpdate, } from '@/lib/yjs/server/bootstrap-review-target' import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' import { - getMetadataMap as getWorkflowMetadataMap, - setVariables, - setWorkflowState, + replaceWorkflowDocumentState, + 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 { deleteSession, getState, storeState } from '@/socket-server/yjs/persistence' -import { getExistingDocument, removeDocument } from '@/socket-server/yjs/upstream-utils' +import { + discardDocument, + discardDocumentIfIdle, + getDocument, + getExistingDocument, + markDocumentPersisted, +} from '@/socket-server/yjs/upstream-utils' interface Logger { info: (message: string, ...args: any[]) => void @@ -41,14 +63,14 @@ 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_PATH = /^\/internal\/yjs\/sessions\/([^/]+)$/ +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 + workflowState?: WorkflowSnapshot variables?: Record<string, any> - entityName?: string + metadata?: WorkflowMetadataPatch } type SavedEntityKind = Exclude<ReviewEntityKind, 'workflow'> @@ -80,6 +102,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, @@ -93,8 +120,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 } @@ -155,12 +181,27 @@ function parseApplyWorkflowStateRequest(body: unknown): ApplyWorkflowStateReques } const candidate = body as Record<string, unknown> + const workflowState = candidate.workflowState + 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 && metadata === undefined && candidate.variables === undefined) { + throw new InvalidInternalYjsRequestError('workflowState, variables, or metadata is required') + } + if ( - !candidate.workflowState || - typeof candidate.workflowState !== 'object' || - Array.isArray(candidate.workflowState) + workflowState !== undefined && + (!workflowState || typeof workflowState !== 'object' || Array.isArray(workflowState)) ) { - throw new InvalidInternalYjsRequestError('workflowState is required') + throw new InvalidInternalYjsRequestError('workflowState must be an object') } if ( @@ -173,9 +214,9 @@ function parseApplyWorkflowStateRequest(body: unknown): ApplyWorkflowStateReques } return { - workflowState: candidate.workflowState as WorkflowSnapshot, + workflowState: workflowState as WorkflowSnapshot | undefined, variables: candidate.variables as Record<string, any> | undefined, - entityName: typeof candidate.entityName === 'string' ? candidate.entityName.trim() : undefined, + metadata, } } @@ -189,6 +230,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') @@ -208,172 +250,279 @@ function parseApplyEntityStateRequest(body: unknown): ApplyEntityStateRequest { } } -function replaceWorkflowDocState( - doc: Y.Doc, - workflowState: WorkflowSnapshot, - variables?: Record<string, any>, - entityName?: string -): void { - setWorkflowState(doc, workflowState, YJS_ORIGINS.SYSTEM) +function clearSessionReseededFromCanonical(doc: Y.Doc): void { + doc.transact(() => { + doc.getMap('metadata').delete('reseededFromCanonical') + }, YJS_ORIGINS.SYSTEM) +} - if (variables !== undefined) { - setVariables(doc, variables, YJS_ORIGINS.SYSTEM) +async function getInitializedSessionDocument( + sessionId: string, + bootstrapState?: Uint8Array +): Promise<Y.Doc> { + const doc = getDocument(sessionId, true, bootstrapState) as Y.Doc & { + whenInitialized?: Promise<void> } + await doc.whenInitialized + return doc +} - doc.transact(() => { - const metadata = getWorkflowMetadataMap(doc) - metadata.delete('reseededFromCanonical') - if (entityName) metadata.set('entityName', entityName) - }, YJS_ORIGINS.SYSTEM) +async function getBootstrappedApplyDocument( + descriptor: ReturnType<typeof buildReviewTargetDescriptorFromEnvelope> +): Promise<Y.Doc> { + 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, bootstrapped.state) } -function clearSessionReseededFromCanonical(doc: Y.Doc): void { - doc.transact(() => { - doc.getMap('metadata').delete('reseededFromCanonical') - }, YJS_ORIGINS.SAVE) +/** + * 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, + sessionId: string, + mutate: (target: Y.Doc) => void, + persist: (staged: Y.Doc) => Promise<void> +): Promise<void> { + const liveState = Y.encodeStateVector(doc) + const staging = new Y.Doc() + Y.applyUpdate(staging, Y.encodeStateAsUpdate(doc)) + try { + mutate(staging) + await persist(staging) + Y.applyUpdate(doc, Y.encodeStateAsUpdate(staging, liveState), YJS_ORIGINS.SYSTEM) + markDocumentPersisted(doc) + } finally { + staging.destroy() + discardDocumentIfIdle(sessionId) + } } -async function handleInternalYjsWorkflowApplyRequest( +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 syncEntityListMemberFromDoc( + entityKind: SavedEntityKind, + entityId: string, + entityDoc: Y.Doc +): Promise<void> { + 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, - workflowId: string + sessionId: string ): Promise<void> { try { - const body = parseApplyWorkflowStateRequest(await readJsonBody(req)) - const liveDoc = await getExistingDocument(workflowId) - const doc = liveDoc ?? new Y.Doc() + if (!isEntityListSessionId(sessionId)) { + throw new InvalidInternalYjsRequestError('Entity-list session ID is required') + } - try { - replaceWorkflowDocState(doc, body.workflowState, body.variables, body.entityName) - await storeState(workflowId, Y.encodeStateAsUpdate(doc)) - } finally { - if (!liveDoc) doc.destroy() + const liveDoc = await getExistingDocument(sessionId) + if (!liveDoc) { + sendJson(res, 200, { success: true, applied: false }) + return } - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ success: true })) + 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 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', - }) - ) + 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 handleInternalYjsEntityApplyRequest( +async function handleInternalYjsWorkflowApplyRequest( req: IncomingMessage, res: ServerResponse, logger: Logger, - entityId: string + workflowId: string ): Promise<void> { 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, - }) - 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 body = parseApplyWorkflowStateRequest(await readJsonBody(req)) + const descriptor = { + workspaceId: null, + entityKind: 'workflow', + entityId: workflowId, + draftSessionId: null, + reviewSessionId: null, + yjsSessionId: workflowId, + } as const + const doc = await getBootstrappedApplyDocument(descriptor) + await applyThroughStaging( + doc, + workflowId, + (target) => applyWorkflowApplyRequest(target, body), + (staged) => saveWorkflowYjsDocToDb(workflowId, staged) + ) + sendJson(res, 200, { success: true }) } catch (error) { - logger.error('Error applying entity state', { error, entityId }) + 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 entity state', - }) - ) + sendJson(res, status, { + error: error instanceof Error ? error.message : 'Failed to apply workflow state', + }) } } -async function handleInternalYjsSessionDeleteRequest( +async function handleInternalYjsEntityApplyRequest( + req: IncomingMessage, res: ServerResponse, logger: Logger, - sessionId: string + entityId: string ): Promise<void> { try { - removeDocument(sessionId) - await deleteSession(sessionId) + const body = parseApplyEntityStateRequest(await readJsonBody(req)) + const descriptor = buildSavedEntityDescriptor(body.entityKind, entityId, null) + const doc = await getBootstrappedApplyDocument(descriptor) + await applyThroughStaging( + doc, + entityId, + (target) => { + seedEntitySession(target, { entityKind: body.entityKind, payload: body.fields }) + clearSessionReseededFromCanonical(target) + }, + (staged) => saveSavedEntityYjsDocToDb(body.entityKind, entityId, staged) + ) + await syncEntityListMemberFromDoc(body.entityKind, entityId, doc) - 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 entity state', { error, entityId }) + 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', + }) } } -async function handleInternalYjsSessionClearReseededRequest( +async function handleInternalYjsSessionApplyUpdateRequest( + req: IncomingMessage, + parsedUrl: URL, res: ServerResponse, logger: Logger, sessionId: string ): Promise<void> { 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 })) + const envelope = parseYjsTransportEnvelope(Object.fromEntries(parsedUrl.searchParams)) + if (envelope.sessionId !== sessionId) { + sendJson(res, 409, { error: 'Session ID mismatch', sessionId }) 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 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 doc = new Y.Doc() + const updateBase64 = (body as Record<string, unknown>).updateBase64 + if (typeof updateBase64 !== 'string' || !updateBase64) { + throw new InvalidInternalYjsRequestError('updateBase64 is required') + } + const doc = await getBootstrappedApplyDocument(descriptor) try { - Y.applyUpdate(doc, state) + // 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) - await storeState(sessionId, Y.encodeStateAsUpdate(doc)) - } finally { - doc.destroy() + if (descriptor.entityKind !== 'workflow' && descriptor.entityId) { + await saveSavedEntityYjsDocToDb(descriptor.entityKind, descriptor.entityId, doc) + await syncEntityListMemberFromDoc(descriptor.entityKind, descriptor.entityId, doc) + markDocumentPersisted(doc) + discardDocumentIfIdle(sessionId) + } + } catch (error) { + discardDocumentIfIdle(descriptor.yjsSessionId) + throw error } - 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' })) - } -} - -async function getLiveOrPersistedYjsState( - sessionId: string -): Promise<{ liveDoc: Y.Doc | null; state: Uint8Array | null }> { - const liveDoc = await getExistingDocument(sessionId) - if (liveDoc) { - return { - liveDoc, - state: Y.encodeStateAsUpdate(liveDoc), - } - } - - return { - liveDoc: null, - state: await getState(sessionId), + logger.error('Error applying Yjs session update', { error, path: parsedUrl.pathname }) + 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', + }) } } @@ -383,37 +532,83 @@ async function handleInternalYjsSnapshotRequest( logger: Logger, sessionId: string ): Promise<void> { + let descriptor: ReturnType<typeof buildReviewTargetDescriptorFromEnvelope> 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 } - const descriptor = buildReviewTargetDescriptorFromEnvelope(envelope) - const { liveDoc, state } = await getLiveOrPersistedYjsState(sessionId) + 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 + } - if (!state) { - res.writeHead(404, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Session not found', sessionId })) + try { + let liveDoc = await getExistingDocument(sessionId) + let bootstrappedForRequest = false + 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 + } + } + + if (!liveDoc) { + sendJson(res, 404, { error: 'Session not found', sessionId }) return } - const runtime = liveDoc ? getRuntimeStateFromDoc(liveDoc) : getRuntimeStateFromUpdate(state) + const state = Y.encodeStateAsUpdate(liveDoc) - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end( - JSON.stringify({ - snapshotBase64: Buffer.from(state).toString('base64'), - descriptor, - runtime, - }) - ) + sendJson(res, 200, { + snapshotBase64: Buffer.from(state).toString('base64'), + descriptor, + runtime: getRuntimeStateFromDoc(liveDoc), + touchedAt: null, + }) + if (bootstrappedForRequest) { + discardDocumentIfIdle(sessionId) + } } 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' })) + const status = Number((error as { status?: unknown }).status) || 500 + sendJson(res, status, { + error: error instanceof Error ? error.message : 'Failed to get snapshot', + }) + } +} + +async function handleInternalYjsSessionDeleteRequest( + res: ServerResponse, + logger: Logger, + sessionId: string +): Promise<void> { + try { + discardDocument(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', + }) } } @@ -467,25 +662,36 @@ async function handleInternalYjsRequest( return true } - const clearReseededId = matchInternalRoute( + const memberListId = matchInternalRoute( parsedUrl.pathname, - INTERNAL_YJS_SESSION_CLEAR_RESEEDED_PATH, + INTERNAL_YJS_ENTITY_LIST_MEMBERS_PATH, 'POST', req.method ) - if (clearReseededId) { - await handleInternalYjsSessionClearReseededRequest(res, logger, clearReseededId) + if (memberListId) { + await handleInternalYjsEntityListMembersRequest(req, res, logger, memberListId) return true } - const deleteId = matchInternalRoute( + const deleteSessionId = matchInternalRoute( parsedUrl.pathname, - INTERNAL_YJS_SESSION_PATH, + INTERNAL_YJS_SESSION_DELETE_PATH, 'DELETE', req.method ) - if (deleteId) { - await handleInternalYjsSessionDeleteRequest(res, logger, deleteId) + if (deleteSessionId) { + await handleInternalYjsSessionDeleteRequest(res, logger, deleteSessionId) + return true + } + + const applyUpdateId = matchInternalRoute( + parsedUrl.pathname, + INTERNAL_YJS_SESSION_APPLY_UPDATE_PATH, + 'POST', + req.method + ) + if (applyUpdateId) { + await handleInternalYjsSessionApplyUpdateRequest(req, parsedUrl, res, logger, applyUpdateId) return true } @@ -508,15 +714,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 } @@ -526,12 +729,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 } @@ -546,7 +747,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/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/persistence.ts b/apps/tradinggoose/socket-server/yjs/persistence.ts deleted file mode 100644 index fa061bddb..000000000 --- a/apps/tradinggoose/socket-server/yjs/persistence.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { getRedisClient, getRedisStorageMode } from '@/lib/redis' - -interface YjsSessionBlob { - state: Buffer - updatedAt: number -} - -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<string, YjsSessionBlob>() - -function stateKey(sessionId: string): string { - return `${REDIS_KEY_PREFIX}${sessionId}:state` -} - -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 -} - -async function readRedisUpdatedAt(sessionId: string): Promise<number | null> { - 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<void> { - 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.updatedAt)) { - 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<Uint8Array | null> { - 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<void> { - 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, - }) - - // Evict oldest entries if over the limit - while (localStore.size > MAX_LOCAL_ENTRIES) { - const oldest = localStore.keys().next().value - if (oldest) localStore.delete(oldest) - } -} - -export async function hasSession(sessionId: string): Promise<boolean> { - const mode = getRedisStorageMode() - - if (mode === 'redis') { - const redis = getRedisClient() - if (!redis) { - return false - } - - // Single Redis call — TTL-based expiry handles staleness (see storeState). - const exists = await redis.exists(stateKey(sessionId)) - return exists === 1 - } - - return readLocalBlob(sessionId) !== null -} - -export async function deleteSession(sessionId: string): Promise<void> { - const mode = getRedisStorageMode() - - if (mode === '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<number | null> { - const mode = getRedisStorageMode() - - if (mode === 'redis') { - const updatedAt = await readRedisUpdatedAt(sessionId) - if (isExpired(updatedAt)) { - 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<typeof setInterval> | null = null - -if (getRedisStorageMode() !== 'redis') { - ttlSweepInterval = setInterval(() => { - const now = Date.now() - for (const [key, blob] of localStore) { - if (now - blob.updatedAt > TTL_MS) { - 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<string, unknown>, 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 6dccc8681..fe632c510 100644 --- a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts +++ b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts @@ -3,19 +3,19 @@ * * Uses the app's single Yjs runtime and exposes only the helpers this repo * needs: `getDocument`, `getExistingDocument`, `peekDocument`, - * `setupWSConnection`, `setPersistence`, `removeDocument`, - * and `cleanupAllDocuments`. + * `setupWSConnection`, `removeDocument`, `discardDocument`, 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' +import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' const messageSync = 0 const messageAwareness = 1 @@ -25,24 +25,21 @@ const wsReadyStateOpen = 1 const PING_TIMEOUT = 30_000 -export interface YjsPersistence { - getState: (docId: string) => Promise<Uint8Array | null> - storeState: (docId: string, state: Uint8Array) => Promise<void> -} - const docs = new Map<string, WSSharedDoc>() -const persistenceMap = new Map<string, YjsPersistence>() +type DocumentPersistenceHandler = (docId: string, doc: Y.Doc) => Promise<void> | void class WSSharedDoc extends Y.Doc { name: string conns: Map<WebSocket, Set<number>> awareness: awarenessProtocol.Awareness whenInitialized: Promise<void> - - private persistScheduled = false - private persistInFlight = false - private persistPending = false - private readonly schedulePersistMutex = mutex.createMutex() + onDocumentIdle?: DocumentPersistenceHandler + onDocumentUpdate?: DocumentPersistenceHandler + onDocumentUpdateDebounceMs = 0 + hasUnsavedChanges = false + isPersisting = false + needsPersist = false + persistTimer: ReturnType<typeof setTimeout> | null = null constructor(name: string, gc: boolean) { super({ gc }) @@ -54,11 +51,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) @@ -82,82 +75,74 @@ class WSSharedDoc extends Y.Doc { } ) - this.on('update', (update: Uint8Array, _origin: unknown) => { + 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) const message = encoding.toUint8Array(encoder) this.conns.forEach((_ids, conn) => send(this, conn, message)) - this.schedulePersist() }) - this.whenInitialized = this.initialize() + this.whenInitialized = Promise.resolve() } +} - private async initialize(): Promise<void> { - const persistence = persistenceMap.get(this.name) - if (persistence) { - const stored = await persistence.getState(this.name) - if (stored) { - Y.applyUpdate(this, stored) - } - } +function scheduleDocumentPersistence(doc: WSSharedDoc): void { + if (doc.persistTimer) { + clearTimeout(doc.persistTimer) + doc.persistTimer = null } - private schedulePersist(): void { - this.schedulePersistMutex(() => { - if (this.persistScheduled) { - this.persistPending = true - return - } - - this.persistScheduled = true - queueMicrotask(() => { - this.persistScheduled = false - void this.flushPersistence() - }) - }) + if (doc.isPersisting) { + doc.needsPersist = true + return } - /** - * 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<void> { - if (this.persistInFlight) { - this.persistPending = true - return - } + if (doc.onDocumentUpdateDebounceMs > 0) { + doc.persistTimer = setTimeout(() => { + doc.persistTimer = null + runDocumentPersistence(doc) + }, doc.onDocumentUpdateDebounceMs) + return + } - this.persistInFlight = true + runDocumentPersistence(doc) +} - try { - const persistence = persistenceMap.get(this.name) - if (!persistence) { - return - } +function runDocumentPersistence(doc: WSSharedDoc): void { + const persist = doc.onDocumentUpdate + if (!persist) { + return + } - do { - this.persistPending = false - const state = Y.encodeStateAsUpdate(this) - await persistence.storeState(this.name, state) - } while (this.persistPending) - } finally { - this.persistInFlight = false - } + 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) => { + doc.hasUnsavedChanges = true + doc.needsPersist = true + 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 { @@ -166,16 +151,28 @@ function cleanupDocument(doc: WSSharedDoc): void { } docs.delete(doc.name) - persistenceMap.delete(doc.name) doc.destroy() } function finalizeDocumentCleanup(doc: WSSharedDoc): void { - void doc - .flushPersistence() - .catch(() => {}) + if (doc.persistTimer) { + clearTimeout(doc.persistTimer) + doc.persistTimer = null + } + + if (!doc.onDocumentIdle || !doc.hasUnsavedChanges) { + cleanupDocument(doc) + return + } + + void Promise.resolve(doc.onDocumentIdle(doc.name, doc)) + .catch((error) => { + console.error('[yjs upstream-utils] Failed to persist idle document', error) + }) .finally(() => { - cleanupDocument(doc) + if (doc.conns.size === 0) { + cleanupDocument(doc) + } }) } @@ -243,14 +240,23 @@ 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, YJS_ORIGINS.SYSTEM) + 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 } @@ -271,19 +277,29 @@ export function setupWSConnection( opts: { docId: string gc?: boolean - persistence?: YjsPersistence - context?: unknown + bootstrapState?: Uint8Array + onDocumentIdle?: DocumentPersistenceHandler + onDocumentUpdate?: DocumentPersistenceHandler + onDocumentUpdateDebounceMs?: number } ): void { - const { docId, gc = true, persistence } = opts - - if (persistence && !persistenceMap.has(docId)) { - persistenceMap.set(docId, persistence) - } + const { + docId, + gc = true, + bootstrapState, + onDocumentIdle, + onDocumentUpdate, + onDocumentUpdateDebounceMs, + } = opts conn.binaryType = 'arraybuffer' - const doc = getDocument(docId, gc) as WSSharedDoc + const doc = getDocument(docId, gc, bootstrapState) as WSSharedDoc + doc.onDocumentIdle = onDocumentIdle + if (onDocumentUpdate) { + doc.onDocumentUpdate = onDocumentUpdate + doc.onDocumentUpdateDebounceMs = onDocumentUpdateDebounceMs ?? 0 + } doc.conns.set(conn, new Set()) conn.on('message', (data: ArrayBuffer) => { @@ -341,16 +357,6 @@ export function setupWSConnection( }) } -export function setPersistence( - docId: string, - hooks: { - getState: (docId: string) => Promise<Uint8Array | null> - storeState: (docId: string, state: Uint8Array) => Promise<void> - } -): void { - persistenceMap.set(docId, hooks) -} - export function removeDocument(docId: string): void { const doc = docs.get(docId) if (!doc) { @@ -371,6 +377,32 @@ 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 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 c153de9ec..2ccd92e17 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( @@ -36,7 +33,14 @@ 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 +} + +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 } @@ -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,26 @@ 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('@/lib/workflows/db-helpers', () => ({ + saveWorkflowYjsDocToDb: vi.fn(), + })) + + vi.doMock('@/lib/yjs/server/apply-entity-state', () => ({ + saveSavedEntityYjsDocToDb: vi.fn(), })) vi.doMock('./upstream-utils', () => ({ getExistingDocument: mockGetExistingDocument, - setPersistence: mockSetPersistence, setupWSConnection: mockSetupWSConnection, })) - - vi.doMock('./persistence', () => ({ - getState: mockGetState, - storeState: mockStoreState, - })) }) afterEach(() => { @@ -154,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', @@ -193,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', @@ -211,7 +205,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 +229,17 @@ 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: expect.any(Function), - }) - ) + 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, + onDocumentUpdate: expect.any(Function), + }) ) expect(socket.write).not.toHaveBeenCalled() expect(socket.destroy).not.toHaveBeenCalled() @@ -255,9 +264,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 +274,13 @@ describe('handleYjsUpgrade', () => { userId: 'user-3', userName: 'User Three', envelope: { - targetKind: 'workflow', + targetKind: 'review_session', sessionId, - workflowId: sessionId, - reviewSessionId: null, + reviewSessionId: sessionId, workspaceId: 'workspace-3', - entityKind: 'workflow', - entityId: sessionId, - draftSessionId: null, + entityKind: 'skill', + entityId: null, + draftSessionId: 'draft-1', }, }) @@ -283,7 +291,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 93c5b46df..52f27d0dc 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.ts @@ -1,24 +1,51 @@ import type { IncomingMessage } from 'http' import type { Duplex } from 'stream' import type { WebSocket, WebSocketServer } from 'ws' +import type * as Y from 'yjs' 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, - 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') +const WORKFLOW_LIVE_PERSIST_DEBOUNCE_MS = 1500 interface YjsIncomingMessage extends IncomingMessage { yjsSessionId?: string yjsUserId?: string + yjsBootstrapState?: Uint8Array + yjsPersistLiveUpdates?: boolean +} + +async function persistWorkflowDocument(docId: string, doc: Y.Doc): Promise<void> { + if (isEntityListSessionId(docId)) { + return + } + + const metadata = doc.getMap<unknown>('metadata') + if ( + metadata.get('entityId') !== docId || + metadata.get('draftSessionId') != null || + metadata.get('reviewSessionId') != null + ) { + return + } + + if (metadata.get('entityKind') === 'workflow') { + await saveWorkflowYjsDocToDb(docId, doc) + } } export function handleYjsUpgrade( @@ -39,12 +66,12 @@ export function handleYjsUpgrade( const yjsSessionId = decodeURIComponent(match[1]) void authenticateAndPrepareUpgrade(yjsSessionId, url) - .then(({ userId, resolvedSessionId }) => { - setPersistence(resolvedSessionId, { getState, storeState }) - + .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) => { @@ -65,8 +92,13 @@ export function handleYjsUpgrade( async function authenticateAndPrepareUpgrade( pathSessionId: string, url: URL -): Promise<{ userId: string; resolvedSessionId: string }> { - const accessMode = parseAccessMode(url) +): Promise<{ + bootstrapState?: Uint8Array + userId: string + resolvedSessionId: string + persistLiveUpdates: boolean +}> { + const accessMode = parseAccessMode(url, pathSessionId) const { userId, envelope } = await authenticateYjsConnection(url) if (envelope.sessionId !== pathSessionId) { @@ -74,6 +106,7 @@ async function authenticateAndPrepareUpgrade( } const descriptor = buildReviewTargetDescriptorFromEnvelope(envelope) + const isListTarget = isEntityListSessionId(descriptor.yjsSessionId) const access = await verifyReviewTargetAccess( userId, @@ -93,15 +126,18 @@ async function authenticateAndPrepareUpgrade( } const liveDoc = await getExistingDocument(pathSessionId) - const persistedState = liveDoc ? null : await getState(pathSessionId) - const runtime = liveDoc - ? getRuntimeStateFromDoc(liveDoc) - : persistedState - ? getRuntimeStateFromUpdate(persistedState) - : null - - // Snapshot bootstrap is the only path that materializes a missing review target. - // WebSocket upgrades only attach to an already-bootstrapped Yjs session. + const bootstrapped = liveDoc + ? 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) { throw new YjsAuthError(409, 'Review target is not bootstrapped') } @@ -111,22 +147,31 @@ async function authenticateAndPrepareUpgrade( } return { + bootstrapState: bootstrapped?.state, userId, resolvedSessionId: pathSessionId, + persistLiveUpdates: + descriptor.entityKind === 'workflow' && descriptor.entityId === pathSessionId, } } -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 { @@ -145,7 +190,14 @@ 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, + onDocumentIdle: persistWorkflowDocument, + onDocumentUpdate: yjsReq.yjsPersistLiveUpdates ? persistWorkflowDocument : undefined, + onDocumentUpdateDebounceMs: WORKFLOW_LIVE_PERSIST_DEBOUNCE_MS, + }) } 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/copilot/store.test.ts b/apps/tradinggoose/stores/copilot/store.test.ts index fc859bec6..0758ac574 100644 --- a/apps/tradinggoose/stores/copilot/store.test.ts +++ b/apps/tradinggoose/stores/copilot/store.test.ts @@ -1,13 +1,14 @@ +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 { 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] @@ -748,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 @@ -1298,6 +1297,12 @@ describe('copilot streaming regressions', () => { const toolCallId = 'edit-workflow-limited-tool' const assistantMessageId = 'assistant-message-limited-edit' const reviewResult = { + requiresReview: true, + reviewToken: 'review-token-limited-edit', + 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, + 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"}', + 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,57 @@ 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, + 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"}', + 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 +3019,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', + reviewToken: 'review-token-edit-workflow-review', + }) + expect(store.getState().toolCallsById[toolCallId]?.state).toBe(ClientToolCallState.success) }) it('auto-executes pending reviewed API tools when access switches to full', async () => { @@ -3067,8 +3123,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(QueryClient.prototype, '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') { @@ -3077,7 +3134,7 @@ describe('copilot tool user action delegation', () => { status: 200, json: async () => ({ success: true, - result: { message: 'ok' }, + result: { success: true, scope: 'personal', message: 'ok' }, }), } } @@ -3094,30 +3151,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/store.ts b/apps/tradinggoose/stores/copilot/store.ts index e756aff7d..8372c2361 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,27 @@ 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 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, + 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' || @@ -1406,7 +1424,20 @@ 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 } @@ -1417,7 +1448,7 @@ const createCopilotStoreInstance = (storeChannelId = DEFAULT_COPILOT_CHANNEL_ID) ) if (logicalSuccess) { - await handleCopilotServerToolSuccess(name) + await handleCopilotServerToolSuccess(name, result, serverContext) } const completionMessage = @@ -1442,7 +1473,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..88ca647bd 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.test.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.test.ts @@ -1,76 +1,35 @@ -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 { QueryClient } from '@tanstack/react-query' +import { beforeEach, describe, expect, it, vi } from 'vitest' +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' +import { workflowKeys } from '@/hooks/queries/workflows' import { createExecutionContext, ensureClientToolInstance, getToolInterruptDisplays, + handleCopilotServerToolSuccess, 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', () => { const toolCallId = 'tool-registry-edit-workflow' - afterEach(() => { - unregisterClientTool(toolCallId) + beforeEach(() => { + vi.restoreAllMocks() }) - 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: {}, - }, - }, - }) - + 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('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: {}, - }, - }, - }) - - 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 +47,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 +60,316 @@ 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 for workspace-scoped environment and credential tools', () => { + const context = createExecutionContext({ + toolCallId, + toolName: 'set_environment_variables', + provenance: { workspaceId: 'workspace-1' }, + }) + + expect( + prepareCopilotToolArgs( + 'set_environment_variables', + { scope: 'workspace', variables: { API_KEY: 'secret' } }, + context + ) + ).toEqual({ + scope: 'workspace', + workspaceId: 'workspace-1', + variables: { API_KEY: 'secret' }, + }) + + for (const toolName of [ + 'read_environment_variables', + 'read_credentials', + 'read_oauth_credentials', + ] as const) { + expect(prepareCopilotToolArgs(toolName, { scope: 'workspace' }, context)).toEqual({ + scope: 'workspace', + workspaceId: 'workspace-1', + }) + } + + expect(() => + prepareCopilotToolArgs( + 'set_environment_variables', + { variables: { API_KEY: 'secret' } }, + context + ) + ).toThrow() + }) + + it('preserves personal scope for environment and credential tools in workspace context', () => { + const context = createExecutionContext({ + toolCallId, + toolName: 'read_environment_variables', + provenance: { workspaceId: 'workspace-1' }, + }) + + for (const toolName of [ + 'read_environment_variables', + 'read_credentials', + 'read_oauth_credentials', + ] as const) { + expect(prepareCopilotToolArgs(toolName, { scope: 'personal' }, context)).toEqual({ + scope: 'personal', + }) + } + }) + + 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() + }) + + 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(QueryClient.prototype, '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(QueryClient.prototype, 'invalidateQueries') + .mockResolvedValue(undefined) + + await handleCopilotServerToolSuccess('edit_skill', { workspaceId: 'workspace-1' }) + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: skillsKeys.list('workspace-1'), + }) + }) + + it('invalidates the selected knowledge base detail tree after server-managed knowledge mutations', async () => { + const invalidateQueries = vi + .spyOn(QueryClient.prototype, 'invalidateQueries') + .mockResolvedValue(undefined) + + await handleCopilotServerToolSuccess('edit_knowledge_base', { + workspaceId: 'workspace-1', + entityId: 'kb-1', + }) + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: knowledgeKeys.list('workspace-1'), + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: knowledgeKeys.detail('kb-1'), + }) + expect(invalidateQueries).toHaveBeenCalledTimes(2) + }) + + it('notifies monitor pages after server-managed monitor mutations', async () => { + class TestCustomEvent<T> { + type: string + detail: T | undefined + + constructor(type: string, init?: CustomEventInit<T>) { + 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('refreshes MCP servers and notifies MCP tool discovery after server-managed MCP mutations', async () => { + class TestCustomEvent<T> { + type: string + detail: T | undefined + + constructor(type: string, init?: CustomEventInit<T>) { + 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(QueryClient.prototype, 'invalidateQueries') + .mockResolvedValue(undefined) + const dispatchEvent = vi.fn() + vi.stubGlobal('window', { dispatchEvent }) + + 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(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 b94dedd54..cff05a73a 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.ts @@ -6,56 +6,28 @@ 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 { 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' +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 { MCP_TOOLS_CHANGED_EVENT, useMcpServersStore } from '@/stores/mcp-servers/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('CopilotToolRegistry') @@ -114,30 +86,35 @@ const COPILOT_TOOL_REGISTRY: Record<ToolId, CopilotToolDefinition> = { [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 +124,48 @@ const COPILOT_TOOL_REGISTRY: Record<ToolId, CopilotToolDefinition> = { 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<ToolId>([ + CopilotTool.create_workflow, + CopilotTool.list_workflows, + CopilotTool.get_agent_accessory_catalog, + 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, +]) + +const WORKSPACE_SCOPED_TOOL_NAMES = new Set<ToolId>([ + CopilotTool.read_environment_variables, + CopilotTool.read_credentials, + CopilotTool.read_oauth_credentials, + CopilotTool.set_environment_variables, +]) + export function createExecutionContext(params: { toolCallId: string toolName: string @@ -263,25 +267,115 @@ export function bindClientToolExecutionContext( export function prepareCopilotToolArgs( toolName: string | undefined, args: Record<string, any> | undefined, - _context: ClientToolExecutionContext + context: ClientToolExecutionContext ): Record<string, any> { 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 + } else if ( + WORKSPACE_SCOPED_TOOL_NAMES.has(toolName) && + clonedArgs.scope === 'workspace' && + !clonedArgs.workspaceId && + context.workspaceId + ) { + clonedArgs.workspaceId = context.workspaceId + } + return ToolArgSchemas[toolName].parse(clonedArgs) as Record<string, any> } -export async function handleCopilotServerToolSuccess(toolName: string | undefined): Promise<void> { - 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<void> { + const workspaceId = readResultWorkspaceId(result, context) try { - await useEnvironmentStore.getState().loadEnvironmentVariables() + const queryClient = getQueryClient() + if (toolName === CopilotTool.set_environment_variables) { + 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() }) + } + window.dispatchEvent(new CustomEvent(MCP_TOOLS_CHANGED_EVENT, { detail: { workspaceId } })) + return + } + + if (!workspaceId || !toolName || !/^(create|edit|rename)_/.test(toolName)) { + return + } + + 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')) { + const entityId = + result && typeof result === 'object' && !Array.isArray(result) + ? (result as { entityId?: unknown }).entityId + : undefined + await Promise.all([ + queryClient.invalidateQueries({ queryKey: knowledgeKeys.list(workspaceId) }), + ...(toolName !== CopilotTool.create_knowledge_base && + typeof entityId === 'string' && + entityId.trim() + ? [queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(entityId) })] + : []), + ]) + } 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, { + detail: { 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, + }) } } 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/stores/custom-tools/types.ts b/apps/tradinggoose/stores/custom-tools/types.ts index 1199e9a4d..f8f83a2b3 100644 --- a/apps/tradinggoose/stores/custom-tools/types.ts +++ b/apps/tradinggoose/stores/custom-tools/types.ts @@ -17,7 +17,7 @@ export interface CustomToolDefinition { title: string schema: CustomToolSchema code: string - createdAt: string + createdAt?: string updatedAt?: string } 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<void> { // Reset all stores to their initial state resetAllStores() + getQueryClient().clear() // Clear localStorage except for essential app settings (minimal usage) const keysToKeep = ['next-favicon', 'theme'] diff --git a/apps/tradinggoose/stores/indicators/types.ts b/apps/tradinggoose/stores/indicators/types.ts index 86d58a7f0..49488e6f1 100644 --- a/apps/tradinggoose/stores/indicators/types.ts +++ b/apps/tradinggoose/stores/indicators/types.ts @@ -8,7 +8,7 @@ export interface IndicatorDefinition { color?: string pineCode: string inputMeta?: InputMetaMap | null - createdAt: string + createdAt?: string updatedAt?: string } diff --git a/apps/tradinggoose/stores/knowledge/store.ts b/apps/tradinggoose/stores/knowledge/store.ts index a0a7a4695..0205da803 100644 --- a/apps/tradinggoose/stores/knowledge/store.ts +++ b/apps/tradinggoose/stores/knowledge/store.ts @@ -17,8 +17,8 @@ export interface KnowledgeBaseData { embeddingModel: string embeddingDimension: number chunkingConfig: ChunkingConfig - createdAt: string - updatedAt: string + createdAt?: string + updatedAt?: string workspaceId: string } diff --git a/apps/tradinggoose/stores/mcp-servers/store.ts b/apps/tradinggoose/stores/mcp-servers/store.ts index 9a896b546..deb403b79 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<McpServersState & McpServersActions>()( devtools( (set) => ({ @@ -21,7 +23,10 @@ export const useMcpServersStore = create<McpServersState & McpServersActions>()( 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({ servers: listedServers, isLoading: false }) logger.info( `Fetched ${data.data?.servers?.length || 0} MCP servers for workspace ${workspaceId}` ) @@ -71,9 +76,6 @@ export const useMcpServersStore = create<McpServersState & McpServersActions>()( 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], @@ -90,42 +92,39 @@ export const useMcpServersStore = create<McpServersState & McpServersActions>()( } }, - 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 : name set((state) => ({ servers: state.servers.map((server) => - server.id === id && server.workspaceId === workspaceId - ? { - ...server, - ...(updatedServer || updates), - updatedAt: updatedServer?.updatedAt || new Date().toISOString(), - } + server.id === id && server.workspaceId === workspaceId && nextName + ? { ...server, name: nextName } : server ), 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 } @@ -162,8 +161,8 @@ export const useMcpServersStore = create<McpServersState & McpServersActions>()( } }, - refreshServer: async (workspaceId: string, id: string) => { - const refreshedAt = new Date().toISOString() + refreshServer: async (workspaceId: string, id: string, result) => { + const refreshedAt = result?.lastToolsRefresh ?? new Date().toISOString() set((state) => ({ servers: state.servers.map((server) => @@ -171,6 +170,10 @@ export const useMcpServersStore = create<McpServersState & McpServersActions>()( ? { ...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 ), @@ -186,5 +189,7 @@ export const useMcpServersStore = create<McpServersState & McpServersActions>()( ) export const useEnabledServers = () => { - return useMcpServersStore((state) => state.servers.filter((s) => s.enabled && !s.deletedAt)) + return useMcpServersStore((state) => + state.servers.filter((server) => !server.deletedAt && server.enabled !== false) + ) } diff --git a/apps/tradinggoose/stores/mcp-servers/types.ts b/apps/tradinggoose/stores/mcp-servers/types.ts index 2c365eb9d..4b1674355 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<string, string> command?: string | null @@ -52,13 +52,23 @@ export interface McpServersActions { | 'workspaceId' > ) => Promise<McpServerWithStatus> - updateServer: ( + renameServer: ( workspaceId: string, id: string, - updates: Partial<McpServerWithStatus> + name: string ) => Promise<McpServerWithStatus | null> deleteServer: (workspaceId: string, id: string) => Promise<void> - refreshServer: (workspaceId: string, id: string) => Promise<void> + refreshServer: ( + workspaceId: string, + id: string, + result?: { + status?: McpServerWithStatus['connectionStatus'] + toolCount?: number + lastConnected?: string | null + lastToolsRefresh?: string | null + error?: string | null + } + ) => Promise<void> } export const initialState: McpServersState = { diff --git a/apps/tradinggoose/stores/skills/types.ts b/apps/tradinggoose/stores/skills/types.ts index 8f3a511f7..d80375056 100644 --- a/apps/tradinggoose/stores/skills/types.ts +++ b/apps/tradinggoose/stores/skills/types.ts @@ -5,7 +5,7 @@ export interface SkillDefinition { name: string description: string content: string - createdAt: string + createdAt?: string updatedAt?: string } 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' - diff --git a/apps/tradinggoose/stores/workflows/json/importer.ts b/apps/tradinggoose/stores/workflows/json/importer.ts index 5f89c58c3..1bd7dce7e 100644 --- a/apps/tradinggoose/stores/workflows/json/importer.ts +++ b/apps/tradinggoose/stores/workflows/json/importer.ts @@ -4,16 +4,16 @@ import { parseImportedWorkflowFile, type WorkflowTransferRecord, } from '@/lib/workflows/import-export' -import type { WorkflowState } from '../workflow/types' const logger = createLogger('WorkflowJsonImporter') +type ImportedWorkflowState = WorkflowTransferRecord['state'] /** * Generate new IDs for all blocks and edges to avoid conflicts */ -function regenerateIds(workflowState: WorkflowState): WorkflowState { +function regenerateIds(workflowState: ImportedWorkflowState): ImportedWorkflowState { const blockIdMap = new Map<string, string>() - const newBlocks: WorkflowState['blocks'] = {} + const newBlocks: ImportedWorkflowState['blocks'] = {} // First pass: create new IDs for all blocks Object.entries(workflowState.blocks).forEach(([oldId, block]) => { @@ -35,7 +35,7 @@ function regenerateIds(workflowState: WorkflowState): WorkflowState { // Third pass: update loops with new block IDs // CRITICAL: Loop IDs must match their block IDs (loops are keyed by their block ID) - const newLoops: WorkflowState['loops'] = {} + const newLoops: ImportedWorkflowState['loops'] = {} if (workflowState.loops) { Object.entries(workflowState.loops).forEach(([oldLoopId, loop]) => { // Map the loop ID using the block ID mapping (loop ID = block ID) @@ -50,7 +50,7 @@ function regenerateIds(workflowState: WorkflowState): WorkflowState { // Fourth pass: update parallels with new block IDs // CRITICAL: Parallel IDs must match their block IDs (parallels are keyed by their block ID) - const newParallels: WorkflowState['parallels'] = {} + const newParallels: ImportedWorkflowState['parallels'] = {} if (workflowState.parallels) { Object.entries(workflowState.parallels).forEach(([oldParallelId, parallel]) => { // Map the parallel ID using the block ID mapping (parallel ID = block ID) @@ -103,6 +103,7 @@ function regenerateIds(workflowState: WorkflowState): WorkflowState { edges: newEdges, loops: newLoops, parallels: newParallels, + variables: workflowState.variables, } } diff --git a/apps/tradinggoose/stores/workflows/json/store.test.ts b/apps/tradinggoose/stores/workflows/json/store.test.ts index 7bb8e239a..037eefa3d 100644 --- a/apps/tradinggoose/stores/workflows/json/store.test.ts +++ b/apps/tradinggoose/stores/workflows/json/store.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSkillsStore } from '@/stores/skills/store' const mockGetSnapshotForWorkflow = vi.hoisted(() => vi.fn()) const mockWorkflowRegistryState = vi.hoisted(() => ({ @@ -29,6 +30,7 @@ import { useWorkflowJsonStore } from './store' describe('workflow json store', () => { beforeEach(() => { mockGetSnapshotForWorkflow.mockReset() + useSkillsStore.getState().resetAll() useWorkflowJsonStore.setState({ json: '', lastGenerated: undefined, @@ -64,20 +66,32 @@ describe('workflow json store', () => { isDeployed: false, deployedAt: undefined, }) + 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 workspace 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 { diff --git a/apps/tradinggoose/stores/workflows/json/store.ts b/apps/tradinggoose/stores/workflows/json/store.ts index 5020fc270..e7b197b26 100644 --- a/apps/tradinggoose/stores/workflows/json/store.ts +++ b/apps/tradinggoose/stores/workflows/json/store.ts @@ -1,10 +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 { createWorkflowExportFile } from '@/lib/workflows/import-export' 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 +11,15 @@ const logger = createLogger('WorkflowJsonStore') export interface WorkflowJsonScope { workflowId?: string | null channelId?: string - workspaceSkills?: Array<Pick<SkillDefinition, 'id' | 'name' | 'description' | 'content'>> } interface WorkflowJsonStore { json: string lastGenerated?: number - generateJson: (scope?: WorkflowJsonScope) => void + generateJson: (scope?: WorkflowJsonScope) => Promise<void> getJson: (scope?: WorkflowJsonScope) => Promise<string> - refreshJson: (scope?: WorkflowJsonScope) => void + refreshJson: (scope?: WorkflowJsonScope) => Promise<void> } export const useWorkflowJsonStore = create<WorkflowJsonStore>()( @@ -30,7 +28,7 @@ export const useWorkflowJsonStore = create<WorkflowJsonStore>()( json: '', lastGenerated: undefined, - generateJson: (scope) => { + generateJson: async (scope) => { const clearJson = () => set({ json: '', @@ -68,27 +66,15 @@ export const useWorkflowJsonStore = create<WorkflowJsonStore>()( 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 exportFile = createWorkflowExportFile({ workflow: { name: currentWorkflow.name, description: currentWorkflow.description ?? '', state: workflowSnapshot, }, - skills: workspaceSkills, + skills: currentWorkflow.workspaceId + ? useSkillsStore.getState().getAllSkills(currentWorkflow.workspaceId) + : [], }) // Convert to formatted JSON @@ -123,15 +109,15 @@ export const useWorkflowJsonStore = create<WorkflowJsonStore>()( // 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/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<WorkflowRegistry>()( 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<string> duplicateWorkflow: (sourceId: string) => Promise<string | null> readWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null 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({ 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<string, string> = {} 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<IndicatorEditorActionEventDetail>).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<IndicatorEditorStateEventDetail>).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<IndicatorEditorStateEventDetail>(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<SkillEditorActionEventDetail>).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<SkillEditorStateEventDetail>).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<SkillEditorStateEventDetail>(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..41a9ace13 100644 --- a/apps/tradinggoose/widgets/widgets/_shared/mcp/utils.ts +++ b/apps/tradinggoose/widgets/widgets/_shared/mcp/utils.ts @@ -1,6 +1,4 @@ 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,42 +26,6 @@ export const createDefaultMcpServerFormData = (): McpServerFormData => ({ env: {}, }) -export const createFormDataFromServer = ( - server: Partial<McpServerWithStatus> -): 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, - 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/components/custom-tool-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/custom-tool-dropdown.tsx index d68d93f12..5f3194b94 100644 --- a/apps/tradinggoose/widgets/widgets/components/custom-tool-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/custom-tool-dropdown.tsx @@ -65,11 +65,7 @@ export function CustomToolDropdown({ const workspaceTools = useMemo(() => { const tools = queryTools.length > 0 ? queryTools : storedTools - return [...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) - }) + return [...tools].sort((a, b) => a.title.localeCompare(b.title)) }, [queryTools, storedTools]) const selectedToolId = value ?? null diff --git a/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx index 3e7736e80..ea9745ccc 100644 --- a/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx @@ -2,6 +2,7 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react' import { Check, ChevronDown, Loader2, Search, Server } from 'lucide-react' +import { useMessages } from 'next-intl' import { shallow } from 'zustand/shallow' import { DropdownMenu, @@ -19,7 +20,6 @@ import { widgetHeaderMenuTextClassName, } from '@/components/widget-header-control' import { cn } from '@/lib/utils' -import { useMessages } from 'next-intl' import { useMcpServersStore } from '@/stores/mcp-servers/store' import type { McpServerWithStatus } from '@/stores/mcp-servers/types' @@ -76,12 +76,11 @@ export function McpDropdown({ if (!workspaceId) return [] return 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) - }) + .filter( + (server) => + server.workspaceId === workspaceId && !server.deletedAt && server.enabled !== false + ) + .sort((a, b) => getServerLabel(a).localeCompare(getServerLabel(b))) }, [servers, workspaceId]) const selectedServerId = value ?? null @@ -128,12 +127,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/components/pine-indicator-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/pine-indicator-dropdown.tsx index cac1b4bb7..b1d6a1bda 100644 --- a/apps/tradinggoose/widgets/widgets/components/pine-indicator-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/pine-indicator-dropdown.tsx @@ -89,11 +89,7 @@ export function IndicatorDropdown({ const workspaceIndicators = useMemo(() => { if (!workspaceId) return [] const scoped = [...indicators] - return scoped.sort((a, b) => { - const aTime = Date.parse(a.createdAt) - const bTime = Date.parse(b.createdAt) - return (Number.isNaN(bTime) ? 0 : bTime) - (Number.isNaN(aTime) ? 0 : aTime) - }) + return scoped.sort((a, b) => a.name.localeCompare(b.name)) }, [indicators, workspaceId]) const defaultIndicatorOptions = useMemo<IndicatorOption[]>( 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 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_custom_tool/custom-tool-editor.test.tsx b/apps/tradinggoose/widgets/widgets/editor_custom_tool/custom-tool-editor.test.tsx index 0373f0671..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 @@ -6,19 +6,12 @@ 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() const mockUseWand = vi.fn() -vi.mock('@/hooks/queries/custom-tools', async () => { - const actual = await vi.importActual<any>('@/hooks/queries/custom-tools') - return { - ...actual, - useUpdateCustomTool: () => mockUseUpdateCustomTool(), - } -}) - vi.mock('@/hooks/workflow/use-wand', () => ({ useWand: (...args: unknown[]) => mockUseWand(...args), })) @@ -98,6 +91,22 @@ 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 @@ -113,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') @@ -166,33 +171,28 @@ describe('CustomToolEditor export', () => { }, code: 'return { movers: [] }', } + const doc = createCustomToolDoc(initialValues) await act(async () => { root.render( <CustomToolEditor activeSection='schema' blockId='dashboard-custom-tool-editor' - initialValues={initialValues} + toolId='tool-1' onSave={vi.fn()} onSectionChange={onSectionChange} exportRef={exportRef as MutableRefObject<() => void>} saveRef={saveRef as MutableRefObject<() => void>} + doc={doc} + save={vi.fn()} /> ) }) - 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 +213,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 +221,19 @@ describe('CustomToolEditor export', () => { <CustomToolEditor activeSection='code' blockId='dashboard-custom-tool-editor' - initialValues={initialValues} + toolId='tool-1' onSave={vi.fn()} onSectionChange={onSectionChange} exportRef={exportRef as MutableRefObject<() => void>} saveRef={saveRef as MutableRefObject<() => void>} + doc={doc} + save={vi.fn()} /> ) }) - 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 +242,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 +257,7 @@ describe('CustomToolEditor export', () => { workflows: [], customTools: [ { - title: 'Fetch Top Movers', + title: 'fetchTopMoversCurrent', schema: { type: 'function', function: { @@ -289,6 +279,7 @@ describe('CustomToolEditor export', () => { watchlists: [], indicators: [], }) + doc.destroy() }) it('blocks export when the current schema is invalid', async () => { @@ -311,34 +302,26 @@ describe('CustomToolEditor export', () => { }, code: 'return { movers: [] }', } + const doc = createCustomToolDoc(initialValues) await act(async () => { root.render( <CustomToolEditor activeSection='schema' blockId='dashboard-custom-tool-editor' - initialValues={initialValues} + toolId='tool-1' onSave={vi.fn()} onSectionChange={onSectionChange} exportRef={exportRef as MutableRefObject<() => void>} saveRef={saveRef as MutableRefObject<() => void>} + doc={doc} + save={vi.fn()} /> ) }) - 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 +330,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..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 @@ -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,7 +13,7 @@ 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 { useUpdateCustomTool } from '@/hooks/queries/custom-tools' +import { useYjsStringField } from '@/lib/yjs/use-entity-fields' 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' @@ -23,17 +24,12 @@ 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 + save: () => Promise<void> onSave: () => void onSectionChange: (section: CustomToolEditorSection) => void exportRef: MutableRefObject<() => void> @@ -43,7 +39,9 @@ interface CustomToolEditorProps { export function CustomToolEditor({ activeSection, blockId, - initialValues, + toolId, + doc, + save, onSave, onSectionChange, exportRef, @@ -51,8 +49,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<string | null>(null) const [codeError, setCodeError] = useState<string | null>(null) const codeEditorRef = useRef<HTMLDivElement>(null) @@ -67,23 +66,10 @@ export function CustomToolEditor({ const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }) const [schemaParamSelectedIndex, setSchemaParamSelectedIndex] = useState(0) - 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 +216,6 @@ IMPORTANT FORMATTING RULES: onStreamChunk: (chunk) => { setFunctionCode((prev) => { const nextCode = prev + chunk - handleFunctionCodeChange(nextCode) if (codeError) { setCodeError(null) } @@ -346,6 +331,8 @@ IMPORTANT FORMATTING RULES: }, [jsonSchema, onSectionChange]) const handleSave = useCallback(async () => { + if (!doc) return + setCodeError(null) try { @@ -354,15 +341,19 @@ IMPORTANT FORMATTING RULES: return } - await updateToolMutation.mutateAsync({ - workspaceId, - toolId: initialValues.id, - updates: { - title: initialValues.title, - schema, - code: functionCode || '', - }, - }) + const title = toolTitle.trim() + if (!title) { + setSchemaError(copy.validation.failedToSave) + onSectionChange('schema') + return + } + + const latestFunctionCode = + codeEditorHandleRef.current?.getEditor()?.getValue() ?? functionCode + + setFunctionCode(latestFunctionCode) + + await save() onSave() } catch (error) { @@ -372,12 +363,14 @@ IMPORTANT FORMATTING RULES: } }, [ parseCurrentSchema, - functionCode, - initialValues.id, - initialValues.title, + doc, onSave, onSectionChange, - updateToolMutation, + save, + functionCode, + setFunctionCode, + toolTitle, + toolId, workspaceId, ]) @@ -387,7 +380,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 +412,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..c1ca91a33 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 { useSavedEntityYjsSession } from '@/lib/yjs/use-entity-fields' import { useCustomTools } from '@/hooks/queries/custom-tools' -import { useCustomToolsStore } from '@/stores/custom-tools/store' +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' @@ -37,11 +36,7 @@ import { WidgetStateMessage } from '@/widgets/widgets/editor_indicator/component import { WorkflowRouteProvider } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' 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)) function emitCustomToolEditorAction(detail: CustomToolEditorActionEventDetail) { window.dispatchEvent( @@ -115,9 +110,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 +118,7 @@ function EditorCustomToolWidgetBody({ const saveRef = useRef<() => void>(() => {}) const [activeSection, setActiveSection] = useState<CustomToolEditorSection>('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 +165,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 +198,12 @@ function EditorCustomToolWidgetBody({ ]) useEffect(() => { - if (!selectedTool?.id) { + if (!selectedToolId) { return } syncActiveSection('schema') - }, [selectedTool?.id, syncActiveSection]) + }, [selectedToolId, syncActiveSection]) useCustomToolEditorActions({ onExport: () => exportRef.current(), @@ -261,6 +251,18 @@ function EditorCustomToolWidgetBody({ return <WidgetStateMessage message={copy.body.customToolNotFound} /> } + if (customToolSession.error) { + return <WidgetStateMessage message={customToolSession.error} /> + } + + if (customToolSession.isLoading) { + return ( + <div className='flex h-full w-full items-center justify-center'> + <LoadingAgent size='md' /> + </div> + ) + } + return ( <WorkflowRouteProvider workspaceId={workspaceId} @@ -270,6 +272,9 @@ function EditorCustomToolWidgetBody({ <div className='flex h-full w-full flex-col overflow-hidden'> <CustomToolEditor activeSection={activeSection} + doc={customToolSession.doc} + save={customToolSession.save} + toolId={selectedToolId} onSectionChange={syncActiveSection} onSave={() => { refetch().catch((refetchError) => { @@ -279,12 +284,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 || '', - }} /> </div> </WorkflowRouteProvider> 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 ( - <Tooltip> - <TooltipTrigger asChild> - <span className='inline-flex'> - <Button - type='button' - variant='outline' - size='sm' - className='h-7 w-7 text-xs' - onClick={handleExport} - disabled={exportDisabled} - > - <Download className='h-4 w-4' /> - <span className='sr-only'>{copy.exportIndicator}</span> - </Button> - </span> - </TooltipTrigger> - <TooltipContent side='top'>{tooltipText}</TooltipContent> - </Tooltip> + <EntityEditorHeaderButton + tooltip={copy.exportIndicator} + label={copy.exportIndicator} + icon={Download} + disabled={exportDisabled} + onClick={() => 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 ( - <Tooltip> - <TooltipTrigger asChild> - <span className='inline-flex'> - <Button - type='button' - variant='default' - size='sm' - className='h-7 w-7 text-xs' - onClick={handleSave} - disabled={saveDisabled} - > - <Save className='h-4 w-4' /> - <span className='sr-only'>{copy.saveIndicator}</span> - </Button> - </span> - </TooltipTrigger> - <TooltipContent side='top'>{copy.saveIndicator}</TooltipContent> - </Tooltip> + <EntityEditorHeaderButton + tooltip={copy.saveIndicator} + label={copy.saveIndicator} + icon={Save} + disabled={saveDisabled} + variant='default' + onClick={() => 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 ( - <Tooltip> - <TooltipTrigger asChild> - <span className='inline-flex'> - <Button - type='button' - variant='secondary' - size='sm' - className='h-7 w-7 text-xs' - onClick={handleVerify} - disabled={verifyDisabled} - > - <Check className='h-4 w-4' /> - <span className='sr-only'>{copy.verifyIndicator}</span> - </Button> - </span> - </TooltipTrigger> - <TooltipContent side='top'>Verify indicator</TooltipContent> - </Tooltip> + <EntityEditorHeaderButton + tooltip={copy.verifyIndicator} + label={copy.verifyIndicator} + icon={Check} + disabled={verifyDisabled} + variant='secondary' + onClick={() => 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..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 @@ -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, @@ -16,16 +17,15 @@ import { } from '@/components/ui/select' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' 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' import { detectTriggerUsage } from '@/lib/indicators/trigger-detection' import { detectUnsupportedFeatures } from '@/lib/indicators/unsupported' import { generateMockMarketSeries } from '@/lib/market/mock-series' -import { useUpdateIndicator } from '@/hooks/queries/indicators' +import { useYjsStringField } from '@/lib/yjs/use-entity-fields' 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 +34,13 @@ 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 + save: () => Promise<void> + 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, + save, + 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' } @@ -178,6 +177,7 @@ export function IndicatorCodePanel({ | { state: 'warning'; message: string; warnings: string[] } | { state: 'error'; message: string } >({ state: 'idle' }) + const [saveError, setSaveError] = useState<string | null>(null) const [showEnvVars, setShowEnvVars] = useState(false) const [envVarSearchTerm, setEnvVarSearchTerm] = useState('') @@ -187,7 +187,6 @@ export function IndicatorCodePanel({ const codeEditorRef = useRef<HTMLDivElement>(null) const codeEditorHandleRef = useRef<MonacoEditorHandle | null>(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 +216,9 @@ 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]) + setSaveError(null) + }, [doc, indicatorId]) const updateCursorState = ( value: string, @@ -274,27 +260,60 @@ export function IndicatorCodePanel({ } const handleSave = useCallback(async () => { - if (!workspaceId || !indicatorId) return - const disallowedMessage = validateNoDollarGlobals(pineCode) + if (!workspaceId || !indicatorId || !doc) return + const currentPineCode = codeEditorHandleRef.current?.getEditor()?.getValue() ?? pineCode + const disallowedMessage = validateNoDollarGlobals(currentPineCode) if (disallowedMessage) { + setSaveError(null) setVerifyStatus({ state: 'error', message: disallowedMessage }) return } - const inferredInputMeta = inferInputMetaFromPineCode(pineCode) + + setSaveError(null) try { - await updateMutation.mutateAsync({ - workspaceId, - indicatorId, - updates: { - pineCode, - inputMeta: inferredInputMeta ?? null, - }, - }) + if (currentPineCode !== pineCode) { + setPineCode(currentPineCode) + } + + await save() } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Failed to save indicator.') console.error('Failed to update indicator', err) } - }, [workspaceId, indicatorId, updateMutation, pineCode]) + }, [workspaceId, indicatorId, doc, pineCode, save, setPineCode]) + + const handleExport = useCallback(() => { + if (!doc) return + const json = exportIndicatorsAsJson({ + exportedFrom: 'indicatorEditor', + indicators: [ + { + name: indicatorName, + pineCode, + }, + ], + }) + 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 @@ -444,6 +463,11 @@ export function IndicatorCodePanel({ )} </Notice> )} + {saveError ? ( + <Notice variant='error' title='Save failed'> + {saveError} + </Notice> + ) : null} </div> <div ref={codeEditorRef} className='relative mt-2 flex min-h-0 flex-1 flex-col rounded-md'> 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..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,9 +1,9 @@ '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 { 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' @@ -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 ?? '') @@ -47,10 +46,13 @@ 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 + const indicatorSession = useSavedEntityYjsSession('indicator', indicatorId, workspaceId) useEffect(() => { if (!indicatorId) { @@ -97,9 +99,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 +118,7 @@ export function EditorIndicatorWidgetBody({ useIndicatorEditorActions({ panelId, widget, + onExport: handleExport, onSave: handleSave, onVerify: handleVerify, }) @@ -153,16 +161,28 @@ export function EditorIndicatorWidgetBody({ return <WidgetStateMessage message={copy.indicatorNotFound} /> } + if (indicatorSession.error) { + return <WidgetStateMessage message={indicatorSession.error} /> + } + + if (indicatorSession.isLoading) { + return ( + <div className='flex h-full w-full items-center justify-center'> + <LoadingAgent size='md' /> + </div> + ) + } + return ( <div className='flex h-full w-full flex-col overflow-hidden'> <IndicatorCodePanel - indicator={indicator} indicatorId={indicatorId} workspaceId={workspaceId} + doc={indicatorSession.doc} + save={indicatorSession.save} + exportRef={codeExportRef} saveRef={codeSaveRef} verifyRef={codeVerifyRef} - panelId={panelId} - widgetKey={widget?.key} /> </div> ) 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<string>((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<typeof vi.fn> - let revokeObjectUrlSpy: ReturnType<typeof vi.fn> - let clickSpy: ReturnType<typeof vi.fn> 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<IndicatorEditorActionEventDetail>).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..774227d1b 100644 --- a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx @@ -1,10 +1,15 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useMessages } from 'next-intl' +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 { useMessages } from 'next-intl' import { formatTemplate } from '@/i18n/utils' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import { useMcpServersStore } from '@/stores/mcp-servers/store' @@ -16,8 +21,6 @@ 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' @@ -25,9 +28,6 @@ import { WidgetStateMessage } from '@/widgets/widgets/editor_indicator/component type EditorMcpWidgetBodyProps = WidgetComponentProps -const getServerName = (server?: Pick<McpServerWithStatus, 'name'> | null) => - server?.name?.trim() || '' - const formatRelativeTime = ( dateString: string | undefined, copy: { @@ -84,6 +84,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<McpServerFormData>) => 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<McpServerFormData>) => { + 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,26 +164,22 @@ export function EditorMcpWidgetBody({ const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) const setPairContext = useSetPairColorContext() - const [formDataState, setFormDataState] = useState<McpServerFormData>(() => - createDefaultMcpServerFormData() - ) const [saveError, setSaveError] = useState<string | null>(null) const initialFormDataRef = useRef<McpServerFormData>(createDefaultMcpServerFormData()) const initializedServerIdRef = useRef<string | null>(null) + const defaultFormData = useMemo(() => createDefaultMcpServerFormData(), []) const { servers, isLoading: isServersLoading, 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() @@ -152,11 +194,7 @@ export function EditorMcpWidgetBody({ 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) => (a.name || a.id).localeCompare(b.name || b.id)) : [], [servers, workspaceId] ) @@ -164,6 +202,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 +217,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,28 +261,39 @@ 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, + headers: sanitizeRecord(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 + if ( + !workspaceId || + !selectedServerId || + formDataState.enabled === false || + !formDataState.url?.trim() + ) { + return + } try { - await refreshServerApi(selectedServerId, workspaceId, copy.failedToRefreshMcpServer) - await refreshServer(workspaceId, selectedServerId) - await refreshTools(true) + const refreshResult = await refreshServerApi( + selectedServerId, + workspaceId, + copy.failedToRefreshMcpServer + ) + await refreshServer(workspaceId, selectedServerId, refreshResult?.data) + await refreshTools() await fetchServers(workspaceId) } catch (refreshError) { console.error('Failed to refresh MCP server tools', refreshError) @@ -252,6 +302,8 @@ export function EditorMcpWidgetBody({ }, [ copy.failedToRefreshMcpServer, fetchServers, + formDataState.enabled, + formDataState.url, refreshServer, refreshTools, selectedServerId, @@ -259,10 +311,9 @@ export function EditorMcpWidgetBody({ ]) const handleSave = useCallback(async () => { - if (!workspaceId || !selectedServerId) return + if (!workspaceId || !selectedServerId || !serverSession.doc) return - const payload = createMcpSavePayload(formDataState) - if (!payload.name) { + if (!formDataState.name.trim()) { setSaveError(copy.serverNameRequired) return } @@ -270,9 +321,14 @@ export function EditorMcpWidgetBody({ setSaveError(null) try { - await updateServer(workspaceId, selectedServerId, payload) + await serverSession.save() initialFormDataRef.current = formDataState - await fetchServers(workspaceId) + 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) @@ -282,8 +338,11 @@ export function EditorMcpWidgetBody({ copy.serverNameRequired, fetchServers, formDataState, + handleRefreshTools, + refreshTools, + serverSession.doc, + serverSession.save, selectedServerId, - updateServer, workspaceId, ]) @@ -325,6 +384,18 @@ export function EditorMcpWidgetBody({ return <WidgetStateMessage message={copy.mcpServerNotFound} /> } + if (serverSession.error) { + return <WidgetStateMessage message={serverSession.error} /> + } + + if (serverSession.isLoading) { + return ( + <div className='flex h-full w-full items-center justify-center'> + <LoadingAgent size='md' /> + </div> + ) + } + const displayStatus = selectedServer.connectionStatus ?? 'disconnected' return ( @@ -333,7 +404,7 @@ export function EditorMcpWidgetBody({ <div className='space-y-2'> <div className='flex flex-wrap items-center gap-2'> <h3 className='font-medium text-foreground text-sm'> - {getServerName(selectedServer) || copy.unnamedServer} + {formDataState.name.trim() || copy.unnamedServer} </h3> <span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 font-medium text-[10px] ${getStatusClassName(displayStatus)}`} @@ -346,13 +417,6 @@ export function EditorMcpWidgetBody({ </span> </div> <div className='flex flex-wrap items-center gap-2 text-muted-foreground text-xs'> - {selectedServer.updatedAt ? ( - <span> - {formatTemplate(copy.updated, { - time: formatRelativeTime(selectedServer.updatedAt, copy.relativeTime) ?? '', - })} - </span> - ) : null} {selectedServer.lastToolsRefresh ? ( <span> {formatTemplate(copy.toolsRefreshed, { diff --git a/apps/tradinggoose/widgets/widgets/editor_skill/components/skill-editor-header.tsx b/apps/tradinggoose/widgets/widgets/editor_skill/components/skill-editor-header.tsx index c295efd2e..286b7c310 100644 --- a/apps/tradinggoose/widgets/widgets/editor_skill/components/skill-editor-header.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_skill/components/skill-editor-header.tsx @@ -1,16 +1,11 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' import { 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 { exportSkillsAsJson } from '@/lib/skills/import-export' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' -import { useSkillsStore } from '@/stores/skills/store' import type { PairColor } from '@/widgets/pair-colors' -import { emitSkillEditorAction, useSkillEditorState } from '@/widgets/utils/skill-editor-actions' +import { emitSkillEditorAction } from '@/widgets/utils/skill-editor-actions' import { emitSkillSelectionChange } from '@/widgets/utils/skill-selection' import { readEntitySelectionState, @@ -79,25 +74,6 @@ interface SkillEditorActionButtonProps { params?: Record<string, unknown> | 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 ( - <Tooltip> - <TooltipTrigger asChild> - <span className='inline-flex'> - <Button - type='button' - variant='outline' - size='sm' - className='h-7 w-7 text-xs' - onClick={handleExport} - disabled={exportDisabled} - > - <Download className='h-4 w-4' /> - <span className='sr-only'>{copy.exportSkill}</span> - </Button> - </span> - </TooltipTrigger> - <TooltipContent side='top'>{tooltipText}</TooltipContent> - </Tooltip> + <EntityEditorHeaderButton + tooltip={copy.exportSkill} + label={copy.exportSkill} + icon={Download} + disabled={exportDisabled} + onClick={() => 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..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,14 +1,14 @@ 'use client' -import { useEffect, useRef, useState } from 'react' -import { useLocale } from 'next-intl' -import { LoadingAgent } from '@/components/ui/loading-agent' +import { useEffect, useRef } from 'react' import { useMessages } from 'next-intl' +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' -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 @@ -43,8 +42,11 @@ 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) useEffect(() => { if (!skillId) { @@ -94,31 +96,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 <WidgetStateMessage message={copy.selectWorkspace} /> } @@ -157,18 +138,26 @@ export function EditorSkillWidgetBody({ return <WidgetStateMessage message={copy.skillNotFound} /> } + if (skillSession.error) { + return <WidgetStateMessage message={skillSession.error} /> + } + + if (skillSession.isLoading) { + return ( + <div className='flex h-full w-full items-center justify-center'> + <LoadingAgent size='md' /> + </div> + ) + } + return ( <div className='flex h-full w-full flex-col overflow-hidden'> <SkillEditor - workspaceId={workspaceId} + doc={skillSession.doc} + save={skillSession.save} + skillId={skillId} + exportRef={exportRef} saveRef={saveRef} - onDirtyChange={setIsDirty} - initialValues={{ - id: skill.id, - name: skill.name, - description: skill.description, - content: skill.content, - }} /> </div> ) 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<string>((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<typeof vi.fn> - let revokeObjectUrlSpy: ReturnType<typeof vi.fn> - let clickSpy: ReturnType<typeof vi.fn> 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<SkillEditorActionEventDetail>).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<SkillEditorActionEventDetail>).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..854fc2303 100644 --- a/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.test.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.test.tsx @@ -6,23 +6,15 @@ 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() - -vi.mock('@/hooks/queries/skills', async () => { - const actual = await vi.importActual<any>('@/hooks/queries/skills') - return { - ...actual, - useUpdateSkill: () => mockUseUpdateSkill(), - } -}) - 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,29 +33,28 @@ describe('SkillEditor dirty state', () => { container.remove() }) - it('returns to a clean state after a successful save', async () => { - const mutateAsync = vi.fn().mockResolvedValue({}) - const onDirtyChange = vi.fn() + it('saves the current Yjs fields', async () => { + const save = vi.fn().mockResolvedValue(undefined) + 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 = () => {} - - mockUseUpdateSkill.mockReturnValue({ - isPending: false, - mutateAsync, - }) + seedEntitySession(doc, { entityKind: 'skill', payload: initialValues }) await act(async () => { root.render( <SkillEditor - workspaceId='workspace-1' + exportRef={exportRef as MutableRefObject<() => 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} + save={save} /> ) }) @@ -78,22 +69,12 @@ describe('SkillEditor dirty state', () => { nameInput!.dispatchEvent(new Event('change', { bubbles: true })) }) - expect(onDirtyChange).toHaveBeenLastCalledWith(true) - await act(async () => { saveRef.current?.() 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(onDirtyChange).toHaveBeenLastCalledWith(false) + 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 93f39d603..6135406c1 100644 --- a/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.tsx @@ -1,82 +1,41 @@ 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 { 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 } from '@/hooks/queries/skills' 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 { 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 + save: () => Promise<void> + exportRef: MutableRefObject<() => void> saveRef: MutableRefObject<() => void> - onDirtyChange?: (isDirty: boolean) => void } -export function SkillEditor({ - workspaceId, - initialValues, - saveRef, - onDirtyChange, -}: SkillEditorProps) { +export function SkillEditor({ skillId, doc, save, exportRef, saveRef }: 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<string | null>(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,50 +60,46 @@ 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, - updates: { - name: trimmedName, - description: trimmedDescription, - content: trimmedContent, - }, - }) - - setName(trimmedName) - setDescription(trimmedDescription) - setContent(trimmedContent) - setSavedValues({ - name: trimmedName, - description: trimmedDescription, - content: trimmedContent, - }) + await save() } 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, copy.validation, description, doc, name, save, skillId]) + + 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,12 +117,10 @@ 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} /> - <p className='text-muted-foreground text-xs'> - {copy.form.helperText} - </p> + <p className='text-muted-foreground text-xs'>{copy.form.helperText}</p> </div> <div className='space-y-2'> @@ -177,7 +130,7 @@ export function SkillEditor({ value={description} onChange={(event) => setDescription(event.target.value)} placeholder={copy.form.descriptionPlaceholder} - disabled={isSaving} + disabled={!doc || isSaving} maxLength={1024} /> </div> @@ -189,7 +142,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} /> 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..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 @@ -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<string, any>, @@ -55,7 +21,6 @@ export async function applyAutoLayoutToWorkflow( options: AutoLayoutOptions = {} ): Promise<{ success: boolean - layoutedBlocks?: Record<string, any> error?: string }> { try { @@ -118,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' @@ -138,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 }> { @@ -156,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() @@ -212,95 +173,15 @@ 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, }) - - 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 || []), - } - - 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 to database', { - workflowId: resolvedWorkflowId, - channelId, - }) - return { success: true } - } catch (saveError) { - const message = - saveError instanceof Error && saveError.message - ? saveError.message - : JSON.stringify(saveError) - logger.error('Failed to save auto layout to database, 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 to database: ${ - 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/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) { 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..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 @@ -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<string | null>(null) // Deployed state management const [deployedState, setDeployedState] = useState<WorkflowState | null>(null) @@ -349,13 +350,13 @@ 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, }) @@ -364,11 +365,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 +563,19 @@ export function ControlBar({ return ( <div className={containerClass}> + {autoLayoutError ? ( + <Tooltip> + <TooltipTrigger asChild> + <div className='flex h-7 max-w-48 items-center gap-1 rounded-sm border border-destructive/30 bg-destructive/10 px-2 text-destructive'> + <AlertTriangle className='h-3.5 w-3.5 shrink-0' /> + <span className='truncate text-xs'>Auto layout failed</span> + </div> + </TooltipTrigger> + <TooltipContent side='bottom' className='max-w-xs'> + {autoLayoutError} + </TooltipContent> + </Tooltip> + ) : null} {showOptionalControls && <ExportControls variant={variant} />} {showOptionalControls && renderAutoLayoutButton()} {renderDeployButton()} 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/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..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 @@ -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,7 +14,6 @@ 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 { 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' 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, }) 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') }) diff --git a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx index c4820d423..a3e960989 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' @@ -54,17 +53,11 @@ 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( - 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/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) { 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..2cfc1d003 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' @@ -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, }) @@ -214,7 +215,7 @@ const ListMcpWidgetContent = ({ const permissions = useUserPermissionsContext() const [hasRequestedLoad, setHasRequestedLoad] = useState(false) const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set()) - const { servers, isLoading, error, fetchServers, deleteServer, updateServer } = + const { servers, isLoading, error, fetchServers, deleteServer, renameServer } = useMcpServersStore( (state) => ({ servers: state.servers, @@ -222,7 +223,7 @@ const ListMcpWidgetContent = ({ error: state.error, fetchServers: state.fetchServers, deleteServer: state.deleteServer, - updateServer: state.updateServer, + renameServer: state.renameServer, }), shallow ) @@ -237,11 +238,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] ) @@ -354,11 +351,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() }, - [permissions.canEdit, updateServer, workspaceId] + [permissions.canEdit, refreshTools, renameServer, workspaceId] ) const handleDeleteServer = useCallback( @@ -369,7 +365,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) } 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, 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 c3f361974..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,67 +114,28 @@ export function DashboardWorkflowCreateMenu({ .filter((workflow) => workflow.workspaceId === workspaceId) .map((workflow) => workflow.name) + let importedSkillsBySourceName: + | ReturnType<typeof buildImportedWorkflowSkillsLookup> + | undefined + if (parsedWorkflow.data.skills.length > 0) { const importResult = await importSkillsMutation.mutateAsync({ workspaceId, file: parsedFile, }) - const importedSkillsBySourceName = buildImportedWorkflowSkillsLookup({ + importedSkillsBySourceName = buildImportedWorkflowSkillsLookup({ expectedSkills: parsedWorkflow.data.skills, importedSkills: importResult?.importedSkills, }) - - const newWorkflowId = await importWorkflowFromJsonContent({ - content, - workspaceId, - existingWorkflowNames, - importedSkillsBySourceName, - createWorkflow, - persistWorkflowState: async (workflowId, state) => { - 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') - } - }, - }) - - logger.info('Workflow imported successfully from dashboard widget') - - if (newWorkflowId) { - onWorkflowCreated?.(newWorkflowId) - } - - return } const newWorkflowId = await importWorkflowFromJsonContent({ content, workspaceId, existingWorkflowNames, + importedSkillsBySourceName, createWorkflow, - persistWorkflowState: async (workflowId, state) => { - 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') - } - }, }) logger.info('Workflow imported successfully from dashboard widget') 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 <token>`, 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_<entityId>` 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_<entityId>` 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_<entityId>` 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. diff --git a/docker-compose.ollama.yml b/docker-compose.ollama.yml index ddc91e53d..dac58c390 100644 --- a/docker-compose.ollama.yml +++ b/docker-compose.ollama.yml @@ -21,13 +21,13 @@ 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:-} - COPILOT_API_URL=${COPILOT_API_URL:-} - OLLAMA_URL=http://ollama: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/docker-compose.prod.yml b/docker-compose.prod.yml index fc0c62a5e..b80653d2b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -18,13 +18,13 @@ 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:-} - COPILOT_API_URL=${COPILOT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:?set NEXT_PUBLIC_SOCKET_URL in .env} - - 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 c2e561344..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). 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`: 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: @@ -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/<release>-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..c8a6c240a 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: "" 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..7edb41d3a 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: "" 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..db51346b9 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: "" # 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 2746abea5..4aebb63d7 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: "" 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..829520969 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: "" # 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..5226982fd 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: "" # UI Branding & Whitelabeling Configuration NEXT_PUBLIC_BRAND_NAME: "Acme AI Studio" 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 b5432b470..7f1cffea5 100644 --- a/helm/tradinggoose/values.yaml +++ b/helm/tradinggoose/values.yaml @@ -68,10 +68,11 @@ 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 + # 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: "" # OPTIONAL - encrypts API keys at rest, must be exactly 64 hex characters, if not set keys stored in plain text - + API_ENCRYPTION_KEY: "" + # 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 2c94806af..b4dec8a00 100644 --- a/packages/db/schema/workspaces.ts +++ b/packages/db/schema/workspaces.ts @@ -86,7 +86,7 @@ export const apiKey = pgTable( 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)` From 7f0dfee3e892a92563ea36aeef10a900720f1250 Mon Sep 17 00:00:00 2001 From: Bruzzz <149516937+BruzWJ@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:54:22 -0600 Subject: [PATCH 16/18] fix(yjs): move entity lists onto shared realtime sessions (#160) * feat(yjs): sync entity list widgets through realtime snapshots Rebuild list widgets from Yjs entity-list snapshots and propagate workflow list mutations back into realtime state.\n\nCo-authored-by: Codex <codex@openai.com>\nCo-authored-by: BWJ2310 <brucewj2310@gmail.com>\nCo-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(yjs): propagate workflow folder ids through list sessions Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(naming): generate available default names for new entities Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(workflow-tree): remove unused folder tree props Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(indicators): sync color metadata through Yjs entity lists Propagate indicator color through Yjs entity-list synchronization and use existing names when creating indicators. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(mcp): route server renames through saved entity fields Remove the dedicated MCP rename route and update the widget/store path to use the shared saved-entity field editor. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(folders): promote direct children on folder delete Delete a folder by moving its direct child folders and workflows up one level, and update the store and UI copy to match. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): make cleanup notifications best-effort Treat workflow list publication and delete cleanup as best-effort so request success is not blocked by socket-server follow-up work. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(tradinggoose): use DB-backed custom tool and entity updates Move custom tool resolution, skill lookup, folder promotion, and realtime invalidation onto direct DB/query-client paths. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): decouple folder changes from metadata sync Remove folderId from workflow metadata and Yjs persistence, then handle workflow folder reassignment separately in the workflow and folder routes. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): refresh MCP servers after entity updates Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(yjs): share entity sessions and simplify editor selection Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(workflows): hydrate workflow list from entity metadata Use workflow entity seeds for rename and duplicate actions, and stop mirroring list data into the registry. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(editors): default unlinked editors to first entity When an editor widget is not tied to a color pair and has no explicit selection, use the first available entity instead of leaving the panel empty. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(list-mcp): select new servers after entity hydration Defer server selection until the entity list contains the new server, and clear stale selections when the current server disappears from the workspace list. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(entity-list): keep entity list sessions aligned with db Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(widgets): remove stale selection reset effects Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): sync workflow list members through realtime helpers Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(widgets): source editor selectors from yjs entity lists Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): keep workflow mutations durable on socket errors Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor: rename persisted helper APIs Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(widgets): validate requested editor entities Handle unknown requested custom tools, indicators, and skills after the entity lists finish loading, and keep the loading state active while those lists resolve. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): reseed live entity-list sessions on upgrade Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflow-list): keep widget selection local Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): require realtime sync for list projections Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): make live list sync best effort Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): skip sync wait for read providers Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(widgets): keep requested ids and selections in sync Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(execution): respect deployed context for saved entities Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(widgets): simplify workflow list hydration Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): hydrate and validate active workflow selection Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(widgets): handle missing requested editor entities Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(agent): pass deployed context through MCP tool discovery Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflow): tolerate missing workflow-linked entities Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): remove stale workflow cache Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): persist folder metadata through yjs sync Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(widgets): clear stale selection after list updates Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): default deployed discover context Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): retry failed entity lists on reopen Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(folders): apply workflow metadata before delete Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(widgets): stop auto-selecting the first workflow Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(workflows): decouple list-member sync from yjs snapshots Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): drop rollback handling for entity publishes Remove the rollback callback and DB cleanup from live member publishes. Snapshot reads now swallow bridge errors because DB-seeded projections repair missed live publishes. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(yjs): reseed entity lists from db snapshots Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(yjs): surface workflow descriptions in entity lists Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): surface realtime-required errors for list refreshes Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(workflow): simplify workflow session fallback handling Remove the automatic workflow fallback and make projection refresh errors non-fatal so widgets rely on explicit workflow state. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(workflows): remove marketplace publishing state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(yjs): expose entity descriptions to dropdown search Add skill and custom tool descriptions to entity list loading so dropdowns can search by description, and wire both dropdowns to surface and match on that field. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): search descriptions and preserve duplicate source Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(workflow-selection): remove widget-scoped selection syncing Consolidate workflow selection persistence around panel-scoped events and shared params. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflow-widgets): resolve widget workflows through shared state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(i18n): remove obsolete workflow auth copy Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(ai): mark tool execution as deployed context Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(ai): preserve deployed context flag Propagate the deployed-context flag through agent execution into provider requests and keep the existing true default for direct tool execution. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(tradinggoose): replace MCP server store with entity list state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): refresh tool list after server edits Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(indicator-list): open source session read-only Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): invalidate discovery cache on refresh Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): enforce access mode on websocket sync Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(ui): remove redundant delete selection resets Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * docs(changelog): add July 1 branch summary Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(yjs): sync MCP server metadata in entity lists Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflow): preserve color-pair workflow selection default Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): retry server list and keep selected status Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(widgets): clear selection after deleting list items Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): surface entity-list refresh failures Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): discard stale entity-list projections Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflow-list): use shared widget state for routing Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * test(yjs): update snapshot bridge failure coverage Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): reopen disposable read sessions Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(tradinggoose): surface entity lookup failures Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(tradinggoose): queue entity selections until listed Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(agent): fail agent blocks when tool hydration fails Reject null tool hydration instead of silently shrinking the selected toolset, and cover the behavior with a regression test plus changelog note. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(widgets): keep explicit entity ids exact Co-authored-by: Codex <codex@openai.com>\nCo-authored-by: BWJ2310 <brucewj2310@gmail.com>\nCo-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * docs(changelog): document entity selection validation Co-authored-by: Codex <codex@openai.com>\nCo-authored-by: BWJ2310 <brucewj2310@gmail.com>\nCo-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): keep shared sessions retrying until live Retry failed shared Yjs opens on the same 1s cadence as write-session token rotation, remove the now-redundant manual retry plumbing from widgets, and keep workflow selection explicit. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * docs(changelog): note shared Yjs and workflow retry behavior Document the workflow selection rule and the shared Yjs session reopen behavior so the July 1 changelog matches the code change. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(workflow-list): sort workflows newest-first Seed list members with createdAt from the database and preserve it through Yjs sessions so the workflow widget can render the list in reverse chronological order. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * docs(changelog): note workflow list newest-first ordering Document that workflow list members now retain createdAt in the Yjs projection so the widget can render newest-first. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * docs(yjs): clarify live list snapshot freshness Document the DB-fresh bootstrap path for live entity-list snapshots, the eventual-consistency caveat for write-mode provider docs, and the changelog note. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(tools): move async tool lookup to package entrypoint Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflow): stabilize workflow hydration on dashboard load Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): ignore missing saved entities during bootstrap Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(workflows): resolve workflow state from routed session Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): serialize entity list reseeds Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflow): remove registry-backed workflow activation Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(workflows): remove active-workflow coupling Make workflow state reads explicit by workflow ID instead of inferring the active workflow from channel context. This removes the registry fallback helpers, updates trigger pollers to read from their own workflow context, and simplifies the deployment/example UI plumbing. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflow): source workflow options from entity metadata Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(workflows): remove template handling from deletions Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(skills): remove the redundant skills store Move SkillDefinition into lib/skills, switch skill consumers to entity lists, and fetch skills directly during workflow JSON export. Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflow): sort workflows by newest createdAt Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(tradinggoose): stabilize MCP and workflow state Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(tradinggoose): read entity lists from the database Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): keep read sessions visible while reopening Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(entity-session): rename seedEntityListSession to replaceEntityListSessionMembers for clarity * feat(skills): support live skill listings Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): keep shared session errors visible Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(tradinggoose): remove template subsystem Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * docs(changelog): refresh July 1 entry Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workspace): list live skills and custom tools Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(indicators): source list data from saved entity state Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * build(db): add migration 0037_familiar_killraven Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): preserve populated workflow lists during load failures Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * feat(indicators): derive custom indicator input metadata from pine code Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): ignore invalid MCP servers and clear stale list errors Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(agent): skip unavailable agent tools during hydration Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(skills): skip missing skill metadata entries Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): preserve live entity-list snapshots Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): reseed entity-list snapshots and preserve errors Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(widgets): persist resolved entity ids and scope workflow events Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(widgets): handle stale requested selections without false errors Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(workflows): remove obsolete workflow store layer Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(widgets): persist resolved list entity ids Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(executor): fail when selected agent resources cannot resolve Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(widgets): reject stale entity selections before fallback Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(widgets): remove redundant selection sync Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(control-bar): sync deployment status from API Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * test(indicators): rename custom option fixture Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(socket-server): serve live entity-list snapshots on reseed failure Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(entities): use canonical DB membership for server lists Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): sort entity list members deterministically Ensure entity-backed lists are ordered before mapping them to UI rows. Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(mcp): list servers from persisted records Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(widgets): require explicit selection scopes Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(yjs): stabilize entity list member ordering Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): surface workflow list load errors Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflow-list): select header-created workflows directly Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * docs(widgets): document entity selection fallback policy Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * test(widgets): cover entity selection fallback rules Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(workflows): keep workflow-list refresh best-effort Move workflow-list refresh out of the workflow creation/duplication transaction cleanup path so committed workflow rows are not rolled back if projection refresh fails. Make the helper swallow refresh lookup/projection errors after logging them, and add tests for the best-effort behavior. Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(agent): fail fast when selected dependencies are missing Align agent tool and skill resolution with startup-time validation, and cover the missing-tool case in tests. Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * style(tradinggoose): normalize formatting and import order Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * refactor(yjs): inline live entity list field loading Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> * fix(agent): skip unavailable agent tools and skills Co-authored-by: BWJ2310 <brucewj2310@gmail.com> Co-authored-by: BWJ2310-backup <jun.1216.wei@gmail.com> --------- Co-authored-by: Codex <codex@openai.com> Co-authored-by: BWJ2310 <brucewj2310@gmail.com> --- .../landing-canvas/landing-canvas.tsx | 12 - .../[workspaceId]/templates/[id]/page.tsx | 135 - .../[workspaceId]/templates/layout.tsx | 5 - .../[workspaceId]/templates/page.tsx | 52 - .../app/api/copilot/chat/route.ts | 2 - .../app/api/folders/[id]/route.test.ts | 29 +- .../app/api/folders/[id]/route.ts | 96 +- .../app/api/indicators/custom/route.ts | 8 +- .../app/api/indicators/options/route.test.ts | 23 +- .../app/api/indicators/options/route.ts | 3 +- .../app/api/jobs/[jobId]/route.test.ts | 2 +- .../app/api/mcp/servers/[id]/refresh/route.ts | 5 +- .../app/api/mcp/servers/[id]/route.ts | 95 - .../tradinggoose/app/api/mcp/servers/route.ts | 91 +- .../app/api/mcp/servers/schema.ts | 4 - .../app/api/mcp/tools/discover/route.ts | 18 +- .../app/api/mcp/tools/execute/route.ts | 10 +- apps/tradinggoose/app/api/monitors/shared.ts | 6 +- .../app/api/providers/ai/handler.ts | 3 + .../tradinggoose/app/api/skills/route.test.ts | 9 + .../app/api/templates/[id]/route.ts | 217 - .../app/api/templates/[id]/star/route.ts | 174 - .../app/api/templates/[id]/use/route.ts | 168 - apps/tradinggoose/app/api/templates/route.ts | 267 - .../app/api/tools/custom/route.ts | 8 +- .../workflows/[id]/duplicate/route.test.ts | 6 + .../app/api/workflows/[id]/duplicate/route.ts | 7 +- .../app/api/workflows/[id]/route.test.ts | 95 +- .../app/api/workflows/[id]/route.ts | 87 +- .../api/workflows/[id]/status/route.test.ts | 3 - .../app/api/workflows/[id]/status/route.ts | 1 - .../app/api/workflows/public/[id]/route.ts | 69 - .../app/api/workflows/route.test.ts | 21 +- apps/tradinggoose/app/api/workflows/route.ts | 7 +- .../app/api/workspaces/[id]/route.test.ts | 2 - .../app/api/workspaces/[id]/route.ts | 83 +- .../dashboard/dashboard-client.tsx | 10 +- .../[workspaceId]/templates/[id]/template.tsx | 341 - .../templates/components/navigation-tabs.tsx | 33 - .../templates/components/template-card.tsx | 510 - .../[workspaceId]/templates/templates.tsx | 389 - apps/tradinggoose/blocks/blocks/workflow.ts | 35 +- .../blocks/blocks/workflow_input.ts | 24 +- .../components/ui/tag-dropdown.test.tsx | 4 - .../__test-utils__/mock-dependencies.ts | 1 - .../handlers/agent/agent-handler.test.ts | 104 + .../executor/handlers/agent/agent-handler.ts | 248 +- .../handlers/agent/skills-resolver.test.ts | 45 + .../handlers/agent/skills-resolver.ts | 66 +- .../hooks/queries/skills.test.tsx | 211 - apps/tradinggoose/hooks/queries/skills.ts | 52 +- apps/tradinggoose/hooks/queries/templates.ts | 396 - apps/tradinggoose/hooks/use-mcp-tools.ts | 100 +- .../workflow/use-workflow-editor-actions.ts | 15 +- apps/tradinggoose/i18n/messages/en.json | 148 +- apps/tradinggoose/i18n/messages/es.json | 148 +- apps/tradinggoose/i18n/messages/zh.json | 148 +- apps/tradinggoose/i18n/public-copy.test.ts | 12 +- .../tradinggoose/lib/copilot/chat-contexts.ts | 2 - .../lib/copilot/entity-documents.ts | 5 +- .../lib/copilot/process-contents.test.ts | 1 - .../lib/copilot/process-contents.ts | 70 +- apps/tradinggoose/lib/copilot/registry.ts | 1 + .../lib/copilot/review-sessions/identity.ts | 8 +- .../copilot/review-sessions/permissions.ts | 16 +- .../workflow-review-tool-utils.test.ts | 4 - .../workflow/workflow-review-tool-utils.ts | 4 - .../agent/get-agent-accessory-catalog.ts | 7 +- .../tools/server/entities/indicator.test.ts | 14 +- .../tools/server/entities/shared.test.ts | 32 +- .../copilot/tools/server/entities/shared.ts | 29 +- .../server/entities/workflow-variable.test.ts | 1 - .../copilot/tools/server/entities/workflow.ts | 56 +- .../lib/custom-tools/operations.ts | 26 +- apps/tradinggoose/lib/function/execution.ts | 4 +- .../lib/indicators/custom/operations.ts | 75 +- apps/tradinggoose/lib/knowledge/service.ts | 55 +- apps/tradinggoose/lib/mcp/service.ts | 150 +- apps/tradinggoose/lib/mcp/utils.ts | 2 + apps/tradinggoose/lib/naming.ts | 9 + apps/tradinggoose/lib/skills/import-export.ts | 2 +- .../lib/skills/operations.test.ts | 6 +- apps/tradinggoose/lib/skills/operations.ts | 29 +- apps/tradinggoose/lib/skills/types.ts | 10 + .../signoz-tradinggoose-overview.json | 716 +- .../lib/workflows/db-helpers.test.ts | 31 + apps/tradinggoose/lib/workflows/db-helpers.ts | 47 +- .../lib/workflows/import-export.ts | 2 +- .../workflows/operations/deployment-utils.ts | 6 +- .../lib/workflows/state-builder.test.ts | 45 - .../lib/workflows/state-builder.ts | 16 - apps/tradinggoose/lib/workflows/utils.ts | 2 - .../lib/yjs/entity-session.test.ts | 24 + apps/tradinggoose/lib/yjs/entity-session.ts | 127 +- apps/tradinggoose/lib/yjs/entity-state.ts | 4 - apps/tradinggoose/lib/yjs/provider.test.ts | 44 +- apps/tradinggoose/lib/yjs/provider.ts | 112 +- .../lib/yjs/server/apply-entity-state.test.ts | 7 - .../lib/yjs/server/apply-entity-state.ts | 50 +- .../yjs/server/apply-workflow-state.test.ts | 79 +- .../lib/yjs/server/apply-workflow-state.ts | 34 +- .../server/bootstrap-review-target.test.ts | 88 + .../lib/yjs/server/bootstrap-review-target.ts | 111 +- .../lib/yjs/server/entity-loaders.ts | 104 +- .../lib/yjs/server/snapshot-bridge.test.ts | 68 + .../lib/yjs/server/snapshot-bridge.ts | 64 +- .../lib/yjs/use-entity-fields.test.tsx | 213 + .../tradinggoose/lib/yjs/use-entity-fields.ts | 367 +- apps/tradinggoose/lib/yjs/use-workflow-doc.ts | 6 +- .../lib/yjs/workflow-session-registry.ts | 18 +- .../lib/yjs/workflow-session.test.ts | 9 - apps/tradinggoose/lib/yjs/workflow-session.ts | 51 +- .../lib/yjs/workflow-shared-session.test.ts | 3 - .../lib/yjs/workflow-shared-session.ts | 7 - apps/tradinggoose/providers/ai/utils.test.ts | 3 + apps/tradinggoose/providers/ai/utils.ts | 2 + apps/tradinggoose/socket-server/index.test.ts | 152 +- .../market/indicator-monitor-runtime.test.ts | 3 +- .../market/indicator-monitor-runtime.ts | 14 +- .../tradinggoose/socket-server/routes/http.ts | 130 +- .../socket-server/yjs/upstream-utils.ts | 25 +- .../socket-server/yjs/ws-handler.test.ts | 4 + .../socket-server/yjs/ws-handler.ts | 17 +- .../stores/copilot/tool-registry.test.ts | 9 +- .../stores/copilot/tool-registry.ts | 3 +- apps/tradinggoose/stores/copilot/types.ts | 1 - apps/tradinggoose/stores/folders/store.ts | 83 +- apps/tradinggoose/stores/index.ts | 3 - apps/tradinggoose/stores/mcp-servers/store.ts | 195 - apps/tradinggoose/stores/mcp-servers/types.ts | 78 - apps/tradinggoose/stores/skills/store.ts | 61 - apps/tradinggoose/stores/skills/types.ts | 21 - apps/tradinggoose/stores/workflows/index.ts | 108 +- .../stores/workflows/json/store.test.ts | 32 +- .../stores/workflows/json/store.ts | 73 +- .../stores/workflows/registry/store.test.ts | 14 +- .../stores/workflows/registry/store.ts | 183 +- .../stores/workflows/registry/types.ts | 24 +- .../stores/workflows/subblock/store.ts | 178 - .../stores/workflows/subblock/types.ts | 15 - .../stores/workflows/subblock/utils.ts | 17 - .../workflows/workflow/store-client.tsx | 82 - .../stores/workflows/workflow/store.ts | 1309 -- apps/tradinggoose/tools/function/execute.ts | 3 + apps/tradinggoose/tools/function/types.ts | 1 + apps/tradinggoose/tools/index.test.ts | 32 +- apps/tradinggoose/tools/index.ts | 106 +- .../tools/slack/message_reader.ts | 2 +- apps/tradinggoose/tools/utils.test.ts | 110 +- apps/tradinggoose/tools/utils.ts | 124 +- apps/tradinggoose/triggers/gmail/poller.ts | 6 +- apps/tradinggoose/triggers/imap/poller.ts | 22 +- apps/tradinggoose/triggers/outlook/poller.ts | 6 +- apps/tradinggoose/widgets/events.ts | 4 +- .../hooks/use-workflow-widget-state.ts | 259 +- apps/tradinggoose/widgets/layout.ts | 2 +- .../widgets/utils/custom-tool-selection.ts | 7 +- .../widgets/utils/entity-selection.test.ts | 18 +- .../widgets/utils/entity-selection.ts | 68 +- .../widgets/utils/indicator-selection.ts | 7 +- .../utils/selection-persistence-factory.ts | 11 +- .../widgets/utils/skill-selection.ts | 12 +- .../use-pending-entity-selection.test.tsx | 62 + .../utils/use-pending-entity-selection.ts | 36 + .../widgets/utils/workflow-selection.ts | 126 +- .../skill/components/skill-list-item.tsx | 5 +- .../components/custom-tool-dropdown.tsx | 67 +- .../widgets/components/mcp-dropdown.tsx | 83 +- .../components/pine-indicator-dropdown.tsx | 59 +- .../widgets/components/skill-dropdown.tsx | 62 +- .../widgets/components/workflow-dropdown.tsx | 116 +- .../custom-tool-editor.test.tsx | 3 - .../editor_custom_tool/custom-tool-editor.tsx | 5 - .../widgets/editor_custom_tool/index.tsx | 127 +- .../editor-indicator-body.tsx | 120 +- .../widgets/widgets/editor_indicator/utils.ts | 11 +- .../widgets/editor_mcp/editor-mcp-body.tsx | 165 +- .../editor_skill/editor-skill-body.tsx | 122 +- .../components/control-bar/auto-layout.ts | 38 +- .../components/deploy-modal/deploy-modal.tsx | 53 +- .../deployment-controls.test.ts | 224 +- .../deployment-controls.tsx | 21 +- .../export-controls/export-controls.tsx | 16 +- .../control-bar/components/index.ts | 1 - .../template-modal/template-modal.tsx | 811 -- .../control-bar/control-bar.test.ts | 4 - .../components/control-bar/control-bar.tsx | 119 +- .../components/subflows/subflow-node.tsx | 4 +- .../sub-block/components/dropdown.tsx | 59 +- .../file-selector/file-selector-input.tsx | 8 +- .../sub-block/components/file-upload.tsx | 10 +- .../mcp-server-modal/mcp-server-selector.tsx | 24 +- .../components/schedule/schedule-config.tsx | 2 +- .../components/skill-input/skill-input.tsx | 20 +- .../workflow-block/workflow-block.test.tsx | 1 + .../workflow-block/workflow-block.tsx | 10 +- .../workflow-controlbar/controlbar.tsx | 47 +- .../components/workflow-controlbar/index.ts | 2 +- .../components/workflow-editor-app.tsx | 3 - .../panel/node-editor-panel.test.tsx | 7 +- .../panel/node-editor-panel.tsx | 7 +- .../workflow-editor/preview/preview-node.tsx | 8 +- .../preview/preview-readonly-guards.test.ts | 2 - .../preview/preview-subflow.tsx | 13 +- .../preview/read-only-node-editor-panel.tsx | 5 +- .../workflow-editor/workflow-canvas.tsx | 215 +- .../editor_workflow/components/workflow.tsx | 50 +- .../widgets/widgets/editor_workflow/index.tsx | 36 +- .../widgets/list_custom_tool/index.tsx | 194 +- .../indicator-list/indicator-list.tsx | 117 +- .../widgets/widgets/list_indicator/index.tsx | 80 +- .../widgets/widgets/list_mcp/index.tsx | 228 +- .../components/skill-list/skill-list.tsx | 94 +- .../widgets/widgets/list_skill/index.test.tsx | 3 - .../widgets/widgets/list_skill/index.tsx | 94 +- .../folder-tree/components/folder-item.tsx | 18 +- .../folder-tree/components/workflow-item.tsx | 206 +- .../components/folder-tree/folder-tree.tsx | 78 +- .../components/workflow-create-menu.tsx | 7 +- .../widgets/widgets/list_workflow/index.tsx | 346 +- .../widgets/workflow_chat/index.test.tsx | 2 - .../widgets/widgets/workflow_chat/index.tsx | 100 +- .../widgets/workflow_console/index.tsx | 33 +- .../components/variables/variables.tsx | 6 +- .../widgets/workflow_variables/index.tsx | 62 +- changelog/July-01-2026.md | 97 + .../db/migrations/0037_familiar_killraven.sql | 6 + .../db/migrations/meta/0037_snapshot.json | 10952 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema/workflows.ts | 98 - packages/db/schema/workspaces.ts | 1 - packages/python-sdk/README.md | 1 - packages/python-sdk/examples/basic_usage.py | 3 +- packages/python-sdk/tests/test_client.py | 2 - packages/python-sdk/tradinggoose/__init__.py | 2 - packages/ts-sdk/README.md | 1 - packages/ts-sdk/examples/basic-usage.ts | 1 - packages/ts-sdk/src/index.test.ts | 2 - packages/ts-sdk/src/index.ts | 1 - 239 files changed, 15973 insertions(+), 11433 deletions(-) delete mode 100644 apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/[id]/page.tsx delete mode 100644 apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/layout.tsx delete mode 100644 apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/page.tsx delete mode 100644 apps/tradinggoose/app/api/mcp/servers/[id]/route.ts delete mode 100644 apps/tradinggoose/app/api/templates/[id]/route.ts delete mode 100644 apps/tradinggoose/app/api/templates/[id]/star/route.ts delete mode 100644 apps/tradinggoose/app/api/templates/[id]/use/route.ts delete mode 100644 apps/tradinggoose/app/api/templates/route.ts delete mode 100644 apps/tradinggoose/app/api/workflows/public/[id]/route.ts delete mode 100644 apps/tradinggoose/app/workspace/[workspaceId]/templates/[id]/template.tsx delete mode 100644 apps/tradinggoose/app/workspace/[workspaceId]/templates/components/navigation-tabs.tsx delete mode 100644 apps/tradinggoose/app/workspace/[workspaceId]/templates/components/template-card.tsx delete mode 100644 apps/tradinggoose/app/workspace/[workspaceId]/templates/templates.tsx create mode 100644 apps/tradinggoose/executor/handlers/agent/skills-resolver.test.ts delete mode 100644 apps/tradinggoose/hooks/queries/skills.test.tsx delete mode 100644 apps/tradinggoose/hooks/queries/templates.ts create mode 100644 apps/tradinggoose/lib/skills/types.ts delete mode 100644 apps/tradinggoose/lib/workflows/state-builder.test.ts delete mode 100644 apps/tradinggoose/lib/workflows/state-builder.ts create mode 100644 apps/tradinggoose/lib/yjs/entity-session.test.ts create mode 100644 apps/tradinggoose/lib/yjs/server/bootstrap-review-target.test.ts create mode 100644 apps/tradinggoose/lib/yjs/server/snapshot-bridge.test.ts create mode 100644 apps/tradinggoose/lib/yjs/use-entity-fields.test.tsx delete mode 100644 apps/tradinggoose/stores/mcp-servers/store.ts delete mode 100644 apps/tradinggoose/stores/mcp-servers/types.ts delete mode 100644 apps/tradinggoose/stores/skills/store.ts delete mode 100644 apps/tradinggoose/stores/skills/types.ts delete mode 100644 apps/tradinggoose/stores/workflows/subblock/store.ts delete mode 100644 apps/tradinggoose/stores/workflows/subblock/types.ts delete mode 100644 apps/tradinggoose/stores/workflows/subblock/utils.ts delete mode 100644 apps/tradinggoose/stores/workflows/workflow/store-client.tsx delete mode 100644 apps/tradinggoose/stores/workflows/workflow/store.ts create mode 100644 apps/tradinggoose/widgets/utils/use-pending-entity-selection.test.tsx create mode 100644 apps/tradinggoose/widgets/utils/use-pending-entity-selection.ts delete mode 100644 apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/template-modal/template-modal.tsx create mode 100644 changelog/July-01-2026.md create mode 100644 packages/db/migrations/0037_familiar_killraven.sql create mode 100644 packages/db/migrations/meta/0037_snapshot.json diff --git a/apps/tradinggoose/app/(landing)/components/feature/components/landing-canvas/landing-canvas.tsx b/apps/tradinggoose/app/(landing)/components/feature/components/landing-canvas/landing-canvas.tsx index 7330b5c4b..b6fa23abb 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/components/landing-canvas/landing-canvas.tsx +++ b/apps/tradinggoose/app/(landing)/components/feature/components/landing-canvas/landing-canvas.tsx @@ -123,18 +123,6 @@ export function LandingCanvas({ <div className='relative mx-auto flex h-[612px] w-full max-w-[1285px] border-none'> <DotPattern className='pointer-events-none absolute inset-0 z-0 h-full w-full opacity-20' /> - {/* Use template button overlay */} - {/* <button - type='button' - aria-label='Use template' - className='absolute top-[24px] left-[50px] z-20 inline-flex items-center justify-center rounded-md border border-[#343434] bg-gradient-to-b from-[#060606] to-[#323232] px-3 py-1.5 text-sm text-white shadow-[inset_0_1.25px_2.5px_0_#9B77FF] transition-all duration-200' - onClick={() => { - // Template usage logic will be implemented here - }} - > - Use template - </button> */} - <div ref={flowWrapRef} className='relative z-10 h-full w-full'> <ReactFlowProvider> <LandingFlow diff --git a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/[id]/page.tsx b/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/[id]/page.tsx deleted file mode 100644 index 68b165a99..000000000 --- a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/[id]/page.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { db } from '@tradinggoose/db' -import { templateStars, templates } from '@tradinggoose/db/schema' -import { and, eq } from 'drizzle-orm' -import { getLocale } from 'next-intl/server' -import { notFound } from 'next/navigation' -import { getSession } from '@/lib/auth' -import { createLogger } from '@/lib/logs/console/logger' -import { getPublicCopy } from '@/i18n/public-copy' -import type { LocaleCode } from '@/i18n/utils' -import TemplateDetails from '@/app/workspace/[workspaceId]/templates/[id]/template' -import type { Template } from '@/app/workspace/[workspaceId]/templates/templates' - -const logger = createLogger('TemplatePage') - -interface TemplatePageProps { - params: Promise<{ - workspaceId: string - id: string - }> -} - -export default async function TemplatePage({ params }: TemplatePageProps) { - const locale = (await getLocale()) as LocaleCode - const copy = getPublicCopy(locale).workspace.templates - const { workspaceId, id } = await params - - try { - // Validate the template ID format (basic UUID validation) - if (!id || typeof id !== 'string' || id.length !== 36) { - notFound() - } - - const session = await getSession() - - if (!session?.user?.id) { - return <div>{copy.loginRequired}</div> - } - - // Fetch template data first without star status to avoid query issues - const templateData = await db - .select({ - id: templates.id, - workflowId: templates.workflowId, - userId: templates.userId, - name: templates.name, - description: templates.description, - author: templates.author, - views: templates.views, - stars: templates.stars, - color: templates.color, - icon: templates.icon, - category: templates.category, - state: templates.state, - createdAt: templates.createdAt, - updatedAt: templates.updatedAt, - }) - .from(templates) - .where(eq(templates.id, id)) - .limit(1) - - if (templateData.length === 0) { - notFound() - } - - const template = templateData[0] - - // Validate that required fields are present - if (!template.id || !template.name || !template.author) { - logger.error('Template missing required fields:', { - id: template.id, - name: template.name, - author: template.author, - }) - notFound() - } - - // Check if user has starred this template - let isStarred = false - try { - const starData = await db - .select({ id: templateStars.id }) - .from(templateStars) - .where( - and(eq(templateStars.templateId, template.id), eq(templateStars.userId, session.user.id)) - ) - .limit(1) - isStarred = starData.length > 0 - } catch { - // Continue with isStarred = false - } - - // Ensure proper serialization of the template data with null checks - const serializedTemplate: Template = { - id: template.id, - workflowId: template.workflowId, - userId: template.userId, - name: template.name, - description: template.description, - author: template.author, - views: template.views, - stars: template.stars, - color: template.color || '#3972F6', // Default color if missing - icon: template.icon || 'FileText', // Default icon if missing - category: template.category as any, - state: template.state as any, - createdAt: template.createdAt ? template.createdAt.toISOString() : new Date().toISOString(), - updatedAt: template.updatedAt ? template.updatedAt.toISOString() : new Date().toISOString(), - isStarred, - } - - logger.info('Template from DB:', template) - logger.info('Serialized template:', serializedTemplate) - logger.info('Template state from DB:', template.state) - - return ( - <TemplateDetails - template={serializedTemplate} - workspaceId={workspaceId} - /> - ) - } catch (error) { - console.error('Error loading template:', error) - return ( - <div className='flex h-screen items-center justify-center'> - <div className='text-center'> - <h1 className='mb-4 font-bold text-2xl'>{copy.errorPage.title}</h1> - <p className='text-muted-foreground'>{copy.errorPage.description}</p> - <p className='mt-2 text-muted-foreground text-sm'> - {copy.errorPage.templateIdLabel} {id} - </p> - </div> - </div> - ) - } -} diff --git a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/layout.tsx b/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/layout.tsx deleted file mode 100644 index 0de233f32..000000000 --- a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/layout.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { GeistSans } from 'geist/font/sans' - -export default function TemplatesLayout({ children }: { children: React.ReactNode }) { - return <div className={GeistSans.className}>{children}</div> -} diff --git a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/page.tsx b/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/page.tsx deleted file mode 100644 index f25e8f5bd..000000000 --- a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/page.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { db } from '@tradinggoose/db' -import { templateStars, templates } from '@tradinggoose/db/schema' -import { and, desc, eq, sql } from 'drizzle-orm' -import { getLocale } from 'next-intl/server' -import { getSession } from '@/lib/auth' -import { getPublicCopy } from '@/i18n/public-copy' -import type { LocaleCode } from '@/i18n/utils' -import type { Template } from '@/app/workspace/[workspaceId]/templates/templates' -import Templates from '@/app/workspace/[workspaceId]/templates/templates' - -export default async function TemplatesPage() { - const locale = (await getLocale()) as LocaleCode - const copy = getPublicCopy(locale).workspace.templates - const session = await getSession() - - if (!session?.user?.id) { - return <div>{copy.loginRequired}</div> - } - - // Fetch templates server-side with all necessary data - const templatesData = await db - .select({ - id: templates.id, - workflowId: templates.workflowId, - userId: templates.userId, - name: templates.name, - description: templates.description, - author: templates.author, - views: templates.views, - stars: templates.stars, - color: templates.color, - icon: templates.icon, - category: templates.category, - state: templates.state, - createdAt: templates.createdAt, - updatedAt: templates.updatedAt, - isStarred: sql<boolean>`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`, - }) - .from(templates) - .leftJoin( - templateStars, - and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id)) - ) - .orderBy(desc(templates.views), desc(templates.createdAt)) - - return ( - <Templates - initialTemplates={templatesData as unknown as Template[]} - currentUserId={session.user.id} - /> - ) -} diff --git a/apps/tradinggoose/app/api/copilot/chat/route.ts b/apps/tradinggoose/app/api/copilot/chat/route.ts index ae9472678..3a4cb1e61 100644 --- a/apps/tradinggoose/app/api/copilot/chat/route.ts +++ b/apps/tradinggoose/app/api/copilot/chat/route.ts @@ -657,7 +657,6 @@ const ChatMessageSchema = z.object({ 'logs', 'workflow_block', 'knowledge', - 'templates', 'docs', ]), label: z.string(), @@ -671,7 +670,6 @@ const ChatMessageSchema = z.object({ blockTypes: z.array(z.string()).optional(), knowledgeId: z.string().optional(), blockId: z.string().optional(), - templateId: z.string().optional(), executionId: z.string().optional(), draftSessionId: z.string().optional(), // For workflow_block, provide both workflowId and blockId diff --git a/apps/tradinggoose/app/api/folders/[id]/route.test.ts b/apps/tradinggoose/app/api/folders/[id]/route.test.ts index 9054195ae..c0f1b2db5 100644 --- a/apps/tradinggoose/app/api/folders/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/folders/[id]/route.test.ts @@ -15,6 +15,7 @@ import { interface FolderDbMockOptions { folderLookupResult?: any + childFoldersResult?: any[] updateResult?: any[] throwError?: boolean circularCheckResults?: any[] @@ -41,10 +42,12 @@ describe('Individual Folder API Route', () => { const { mockAuthenticatedUser, mockUnauthenticated } = mockAuth(TEST_USER) const mockGetUserEntityPermissions = vi.fn() + const mockRefreshWorkflowList = vi.fn() function createFolderDbMock(options: FolderDbMockOptions = {}) { const { folderLookupResult = mockFolder, + childFoldersResult = [], updateResult = [{ ...mockFolder, name: 'Updated Folder' }], throwError = false, circularCheckResults = [], @@ -74,6 +77,9 @@ describe('Individual Folder API Route', () => { const result = circularCheckResults[index] ? [circularCheckResults[index]] : [] return Promise.resolve(callback(result)) } + if (callCount === 2) { + return Promise.resolve(callback(childFoldersResult)) + } return Promise.resolve(callback([])) }), })), @@ -97,6 +103,9 @@ describe('Individual Folder API Route', () => { select: mockSelect, update: mockUpdate, delete: mockDelete, + transaction: vi.fn(async (callback) => + callback({ update: mockUpdate, delete: mockDelete }) + ), }, mocks: { select: mockSelect, @@ -112,10 +121,14 @@ describe('Individual Folder API Route', () => { setupCommonApiMocks() mockGetUserEntityPermissions.mockResolvedValue('admin') + mockRefreshWorkflowList.mockResolvedValue(undefined) vi.doMock('@/lib/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) + vi.doMock('@/lib/workflows/db-helpers', () => ({ + refreshWorkflowList: mockRefreshWorkflowList, + })) }) afterEach(() => { @@ -417,14 +430,15 @@ describe('Individual Folder API Route', () => { }) describe('DELETE /api/folders/[id]', () => { - it('should delete folder and all contents successfully', async () => { + it('should delete the folder and move direct child folders and workflows up one level', async () => { mockAuthenticatedUser() const dbMock = createFolderDbMock({ - folderLookupResult: mockFolder, + folderLookupResult: { ...mockFolder, parentId: 'parent-folder' }, + childFoldersResult: [{ id: 'child-folder' }], + updateResult: [{ id: 'workflow-1' }], }) - // Mock the recursive deletion function vi.doMock('@tradinggoose/db', () => dbMock) const req = createMockRequest('DELETE') @@ -438,7 +452,13 @@ describe('Individual Folder API Route', () => { const data = await response.json() expect(data).toHaveProperty('success', true) - expect(data).toHaveProperty('deletedItems') + expect(data).toMatchObject({ + deletedFolderId: 'folder-1', + parentId: 'parent-folder', + movedFolders: 1, + movedWorkflows: 1, + }) + expect(mockRefreshWorkflowList).toHaveBeenCalledWith('workspace-123') }) it('should return 401 for unauthenticated delete requests', async () => { @@ -506,6 +526,7 @@ describe('Individual Folder API Route', () => { const dbMock = createFolderDbMock({ folderLookupResult: mockFolder, + updateResult: [], }) vi.doMock('@tradinggoose/db', () => dbMock) diff --git a/apps/tradinggoose/app/api/folders/[id]/route.ts b/apps/tradinggoose/app/api/folders/[id]/route.ts index 0c69f764a..78b88527d 100644 --- a/apps/tradinggoose/app/api/folders/[id]/route.ts +++ b/apps/tradinggoose/app/api/folders/[id]/route.ts @@ -5,6 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' +import { refreshWorkflowList } from '@/lib/workflows/db-helpers' const logger = createLogger('FoldersIDAPI') @@ -83,7 +84,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } } -// DELETE - Delete a folder and all its contents +// DELETE - Delete one folder and promote its direct children one level up. export async function DELETE( request: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -121,17 +122,57 @@ export async function DELETE( ) } - // Recursively delete folder and all its contents - const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId) + const parentId = existingFolder.parentId ?? null + const childFolders = await db + .select({ id: workflowFolder.id }) + .from(workflowFolder) + .where( + and( + eq(workflowFolder.parentId, id), + eq(workflowFolder.workspaceId, existingFolder.workspaceId) + ) + ) + + let movedWorkflows: Array<{ id: string }> = [] + + await db.transaction(async (tx) => { + const now = new Date() + if (childFolders.length > 0) { + await tx + .update(workflowFolder) + .set({ parentId, updatedAt: now }) + .where( + and( + eq(workflowFolder.parentId, id), + eq(workflowFolder.workspaceId, existingFolder.workspaceId) + ) + ) + } - logger.info('Deleted folder and all contents:', { + movedWorkflows = await tx + .update(workflow) + .set({ folderId: parentId, updatedAt: now }) + .where(and(eq(workflow.workspaceId, existingFolder.workspaceId), eq(workflow.folderId, id))) + .returning({ id: workflow.id }) + + await tx.delete(workflowFolder).where(eq(workflowFolder.id, id)) + }) + + await refreshWorkflowList(existingFolder.workspaceId) + + logger.info('Deleted folder and promoted direct children:', { id, - deletionStats, + parentId, + movedFolders: childFolders.length, + movedWorkflows: movedWorkflows.length, }) return NextResponse.json({ success: true, - deletedItems: deletionStats, + deletedFolderId: id, + parentId, + movedFolders: childFolders.length, + movedWorkflows: movedWorkflows.length, }) } catch (error) { logger.error('Error deleting folder:', { error }) @@ -139,49 +180,6 @@ export async function DELETE( } } -// Helper function to recursively delete a folder and all its contents -async function deleteFolderRecursively( - folderId: string, - workspaceId: string -): Promise<{ folders: number; workflows: number }> { - const stats = { folders: 0, workflows: 0 } - - // Get all child folders first (workspace-scoped, not user-scoped) - const childFolders = await db - .select({ id: workflowFolder.id }) - .from(workflowFolder) - .where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId))) - - // Recursively delete child folders - for (const childFolder of childFolders) { - const childStats = await deleteFolderRecursively(childFolder.id, workspaceId) - stats.folders += childStats.folders - stats.workflows += childStats.workflows - } - - // Delete all workflows in this folder (workspace-scoped, not user-scoped) - // The database cascade will handle deleting related workflow_blocks, workflow_edges, workflow_subflows - const workflowsInFolder = await db - .select({ id: workflow.id }) - .from(workflow) - .where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId))) - - if (workflowsInFolder.length > 0) { - await db - .delete(workflow) - .where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId))) - - stats.workflows += workflowsInFolder.length - } - - // Delete this folder - await db.delete(workflowFolder).where(eq(workflowFolder.id, folderId)) - - stats.folders += 1 - - return stats -} - // Helper function to check for circular references async function checkForCircularReference(folderId: string, parentId: string): Promise<boolean> { let currentParentId: string | null = parentId diff --git a/apps/tradinggoose/app/api/indicators/custom/route.ts b/apps/tradinggoose/app/api/indicators/custom/route.ts index d430b09c7..665b1fccb 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -10,7 +10,7 @@ import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' import { deleteYjsSessionInSocketServer, - notifyEntityListMemberRemoved, + refreshEntityListSession, } from '@/lib/yjs/server/snapshot-bridge' import { authenticateIndicatorRequest, checkWorkspacePermission } from '../utils' @@ -252,10 +252,8 @@ export async function DELETE(request: NextRequest) { .delete(pineIndicators) .where(and(eq(pineIndicators.id, indicatorId), eq(pineIndicators.workspaceId, workspaceId))) - await Promise.allSettled([ - deleteYjsSessionInSocketServer(indicatorId), - notifyEntityListMemberRemoved('indicator', workspaceId, indicatorId), - ]) + await refreshEntityListSession('indicator', workspaceId) + await Promise.allSettled([deleteYjsSessionInSocketServer(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 cfb08e307..d0a80ca5f 100644 --- a/apps/tradinggoose/app/api/indicators/options/route.test.ts +++ b/apps/tradinggoose/app/api/indicators/options/route.test.ts @@ -68,7 +68,6 @@ describe('indicator options route', () => { pineCode: 'trigger-capable', inputMeta: { Threshold: { title: 'Threshold', type: 'float', defval: 2.5 }, - Broken: { title: '' }, }, }, { @@ -81,13 +80,11 @@ describe('indicator options route', () => { }, }, { - id: 'custom-malformed', - name: 'Custom Malformed', + id: 'custom-without-inputs', + name: 'Custom Without Inputs', color: '#654321', pineCode: 'trigger-capable', - inputMeta: { - Broken: { title: '' }, - }, + inputMeta: undefined, }, ]) }) @@ -97,14 +94,14 @@ describe('indicator options route', () => { return GET(new NextRequest(`http://localhost/api/indicators/options${search}`)) } - it('returns monitor-surface trigger-capable options with normalized input metadata', async () => { + it('returns monitor-surface trigger-capable options with derived input metadata', async () => { const response = await getOptions('?workspaceId=workspace-1&surface=monitor') const payload = await response.json() expect(response.status).toBe(200) expect(payload.data.map((entry: any) => entry.id).sort()).toEqual([ - 'custom-malformed', 'custom-trigger', + 'custom-without-inputs', 'default-trigger', ]) @@ -125,9 +122,11 @@ describe('indicator options route', () => { }) ) - const malformedOption = payload.data.find((entry: any) => entry.id === 'custom-malformed') - expect(malformedOption.inputTitles).toEqual([]) - expect(malformedOption.inputMeta).toBeUndefined() + const optionWithoutInputs = payload.data.find( + (entry: any) => entry.id === 'custom-without-inputs' + ) + expect(optionWithoutInputs.inputTitles).toEqual([]) + expect(optionWithoutInputs.inputMeta).toBeUndefined() }) it('keeps copilot surface broader than monitor surface', async () => { @@ -136,9 +135,9 @@ describe('indicator options route', () => { expect(response.status).toBe(200) expect(payload.data.map((entry: any) => entry.id).sort()).toEqual([ - 'custom-malformed', 'custom-study', 'custom-trigger', + 'custom-without-inputs', 'default-study', 'default-trigger', ]) diff --git a/apps/tradinggoose/app/api/indicators/options/route.ts b/apps/tradinggoose/app/api/indicators/options/route.ts index e1a40d3ed..fb8003452 100644 --- a/apps/tradinggoose/app/api/indicators/options/route.ts +++ b/apps/tradinggoose/app/api/indicators/options/route.ts @@ -2,7 +2,6 @@ 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' import type { InputMetaMap } from '@/lib/indicators/types' import { createLogger } from '@/lib/logs/console/logger' @@ -89,7 +88,7 @@ export async function GET(request: NextRequest) { const customOptions: IndicatorOptionRecord[] = customRows .filter((row) => copilotSurface || isIndicatorTriggerCapable(row.pineCode)) .map((row) => { - const inputMeta = normalizeInputMetaMap(row.inputMeta) + const inputMeta = row.inputMeta ?? undefined const inputTitles = Object.keys(inputMeta ?? {}) return { diff --git a/apps/tradinggoose/app/api/jobs/[jobId]/route.test.ts b/apps/tradinggoose/app/api/jobs/[jobId]/route.test.ts index 56bcd02c3..e624db872 100644 --- a/apps/tradinggoose/app/api/jobs/[jobId]/route.test.ts +++ b/apps/tradinggoose/app/api/jobs/[jobId]/route.test.ts @@ -185,7 +185,7 @@ describe('GET /api/jobs/[jobId]', () => { }) }) - it('returns public workflow output for completed workflow jobs', async () => { + it('returns workflow execution output for completed workflow jobs', async () => { mockCompletedWorkflowJob({ source: 'workflow_execute_api' }) const response = await GET(new Request('http://localhost/api/jobs/job-1') as any, { 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 9347d1d5c..e5e9707e4 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts @@ -7,6 +7,7 @@ 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' +import { refreshEntityListSession } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('McpServerRefreshAPI') @@ -56,7 +57,7 @@ export const POST = withMcpAuth('read')( let lastError: string | null = null try { - const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) + const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId, false) connectionStatus = 'connected' toolCount = tools.length logger.info( @@ -84,8 +85,10 @@ export const POST = withMcpAuth('read')( lastError, lastConnected, toolCount, + updatedAt: now, }) .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) + await refreshEntityListSession('mcp_server', workspaceId) logger.info(`[${requestId}] Successfully refreshed MCP server: ${serverId}`) return createMcpSuccessResponse({ diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts deleted file mode 100644 index 85061f75b..000000000 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ /dev/null @@ -1,95 +0,0 @@ -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 { SavedEntityRealtimeRequiredError } 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 { RenameMcpServerSchema } from '../schema' - -const logger = createLogger('McpServerAPI') - -export const dynamic = 'force-dynamic' - -/** - * 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 ( - request: NextRequest, - { userId, workspaceId, requestId }, - { params }: { params: { id: string } } - ) => { - const serverId = params.id - - try { - const rawBody = getParsedBody(request) || (await request.json()) - - const parseResult = RenameMcpServerSchema.safeParse(rawBody) - if (!parseResult.success) { - return createMcpErrorResponse( - new Error(`Invalid request body: ${parseResult.error.message}`), - 'Invalid request body', - 400 - ) - } - - const body = parseResult.data - - logger.info(`[${requestId}] Updating MCP server: ${serverId} in workspace: ${workspaceId}`, { - userId, - updates: ['name'], - }) - - 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', - 404 - ) - } - - 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: 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) - } - - logger.error(`[${requestId}] Error updating MCP server:`, error) - return createMcpErrorResponse( - error instanceof Error ? error : new Error('Failed to update MCP server'), - 'Failed to update MCP server', - 500 - ) - } - } -) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index aa53a7e0d..d4e7ecc0c 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, inArray, isNull } from 'drizzle-orm' +import { and, asc, 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' @@ -8,11 +8,9 @@ import { createLogger } from '@/lib/logs/console/logger' 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 { requireSavedEntityRealtimeListMembers } from '@/lib/yjs/server/bootstrap-review-target' import { deleteYjsSessionInSocketServer, - notifyEntityListMemberRemoved, + refreshEntityListSession, } from '@/lib/yjs/server/snapshot-bridge' import { CreateMcpServerSchema } from './schema' @@ -28,60 +26,40 @@ export const GET = withMcpAuth('read')( try { logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) - const listMembers = await requireSavedEntityRealtimeListMembers('mcp_server', workspaceId) - const listMemberIds = listMembers.map((member) => member.entityId) - const statusById = new Map( - listMemberIds.length === 0 - ? [] - : ( - await db - .select({ - id: mcpServers.id, - 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) - if (!status) { - return [] - } - - return { - id: server.entityId, - name: server.entityName, - enabled: server.enabled !== false, - workspaceId, - updatedAt: status.updatedAt?.toISOString(), - connectionStatus: status.connectionStatus, - lastError: status.lastError, - toolCount: status.toolCount, - lastConnected: status.lastConnected?.toISOString(), - lastToolsRefresh: status.lastToolsRefresh?.toISOString(), - } - }) + const rows = 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))) + .orderBy(asc(mcpServers.name), asc(mcpServers.id)) + + const servers = rows.map((server) => ({ + id: server.id, + name: server.name, + enabled: server.enabled !== false, + workspaceId, + updatedAt: server.updatedAt?.toISOString(), + connectionStatus: server.connectionStatus, + lastError: server.lastError, + toolCount: server.toolCount, + lastConnected: server.lastConnected?.toISOString(), + lastToolsRefresh: server.lastToolsRefresh?.toISOString(), + })) logger.info( `[${requestId}] Listed ${servers.length} MCP servers for workspace ${workspaceId}` ) 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'), @@ -143,9 +121,6 @@ export const POST = withMcpAuth('write')( if (error instanceof McpServerConfigError) { return createMcpErrorResponse(error, error.message, error.status) } - 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'), @@ -198,10 +173,8 @@ export const DELETE = withMcpAuth('write')( ) ) - await Promise.allSettled([ - deleteYjsSessionInSocketServer(serverId), - notifyEntityListMemberRemoved('mcp_server', workspaceId, serverId), - ]) + await refreshEntityListSession('mcp_server', workspaceId) + await Promise.allSettled([deleteYjsSessionInSocketServer(serverId)]) logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) return createMcpSuccessResponse({ diff --git a/apps/tradinggoose/app/api/mcp/servers/schema.ts b/apps/tradinggoose/app/api/mcp/servers/schema.ts index f877c3ae4..e2a21b5fb 100644 --- a/apps/tradinggoose/app/api/mcp/servers/schema.ts +++ b/apps/tradinggoose/app/api/mcp/servers/schema.ts @@ -21,7 +21,3 @@ export const CreateMcpServerSchema = McpServerBaseSchema.refine( path: ['url'], } ) - -export const RenameMcpServerSchema = z.object({ - name: z.string().trim().min(1), -}).strict() diff --git a/apps/tradinggoose/app/api/mcp/tools/discover/route.ts b/apps/tradinggoose/app/api/mcp/tools/discover/route.ts index 380d7dbbf..2ad8318da 100644 --- a/apps/tradinggoose/app/api/mcp/tools/discover/route.ts +++ b/apps/tradinggoose/app/api/mcp/tools/discover/route.ts @@ -17,6 +17,7 @@ export const GET = withMcpAuth('read')( try { const { searchParams } = new URL(request.url) const serverId = searchParams.get('serverId') + const isDeployedContext = searchParams.get('isDeployedContext') !== 'false' logger.info(`[${requestId}] Discovering MCP tools for user ${userId}`, { serverId, @@ -25,9 +26,14 @@ export const GET = withMcpAuth('read')( let tools if (serverId) { - tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) + tools = await mcpService.discoverServerTools( + userId, + serverId, + workspaceId, + isDeployedContext + ) } else { - tools = await mcpService.discoverTools(userId, workspaceId) + tools = await mcpService.discoverTools(userId, workspaceId, isDeployedContext) } const byServer: Record<string, number> = {} @@ -61,6 +67,7 @@ export const POST = withMcpAuth('read')( try { const body = getParsedBody(request) || (await request.json()) const { serverIds } = body + const isDeployedContext = body.isDeployedContext !== false if (!Array.isArray(serverIds)) { return createMcpErrorResponse( @@ -77,7 +84,12 @@ export const POST = withMcpAuth('read')( const results = await Promise.allSettled( serverIds.map(async (serverId: string) => { - const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) + const tools = await mcpService.discoverServerTools( + userId, + serverId, + workspaceId, + isDeployedContext + ) return { serverId, toolCount: tools.length } }) ) diff --git a/apps/tradinggoose/app/api/mcp/tools/execute/route.ts b/apps/tradinggoose/app/api/mcp/tools/execute/route.ts index 63845865f..eb18f5b6f 100644 --- a/apps/tradinggoose/app/api/mcp/tools/execute/route.ts +++ b/apps/tradinggoose/app/api/mcp/tools/execute/route.ts @@ -58,6 +58,7 @@ export const POST = withMcpAuth('read')( }) const { serverId, toolName, arguments: args } = body + const isDeployedContext = body.isDeployedContext !== false const serverIdValidation = validateStringParam(serverId, 'serverId') if (!serverIdValidation.isValid) { @@ -77,7 +78,12 @@ export const POST = withMcpAuth('read')( let tool = null try { - const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) + const tools = await mcpService.discoverServerTools( + userId, + serverId, + workspaceId, + isDeployedContext + ) tool = tools.find((t) => t.name === toolName) if (!tool) { @@ -176,7 +182,7 @@ export const POST = withMcpAuth('read')( } const result = await Promise.race([ - mcpService.executeTool(userId, serverId, toolCall, workspaceId), + mcpService.executeTool(userId, serverId, toolCall, workspaceId, isDeployedContext), new Promise<never>((_, reject) => setTimeout( () => reject(new Error('Tool execution timeout')), diff --git a/apps/tradinggoose/app/api/monitors/shared.ts b/apps/tradinggoose/app/api/monitors/shared.ts index d813478f6..6752f33d1 100644 --- a/apps/tradinggoose/app/api/monitors/shared.ts +++ b/apps/tradinggoose/app/api/monitors/shared.ts @@ -7,7 +7,7 @@ import { } from '@tradinggoose/db/schema' import { and, desc, eq, inArray, sql } from 'drizzle-orm' import { DEFAULT_INDICATOR_RUNTIME_MAP } from '@/lib/indicators/default/runtime' -import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' +import { inferInputMetaFromPineCode } from '@/lib/indicators/input-meta' import { type IndicatorMonitorProviderConfig, toPublicIndicatorMonitorProviderConfig, @@ -299,7 +299,7 @@ export const loadIndicatorInputMetadata = async ( .select({ id: pineIndicators.id, workspaceId: pineIndicators.workspaceId, - inputMeta: pineIndicators.inputMeta, + pineCode: pineIndicators.pineCode, }) .from(pineIndicators) .where(and(eq(pineIndicators.id, indicatorId), eq(pineIndicators.workspaceId, workspaceId))) @@ -310,7 +310,7 @@ export const loadIndicatorInputMetadata = async ( throw new Error(`Indicator ${indicatorId} not found.`) } - const inputMeta = normalizeInputMetaMap(row.inputMeta) + const inputMeta = inferInputMetaFromPineCode(row.pineCode) return { id: row.id, ...(inputMeta && Object.keys(inputMeta).length > 0 ? { inputMeta } : {}), diff --git a/apps/tradinggoose/app/api/providers/ai/handler.ts b/apps/tradinggoose/app/api/providers/ai/handler.ts index 82e649ff8..13a3d8ea8 100644 --- a/apps/tradinggoose/app/api/providers/ai/handler.ts +++ b/apps/tradinggoose/app/api/providers/ai/handler.ts @@ -42,6 +42,7 @@ export interface ProviderRouteBody { reasoningEffort?: string verbosity?: string thinkingLevel?: string + isDeployedContext?: boolean } interface HandleAIProviderParams { @@ -88,6 +89,7 @@ export async function handleAIProviderRequest({ reasoningEffort, verbosity, thinkingLevel, + isDeployedContext, } = body const providerConfig = getProvider(providerId) @@ -182,6 +184,7 @@ export async function handleAIProviderRequest({ reasoningEffort, verbosity, thinkingLevel, + isDeployedContext, }) const executionTime = Date.now() - startTime diff --git a/apps/tradinggoose/app/api/skills/route.test.ts b/apps/tradinggoose/app/api/skills/route.test.ts index bfcf5fa99..9d9131e06 100644 --- a/apps/tradinggoose/app/api/skills/route.test.ts +++ b/apps/tradinggoose/app/api/skills/route.test.ts @@ -79,6 +79,15 @@ describe('Skills API Routes', () => { expect(body.error).toBe('workspaceId is required') }) + it('GET should list live workspace skills', async () => { + const req = new NextRequest('http://localhost:3000/api/skills?workspaceId=ws-1') + const { GET } = await import('@/app/api/skills/route') + const res = await GET(req) + + expect(res.status).toBe(200) + expect(mockListSkills).toHaveBeenCalledWith({ workspaceId: 'ws-1' }) + }) + it('POST should require workspaceId in body', async () => { const req = new NextRequest('http://localhost:3000/api/skills', { method: 'POST', diff --git a/apps/tradinggoose/app/api/templates/[id]/route.ts b/apps/tradinggoose/app/api/templates/[id]/route.ts deleted file mode 100644 index 1b880f10f..000000000 --- a/apps/tradinggoose/app/api/templates/[id]/route.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { db } from '@tradinggoose/db' -import { templates, workflow } from '@tradinggoose/db/schema' -import { eq, sql } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { getSession } from '@/lib/auth' -import { createLogger } from '@/lib/logs/console/logger' -import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils' -import { generateRequestId } from '@/lib/utils' - -const logger = createLogger('TemplateByIdAPI') - -export const revalidate = 0 - -// GET /api/templates/[id] - Retrieve a single template by ID -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized template access attempt for ID: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - logger.debug(`[${requestId}] Fetching template: ${id}`) - - // Fetch the template by ID - const result = await db.select().from(templates).where(eq(templates.id, id)).limit(1) - - if (result.length === 0) { - logger.warn(`[${requestId}] Template not found: ${id}`) - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } - - const template = result[0] - - // Increment the view count - try { - await db - .update(templates) - .set({ - views: sql`${templates.views} + 1`, - updatedAt: new Date(), - }) - .where(eq(templates.id, id)) - - logger.debug(`[${requestId}] Incremented view count for template: ${id}`) - } catch (viewError) { - // Log the error but don't fail the request - logger.warn(`[${requestId}] Failed to increment view count for template: ${id}`, viewError) - } - - logger.info(`[${requestId}] Successfully retrieved template: ${id}`) - - return NextResponse.json({ - data: { - ...template, - views: template.views + 1, // Return the incremented view count - }, - }) - } catch (error: any) { - logger.error(`[${requestId}] Error fetching template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -const updateTemplateSchema = z.object({ - name: z.string().min(1).max(100), - description: z.string().min(1).max(500), - author: z.string().min(1).max(100), - category: z.string().min(1), - icon: z.string().min(1), - color: z.string().regex(/^#[0-9A-F]{6}$/i), - state: z.any().optional(), // Workflow state -}) - -// PUT /api/templates/[id] - Update a template -export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized template update attempt for ID: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - const validationResult = updateTemplateSchema.safeParse(body) - - if (!validationResult.success) { - logger.warn(`[${requestId}] Invalid template data for update: ${id}`, validationResult.error) - return NextResponse.json( - { error: 'Invalid template data', details: validationResult.error.errors }, - { status: 400 } - ) - } - - const { name, description, author, category, icon, color, state } = validationResult.data - - // Check if template exists - const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1) - - if (existingTemplate.length === 0) { - logger.warn(`[${requestId}] Template not found for update: ${id}`) - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } - - // Permission: template owner OR admin of the workflow's workspace (if any) - let canUpdate = existingTemplate[0].userId === session.user.id - - if (!canUpdate && existingTemplate[0].workflowId) { - const wfRows = await db - .select({ workspaceId: workflow.workspaceId }) - .from(workflow) - .where(eq(workflow.id, existingTemplate[0].workflowId)) - .limit(1) - - const workspaceId = wfRows[0]?.workspaceId as string | null | undefined - if (workspaceId) { - const hasAdmin = await hasWorkspaceAdminAccess(session.user.id, workspaceId) - if (hasAdmin) canUpdate = true - } - } - - if (!canUpdate) { - logger.warn(`[${requestId}] User denied permission to update template ${id}`) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - - // Update the template - const updatedTemplate = await db - .update(templates) - .set({ - name, - description, - author, - category, - icon, - color, - ...(state && { state }), - updatedAt: new Date(), - }) - .where(eq(templates.id, id)) - .returning() - - logger.info(`[${requestId}] Successfully updated template: ${id}`) - - return NextResponse.json({ - data: updatedTemplate[0], - message: 'Template updated successfully', - }) - } catch (error: any) { - logger.error(`[${requestId}] Error updating template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -// DELETE /api/templates/[id] - Delete a template -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized template delete attempt for ID: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Fetch template - const existing = await db.select().from(templates).where(eq(templates.id, id)).limit(1) - if (existing.length === 0) { - logger.warn(`[${requestId}] Template not found for delete: ${id}`) - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } - - const template = existing[0] - - // Permission: owner or admin of the workflow's workspace (if any) - let canDelete = template.userId === session.user.id - - if (!canDelete && template.workflowId) { - // Look up workflow to get workspaceId - const wfRows = await db - .select({ workspaceId: workflow.workspaceId }) - .from(workflow) - .where(eq(workflow.id, template.workflowId)) - .limit(1) - - const workspaceId = wfRows[0]?.workspaceId as string | null | undefined - if (workspaceId) { - const hasAdmin = await hasWorkspaceAdminAccess(session.user.id, workspaceId) - if (hasAdmin) canDelete = true - } - } - - if (!canDelete) { - logger.warn(`[${requestId}] User denied permission to delete template ${id}`) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - - await db.delete(templates).where(eq(templates.id, id)) - - logger.info(`[${requestId}] Deleted template: ${id}`) - return NextResponse.json({ success: true }) - } catch (error: any) { - logger.error(`[${requestId}] Error deleting template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/tradinggoose/app/api/templates/[id]/star/route.ts b/apps/tradinggoose/app/api/templates/[id]/star/route.ts deleted file mode 100644 index 70c84abe4..000000000 --- a/apps/tradinggoose/app/api/templates/[id]/star/route.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { db } from '@tradinggoose/db' -import { templateStars, templates } from '@tradinggoose/db/schema' -import { and, eq, sql } from 'drizzle-orm' -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' - -const logger = createLogger('TemplateStarAPI') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -// GET /api/templates/[id]/star - Check if user has starred this template -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized star check attempt for template: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - logger.debug( - `[${requestId}] Checking star status for template: ${id}, user: ${session.user.id}` - ) - - // Check if the user has starred this template - const starRecord = await db - .select({ id: templateStars.id }) - .from(templateStars) - .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) - .limit(1) - - const isStarred = starRecord.length > 0 - - logger.info(`[${requestId}] Star status checked: ${isStarred} for template: ${id}`) - - return NextResponse.json({ data: { isStarred } }) - } catch (error: any) { - logger.error(`[${requestId}] Error checking star status for template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -// POST /api/templates/[id]/star - Add a star to the template -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized star attempt for template: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - logger.debug(`[${requestId}] Adding star for template: ${id}, user: ${session.user.id}`) - - // Verify the template exists - const templateExists = await db - .select({ id: templates.id }) - .from(templates) - .where(eq(templates.id, id)) - .limit(1) - - if (templateExists.length === 0) { - logger.warn(`[${requestId}] Template not found: ${id}`) - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } - - // Check if user has already starred this template - const existingStar = await db - .select({ id: templateStars.id }) - .from(templateStars) - .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) - .limit(1) - - if (existingStar.length > 0) { - logger.info(`[${requestId}] Template already starred: ${id}`) - return NextResponse.json({ message: 'Template already starred' }, { status: 200 }) - } - - // Use a transaction to ensure consistency - await db.transaction(async (tx) => { - // Add the star record - await tx.insert(templateStars).values({ - id: uuidv4(), - userId: session.user.id, - templateId: id, - starredAt: new Date(), - createdAt: new Date(), - }) - - // Increment the star count - await tx - .update(templates) - .set({ - stars: sql`${templates.stars} + 1`, - updatedAt: new Date(), - }) - .where(eq(templates.id, id)) - }) - - logger.info(`[${requestId}] Successfully starred template: ${id}`) - return NextResponse.json({ message: 'Template starred successfully' }, { status: 201 }) - } catch (error: any) { - // Handle unique constraint violations gracefully - if (error.code === '23505') { - logger.info(`[${requestId}] Duplicate star attempt for template: ${id}`) - return NextResponse.json({ message: 'Template already starred' }, { status: 200 }) - } - - logger.error(`[${requestId}] Error starring template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -// DELETE /api/templates/[id]/star - Remove a star from the template -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized unstar attempt for template: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - logger.debug(`[${requestId}] Removing star for template: ${id}, user: ${session.user.id}`) - - // Check if the star exists - const existingStar = await db - .select({ id: templateStars.id }) - .from(templateStars) - .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) - .limit(1) - - if (existingStar.length === 0) { - logger.info(`[${requestId}] No star found to remove for template: ${id}`) - return NextResponse.json({ message: 'Template not starred' }, { status: 200 }) - } - - // Use a transaction to ensure consistency - await db.transaction(async (tx) => { - // Remove the star record - await tx - .delete(templateStars) - .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) - - // Decrement the star count (prevent negative values) - await tx - .update(templates) - .set({ - stars: sql`GREATEST(${templates.stars} - 1, 0)`, - updatedAt: new Date(), - }) - .where(eq(templates.id, id)) - }) - - logger.info(`[${requestId}] Successfully unstarred template: ${id}`) - return NextResponse.json({ message: 'Template unstarred successfully' }, { status: 200 }) - } catch (error: any) { - logger.error(`[${requestId}] Error unstarring template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/tradinggoose/app/api/templates/[id]/use/route.ts b/apps/tradinggoose/app/api/templates/[id]/use/route.ts deleted file mode 100644 index c8f635eb5..000000000 --- a/apps/tradinggoose/app/api/templates/[id]/use/route.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { db } from '@tradinggoose/db' -import { templates, workflow } from '@tradinggoose/db/schema' -import { eq, sql } from 'drizzle-orm' -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 { 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') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -// POST /api/templates/[id]/use - Use a template (increment views and create workflow) -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized use attempt for template: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Get workspace ID from request body - const body = await request.json() - const { workspaceId } = body - - if (!workspaceId) { - logger.warn(`[${requestId}] Missing workspaceId in request body`) - return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) - } - - logger.debug( - `[${requestId}] Using template: ${id}, user: ${session.user.id}, workspace: ${workspaceId}` - ) - - // Get the template with its data - const template = await db - .select({ - id: templates.id, - name: templates.name, - description: templates.description, - state: templates.state, - color: templates.color, - }) - .from(templates) - .where(eq(templates.id, id)) - .limit(1) - - if (template.length === 0) { - logger.warn(`[${requestId}] Template not found: ${id}`) - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } - - const templateData = template[0] - - // Create a new workflow ID - const newWorkflowId = uuidv4() - const now = new Date() - - const templateState = - 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) - const workflowName = `${templateData.name} (copy)` - - await db.insert(workflow).values({ - id: newWorkflowId, - workspaceId: workspaceId, - name: workflowName, - description: templateData.description, - color: templateData.color, - userId: session.user.id, - createdAt: now, - updatedAt: now, - lastSynced: now, - }) - - const regeneratedState = regenerateWorkflowStateIds(templateState) - 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' }, - { status: 500 } - ) - } - - await db - .update(templates) - .set({ - views: sql`${templates.views} + 1`, - updatedAt: new Date(), - }) - .where(eq(templates.id, id)) - - logger.info( - `[${requestId}] Successfully used template: ${id}, created workflow: ${newWorkflowId}` - ) - - // Track template usage - try { - const { trackPlatformEvent } = await import('@/lib/telemetry/tracer') - const templateState = templateData.state as any - trackPlatformEvent('platform.template.used', { - 'template.id': id, - 'template.name': templateData.name, - 'workflow.created_id': newWorkflowId, - 'workflow.blocks_count': templateState?.blocks - ? Object.keys(templateState.blocks).length - : 0, - 'workspace.id': workspaceId, - }) - } catch (_e) { - // Silently fail - } - - // Verify the workflow was actually created - const verifyWorkflow = await db - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.id, newWorkflowId)) - .limit(1) - - if (verifyWorkflow.length === 0) { - logger.error(`[${requestId}] Workflow was not created properly: ${newWorkflowId}`) - return NextResponse.json({ error: 'Failed to create workflow' }, { status: 500 }) - } - - return NextResponse.json( - { - message: 'Template used successfully', - workflowId: newWorkflowId, - workspaceId: workspaceId, - }, - { status: 201 } - ) - } catch (error: any) { - logger.error(`[${requestId}] Error using template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/tradinggoose/app/api/templates/route.ts b/apps/tradinggoose/app/api/templates/route.ts deleted file mode 100644 index e2974d33b..000000000 --- a/apps/tradinggoose/app/api/templates/route.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { db } from '@tradinggoose/db' -import { templateStars, templates, workflow } from '@tradinggoose/db/schema' -import { and, desc, eq, ilike, or, sql } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { v4 as uuidv4 } from 'uuid' -import { z } from 'zod' -import { getSession } from '@/lib/auth' -import { createLogger } from '@/lib/logs/console/logger' -import { generateRequestId } from '@/lib/utils' - -const logger = createLogger('TemplatesAPI') - -export const revalidate = 0 - -// Function to sanitize sensitive data from workflow state -function sanitizeWorkflowState(state: any): any { - const sanitizedState = JSON.parse(JSON.stringify(state)) // Deep clone - - if (sanitizedState.blocks) { - Object.values(sanitizedState.blocks).forEach((block: any) => { - if (block.subBlocks) { - Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => { - // Clear OAuth credentials and API keys using regex patterns - if ( - /credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key) || - /credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test( - subBlock.type || '' - ) || - /credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test( - subBlock.value || '' - ) - ) { - subBlock.value = '' - } - }) - } - - // Also clear from data field if present - if (block.data) { - Object.entries(block.data).forEach(([key, value]: [string, any]) => { - if (/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key)) { - block.data[key] = '' - } - }) - } - }) - } - - return sanitizedState -} - -// Schema for creating a template -const CreateTemplateSchema = z.object({ - workflowId: z.string().min(1, 'Workflow ID is required'), - name: z.string().min(1, 'Name is required').max(100, 'Name must be less than 100 characters'), - description: z - .string() - .min(1, 'Description is required') - .max(500, 'Description must be less than 500 characters'), - author: z - .string() - .min(1, 'Author is required') - .max(100, 'Author must be less than 100 characters'), - category: z.string().min(1, 'Category is required'), - icon: z.string().min(1, 'Icon is required'), - color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Color must be a valid hex color (e.g., #3972F6)'), - state: z.object({ - blocks: z.record(z.any()), - edges: z.array(z.any()), - loops: z.record(z.any()), - parallels: z.record(z.any()), - variables: z.record(z.any()).optional(), - }), -}) - -// Schema for query parameters -const QueryParamsSchema = z.object({ - category: z.string().optional(), - limit: z.coerce.number().optional().default(50), - offset: z.coerce.number().optional().default(0), - search: z.string().optional(), - workflowId: z.string().optional(), -}) - -// GET /api/templates - Retrieve templates -export async function GET(request: NextRequest) { - const requestId = generateRequestId() - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized templates access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(request.url) - const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) - - logger.debug(`[${requestId}] Fetching templates with params:`, params) - - // Build query conditions - const conditions = [] - - // Apply category filter if provided - if (params.category) { - conditions.push(eq(templates.category, params.category)) - } - - // Apply search filter if provided - if (params.search) { - const searchTerm = `%${params.search}%` - conditions.push( - or(ilike(templates.name, searchTerm), ilike(templates.description, searchTerm)) - ) - } - - // Apply workflow filter if provided (for getting template by workflow) - if (params.workflowId) { - conditions.push(eq(templates.workflowId, params.workflowId)) - } - - // Combine conditions - const whereCondition = conditions.length > 0 ? and(...conditions) : undefined - - // Apply ordering, limit, and offset with star information - const results = await db - .select({ - id: templates.id, - workflowId: templates.workflowId, - userId: templates.userId, - name: templates.name, - description: templates.description, - author: templates.author, - views: templates.views, - stars: templates.stars, - color: templates.color, - icon: templates.icon, - category: templates.category, - state: templates.state, - createdAt: templates.createdAt, - updatedAt: templates.updatedAt, - isStarred: sql<boolean>`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`, - }) - .from(templates) - .leftJoin( - templateStars, - and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id)) - ) - .where(whereCondition) - .orderBy(desc(templates.views), desc(templates.createdAt)) - .limit(params.limit) - .offset(params.offset) - - // Get total count for pagination - const totalCount = await db - .select({ count: sql<number>`count(*)` }) - .from(templates) - .where(whereCondition) - - const total = totalCount[0]?.count || 0 - - logger.info(`[${requestId}] Successfully retrieved ${results.length} templates`) - - return NextResponse.json({ - data: results, - pagination: { - total, - limit: params.limit, - offset: params.offset, - page: Math.floor(params.offset / params.limit) + 1, - totalPages: Math.ceil(total / params.limit), - }, - }) - } catch (error: any) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid query parameters`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid query parameters', details: error.errors }, - { status: 400 } - ) - } - - logger.error(`[${requestId}] Error fetching templates`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -// POST /api/templates - Create a new template -export async function POST(request: NextRequest) { - const requestId = generateRequestId() - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized template creation attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - const data = CreateTemplateSchema.parse(body) - - logger.debug(`[${requestId}] Creating template:`, { - name: data.name, - category: data.category, - workflowId: data.workflowId, - }) - - // Verify the workflow exists and belongs to the user - const workflowExists = await db - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.id, data.workflowId)) - .limit(1) - - if (workflowExists.length === 0) { - logger.warn(`[${requestId}] Workflow not found: ${data.workflowId}`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - // Create the template - const templateId = uuidv4() - const now = new Date() - - // Sanitize the workflow state to remove sensitive credentials - const sanitizedState = sanitizeWorkflowState(data.state) - - const newTemplate = { - id: templateId, - workflowId: data.workflowId, - userId: session.user.id, - name: data.name, - description: data.description || null, - author: data.author, - views: 0, - stars: 0, - color: data.color, - icon: data.icon, - category: data.category, - state: sanitizedState, - createdAt: now, - updatedAt: now, - } - - await db.insert(templates).values(newTemplate) - - logger.info(`[${requestId}] Successfully created template: ${templateId}`) - - return NextResponse.json( - { - id: templateId, - message: 'Template created successfully', - }, - { status: 201 } - ) - } catch (error: any) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid template data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid template data', details: error.errors }, - { status: 400 } - ) - } - - logger.error(`[${requestId}] Error creating template`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/tradinggoose/app/api/tools/custom/route.ts b/apps/tradinggoose/app/api/tools/custom/route.ts index 264ec7349..444363084 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.ts @@ -14,7 +14,7 @@ import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' import { deleteYjsSessionInSocketServer, - notifyEntityListMemberRemoved, + refreshEntityListSession, } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('CustomToolsAPI') @@ -232,10 +232,8 @@ export async function DELETE(request: NextRequest) { .delete(customTools) .where(and(eq(customTools.id, toolId), eq(customTools.workspaceId, workspaceId))) - await Promise.allSettled([ - deleteYjsSessionInSocketServer(toolId), - notifyEntityListMemberRemoved('custom_tool', workspaceId, toolId), - ]) + await refreshEntityListSession('custom_tool', workspaceId) + await Promise.allSettled([deleteYjsSessionInSocketServer(toolId)]) logger.info(`[${requestId}] Deleted tool: ${toolId}`) return NextResponse.json({ success: true }) 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 9f79c7af1..d8ac056e7 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts @@ -8,6 +8,7 @@ describe('Workflow Duplicate API Route', () => { let loadWorkflowStateMock: ReturnType<typeof vi.fn> let regenerateWorkflowStateIdsMock: ReturnType<typeof vi.fn> let applyWorkflowStateMock: ReturnType<typeof vi.fn> + let refreshWorkflowListForWorkflowMock: ReturnType<typeof vi.fn> let insertValuesMock: ReturnType<typeof vi.fn> let deleteWhereMock: ReturnType<typeof vi.fn> @@ -44,6 +45,7 @@ describe('Workflow Duplicate API Route', () => { loadWorkflowStateMock = vi.fn() regenerateWorkflowStateIdsMock = vi.fn((state) => JSON.parse(JSON.stringify(state))) applyWorkflowStateMock = vi.fn().mockResolvedValue(undefined) + refreshWorkflowListForWorkflowMock = vi.fn().mockResolvedValue(undefined) insertValuesMock = vi.fn().mockResolvedValue(undefined) deleteWhereMock = vi.fn().mockResolvedValue(undefined) @@ -110,6 +112,7 @@ describe('Workflow Duplicate API Route', () => { isWorkflowRealtimeRequiredError: vi.fn(() => false), requireWorkflowRealtimeState: loadWorkflowStateMock, regenerateWorkflowStateIds: regenerateWorkflowStateIdsMock, + refreshWorkflowListForWorkflow: refreshWorkflowListForWorkflowMock, WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', })) @@ -161,6 +164,7 @@ describe('Workflow Duplicate API Route', () => { expect(response.status).toBe(201) expect(insertValuesMock).toHaveBeenCalledOnce() expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + expect(refreshWorkflowListForWorkflowMock).toHaveBeenCalledOnce() const insertedWorkflow = insertValuesMock.mock.calls[0][0] const persistedWorkflowId = applyWorkflowStateMock.mock.calls[0][0] @@ -168,6 +172,7 @@ describe('Workflow Duplicate API Route', () => { const persistedVariables = applyWorkflowStateMock.mock.calls[0][2] expect(insertedWorkflow.id).toBe(persistedWorkflowId) + expect(refreshWorkflowListForWorkflowMock).toHaveBeenCalledWith(persistedWorkflowId) expect(persistedState.blocks).toEqual( expect.objectContaining({ [Object.keys(persistedState.blocks)[0]]: expect.objectContaining({ @@ -207,6 +212,7 @@ describe('Workflow Duplicate API Route', () => { expect(response.status).toBe(500) expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + expect(refreshWorkflowListForWorkflowMock).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 5194a0903..e178b9428 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -9,6 +9,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' import { + refreshWorkflowListForWorkflow, regenerateWorkflowStateIds, requireWorkflowRealtimeState, } from '@/lib/workflows/db-helpers' @@ -128,21 +129,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: isDeployed: false, collaborators: [], runCount: 0, - isPublished: false, - marketplaceData: null, }) try { await applyWorkflowState( newWorkflowId, createWorkflowSnapshot(duplicatedWorkflowState), - duplicatedVariables, - { name, description: resolvedDescription, folderId: folderId || null } + duplicatedVariables ) } catch (error) { await db.delete(workflow).where(eq(workflow.id, newWorkflowId)) throw error } + await refreshWorkflowListForWorkflow(newWorkflowId) logger.info(`[${requestId}] Duplicated editable workflow state from Yjs`, { sourceWorkflowId, diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index c5c8270c1..fa1f99b20 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -19,8 +19,12 @@ describe('Workflow By ID API Route', () => { const mockReadWorkflowById = vi.fn() const mockReadWorkflowAccessContext = vi.fn() const mockLoadWorkflowState = vi.fn() - const mockApplyWorkflowMetadata = vi.fn() + const mockRefreshWorkflowListForWorkflow = vi.fn() + const mockRefreshWorkflowList = vi.fn() const mockDeleteYjsSession = vi.fn() + const mockDbUpdateReturning = vi.fn() + const mockDbUpdateWhere = vi.fn() + const mockDbUpdateSet = vi.fn() beforeEach(() => { vi.resetModules() @@ -37,6 +41,8 @@ describe('Workflow By ID API Route', () => { WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', isWorkflowRealtimeRequiredError: vi.fn(() => false), requireWorkflowRealtimeState: mockLoadWorkflowState, + refreshWorkflowListForWorkflow: mockRefreshWorkflowListForWorkflow, + refreshWorkflowList: mockRefreshWorkflowList, })) vi.doMock('@tradinggoose/db', () => ({ @@ -46,19 +52,16 @@ describe('Workflow By ID API Route', () => { where: vi.fn().mockResolvedValue([]), }), }), + update: vi.fn().mockReturnValue({ + set: mockDbUpdateSet, + }), }, })) vi.doMock('@tradinggoose/db/schema', () => ({ - templates: { - workflowId: 'workflowId', - id: 'id', - name: 'name', - views: 'views', - stars: 'stars', - }, workflow: { id: 'id', + folderId: 'folderId', }, })) @@ -69,21 +72,28 @@ describe('Workflow By ID API Route', () => { mockReadWorkflowById.mockReset() mockReadWorkflowAccessContext.mockReset() mockLoadWorkflowState.mockReset() - mockApplyWorkflowMetadata.mockReset() + mockRefreshWorkflowListForWorkflow.mockReset() + mockRefreshWorkflowList.mockReset() mockDeleteYjsSession.mockReset() + mockDbUpdateReturning.mockReset() + mockDbUpdateWhere.mockReset() + mockDbUpdateSet.mockReset() mockLoadWorkflowState.mockResolvedValue(null) - mockApplyWorkflowMetadata.mockResolvedValue({ - id: 'workflow-123', - name: 'Updated Workflow', - description: 'Updated description', - folderId: 'folder-1', - workspaceId: null, - }) + mockRefreshWorkflowListForWorkflow.mockResolvedValue(undefined) + mockDbUpdateWhere.mockReturnValue({ returning: mockDbUpdateReturning }) + mockDbUpdateSet.mockReturnValue({ where: mockDbUpdateWhere }) + mockDbUpdateReturning.mockResolvedValue([ + { + id: 'workflow-123', + name: 'Updated Workflow', + description: 'Updated description', + folderId: 'folder-1', + workspaceId: null, + }, + ]) + mockRefreshWorkflowList.mockResolvedValue(undefined) mockDeleteYjsSession.mockResolvedValue(undefined) - vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ - applyWorkflowMetadata: mockApplyWorkflowMetadata, - })) vi.doMock('@/lib/yjs/server/snapshot-bridge', () => ({ deleteYjsSessionInSocketServer: mockDeleteYjsSession, })) @@ -100,9 +110,10 @@ describe('Workflow By ID API Route', () => { function expectWorkflowRenameApplied() { expect(mockLoadWorkflowState).not.toHaveBeenCalled() - expect(mockApplyWorkflowMetadata).toHaveBeenCalledWith('workflow-123', { - name: 'Updated Workflow', - }) + expect(mockDbUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Updated Workflow' }) + ) + expect(mockRefreshWorkflowListForWorkflow).toHaveBeenCalledWith('workflow-123') } describe('GET /api/workflows/[id]', () => { @@ -511,6 +522,7 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(200) const data = await response.json() expect(data.success).toBe(true) + expect(mockRefreshWorkflowList).toHaveBeenCalledWith('workspace-456') }) it('should deny deletion for non-admin users', async () => { @@ -631,7 +643,7 @@ describe('Workflow By ID API Route', () => { expectWorkflowRenameApplied() }) - it('updates workflow metadata through the Yjs session without loading workflow state', async () => { + it('updates workflow metadata without loading workflow state', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -641,12 +653,14 @@ describe('Workflow By ID API Route', () => { workspaceId: null, } - const updateData = { description: 'New description', folderId: 'folder-1' } - mockApplyWorkflowMetadata.mockResolvedValueOnce({ - ...mockWorkflow, - ...updateData, - updatedAt: new Date(), - }) + const updateData = { description: 'New description' } + mockDbUpdateReturning.mockResolvedValueOnce([ + { + ...mockWorkflow, + ...updateData, + updatedAt: new Date(), + }, + ]) vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ @@ -675,12 +689,12 @@ describe('Workflow By ID API Route', () => { 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) + expect(mockDbUpdateSet).toHaveBeenCalledWith(expect.objectContaining(updateData)) + expect(mockRefreshWorkflowListForWorkflow).toHaveBeenCalledWith('workflow-123') }) - it('updates workflow name, description, and folder in one Yjs metadata patch', async () => { + it('updates workflow row metadata and publishes list fields', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -694,11 +708,13 @@ describe('Workflow By ID API Route', () => { description: 'New description', folderId: 'folder-1', } - mockApplyWorkflowMetadata.mockResolvedValueOnce({ - ...mockWorkflow, - ...updateData, - updatedAt: new Date(), - }) + mockDbUpdateReturning.mockResolvedValueOnce([ + { + ...mockWorkflow, + ...updateData, + updatedAt: new Date(), + }, + ]) vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ @@ -730,7 +746,8 @@ describe('Workflow By ID API Route', () => { expect(data.workflow.description).toBe('New description') expect(data.workflow.folderId).toBe('folder-1') expect(mockLoadWorkflowState).not.toHaveBeenCalled() - expect(mockApplyWorkflowMetadata).toHaveBeenCalledWith('workflow-123', updateData) + expect(mockDbUpdateSet).toHaveBeenCalledWith(expect.objectContaining(updateData)) + expect(mockRefreshWorkflowListForWorkflow).toHaveBeenCalledWith('workflow-123') }) it('should deny update for users with only read permission', async () => { @@ -809,7 +826,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() + expect(mockDbUpdateSet).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 17a501a9e..5e952eed1 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -1,5 +1,5 @@ import { db } from '@tradinggoose/db' -import { templates, workflow } from '@tradinggoose/db/schema' +import { workflow } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -9,9 +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 { requireWorkflowRealtimeState } from '@/lib/workflows/db-helpers' +import { + refreshWorkflowList, + refreshWorkflowListForWorkflow, + 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' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' @@ -162,11 +165,6 @@ 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 } : {}), @@ -247,50 +245,11 @@ export async function DELETE( return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - // Check if workflow has published templates before deletion - const { searchParams } = new URL(request.url) - const checkTemplates = searchParams.get('check-templates') === 'true' - const deleteTemplatesParam = searchParams.get('deleteTemplates') - - if (checkTemplates) { - // Return template information for frontend to handle - const publishedTemplates = await db - .select() - .from(templates) - .where(eq(templates.workflowId, workflowId)) - - return NextResponse.json({ - hasPublishedTemplates: publishedTemplates.length > 0, - count: publishedTemplates.length, - publishedTemplates: publishedTemplates.map((t) => ({ - id: t.id, - name: t.name, - views: t.views, - stars: t.stars, - })), - }) - } - - // Handle template deletion based on user choice - if (deleteTemplatesParam !== null) { - const deleteTemplates = deleteTemplatesParam === 'delete' - - if (deleteTemplates) { - // Delete all templates associated with this workflow - await db.delete(templates).where(eq(templates.workflowId, workflowId)) - logger.info(`[${requestId}] Deleted templates for workflow ${workflowId}`) - } else { - // Orphan the templates (set workflowId to null) - await db - .update(templates) - .set({ workflowId: null }) - .where(eq(templates.workflowId, workflowId)) - logger.info(`[${requestId}] Orphaned templates for workflow ${workflowId}`) - } - } - await db.delete(workflow).where(eq(workflow.id, workflowId)) - await deleteYjsSessionInSocketServer(workflowId).catch(() => undefined) + if (workflowData.workspaceId) { + await refreshWorkflowList(workflowData.workspaceId) + } + await Promise.allSettled([deleteYjsSessionInSocketServer(workflowId)]) const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully deleted workflow ${workflowId} in ${elapsed}ms`) @@ -299,13 +258,15 @@ export async function DELETE( } catch (error: any) { const elapsed = Date.now() - startTime logger.error(`[${requestId}] Error deleting workflow ${workflowId} after ${elapsed}ms`, error) + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } /** * PUT /api/workflows/[id] - * Update workflow metadata (name, description, folderId) + * Update workflow row metadata (name, description, folderId) */ export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() @@ -362,15 +323,27 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const metadata = { + const rowUpdates = { ...(updates.name !== undefined ? { name: updates.name } : {}), ...(updates.description !== undefined ? { description: updates.description } : {}), ...(updates.folderId !== undefined ? { folderId: updates.folderId } : {}), + updatedAt: new Date(), + } + const [updatedWorkflow] = await db + .update(workflow) + .set(rowUpdates) + .where(eq(workflow.id, workflowId)) + .returning() + if (!updatedWorkflow) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + if ( + updates.name !== undefined || + updates.description !== undefined || + updates.folderId !== undefined + ) { + await refreshWorkflowListForWorkflow(workflowId) } - 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/app/api/workflows/[id]/status/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts index bc5b265b0..1b1fd971a 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts @@ -105,7 +105,6 @@ describe('Workflow Status API Route', () => { workflow: { isDeployed: true, deployedAt: null, - isPublished: false, }, }) @@ -159,7 +158,6 @@ describe('Workflow Status API Route', () => { workflow: { isDeployed: true, deployedAt: null, - isPublished: false, }, }) @@ -206,7 +204,6 @@ describe('Workflow Status API Route', () => { workflow: { isDeployed: true, deployedAt: null, - isPublished: false, }, }) mockLoadWorkflowState.mockResolvedValue(null) diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts index a50b260c7..7b0799f4f 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts @@ -59,7 +59,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return createSuccessResponse({ isDeployed: validation.workflow.isDeployed, deployedAt: validation.workflow.deployedAt, - isPublished: validation.workflow.isPublished, needsRedeployment, }) } catch (error) { diff --git a/apps/tradinggoose/app/api/workflows/public/[id]/route.ts b/apps/tradinggoose/app/api/workflows/public/[id]/route.ts deleted file mode 100644 index cbd4c9139..000000000 --- a/apps/tradinggoose/app/api/workflows/public/[id]/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { db } from '@tradinggoose/db' -import { marketplace, workflow } from '@tradinggoose/db/schema' -import { eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' -import { createLogger } from '@/lib/logs/console/logger' -import { generateRequestId } from '@/lib/utils' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' - -const logger = createLogger('PublicWorkflowAPI') - -// Cache response for performance -export const revalidate = 3600 - -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - - try { - const { id } = await params - - // First, check if the workflow exists and is published to the marketplace - const marketplaceEntry = await db - .select({ - id: marketplace.id, - workflowId: marketplace.workflowId, - state: marketplace.state, - name: marketplace.name, - description: marketplace.description, - authorId: marketplace.authorId, - authorName: marketplace.authorName, - }) - .from(marketplace) - .where(eq(marketplace.workflowId, id)) - .limit(1) - .then((rows) => rows[0]) - - if (!marketplaceEntry) { - // Check if workflow exists but is not in marketplace - const workflowExists = await db - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.id, id)) - .limit(1) - .then((rows) => rows.length > 0) - - if (!workflowExists) { - logger.warn(`[${requestId}] Workflow not found: ${id}`) - return createErrorResponse('Workflow not found', 404) - } - - logger.warn(`[${requestId}] Workflow exists but is not published: ${id}`) - return createErrorResponse('Workflow is not published', 403) - } - - logger.info(`[${requestId}] Retrieved public workflow: ${id}`) - - return createSuccessResponse({ - id: marketplaceEntry.workflowId, - name: marketplaceEntry.name, - description: marketplaceEntry.description, - authorId: marketplaceEntry.authorId, - authorName: marketplaceEntry.authorName, - state: marketplaceEntry.state, - isPublic: true, - }) - } catch (error) { - logger.error(`[${requestId}] Error getting public workflow: ${(await params).id}`, error) - return createErrorResponse('Failed to get public workflow', 500) - } -} diff --git a/apps/tradinggoose/app/api/workflows/route.test.ts b/apps/tradinggoose/app/api/workflows/route.test.ts index d388e6b16..98a0acc45 100644 --- a/apps/tradinggoose/app/api/workflows/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/route.test.ts @@ -8,6 +8,7 @@ describe('Workflow API Route', () => { const insertValuesMock = vi.fn() const deleteWhereMock = vi.fn() const applyWorkflowStateMock = vi.fn() + const refreshWorkflowListForWorkflowMock = vi.fn() const randomUUIDMock = vi.fn() const createRequest = (body: Record<string, unknown>) => @@ -26,6 +27,7 @@ describe('Workflow API Route', () => { insertValuesMock.mockResolvedValue(undefined) deleteWhereMock.mockResolvedValue(undefined) applyWorkflowStateMock.mockResolvedValue(undefined) + refreshWorkflowListForWorkflowMock.mockResolvedValue(undefined) randomUUIDMock.mockReset() randomUUIDMock.mockReturnValueOnce('workflow-123').mockReturnValueOnce('variable-123') vi.stubGlobal('crypto', { @@ -84,6 +86,10 @@ describe('Workflow API Route', () => { generateRequestId: vi.fn(() => 'request-id'), })) + vi.doMock('@/lib/workflows/db-helpers', () => ({ + refreshWorkflowListForWorkflow: refreshWorkflowListForWorkflowMock, + })) + vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ applyWorkflowState: applyWorkflowStateMock, })) @@ -120,7 +126,7 @@ describe('Workflow API Route', () => { variables: { 'var-1': { id: 'var-1', - workflowId: 'template-workflow', + workflowId: 'source-workflow', name: 'apiKey', type: 'plain', value: 'secret', @@ -141,6 +147,7 @@ describe('Workflow API Route', () => { expect(response.status).toBe(200) expect(insertValuesMock).toHaveBeenCalledOnce() expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + expect(refreshWorkflowListForWorkflowMock).toHaveBeenCalledWith('workflow-123') const insertedWorkflow = insertValuesMock.mock.calls[0][0] const persistedState = applyWorkflowStateMock.mock.calls[0][1] @@ -163,12 +170,7 @@ describe('Workflow API Route', () => { loops: initialWorkflowState.loops, parallels: initialWorkflowState.parallels, }), - persistedVariables, - expect.objectContaining({ - name: 'Workflow Copy', - description: 'Created from seed', - folderId: null, - }) + persistedVariables ) }) @@ -192,6 +194,7 @@ describe('Workflow API Route', () => { expect(response.status).toBe(500) expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + expect(refreshWorkflowListForWorkflowMock).not.toHaveBeenCalled() expect(deleteWhereMock).toHaveBeenCalledOnce() }) @@ -206,6 +209,7 @@ describe('Workflow API Route', () => { expect(response.status).toBe(200) expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + expect(refreshWorkflowListForWorkflowMock).toHaveBeenCalledWith('workflow-123') const insertedWorkflow = insertValuesMock.mock.calls[0][0] const persistedVariables = applyWorkflowStateMock.mock.calls[0][2] @@ -218,8 +222,7 @@ describe('Workflow API Route', () => { loops: {}, parallels: {}, }), - {}, - expect.any(Object) + {} ) }) diff --git a/apps/tradinggoose/app/api/workflows/route.ts b/apps/tradinggoose/app/api/workflows/route.ts index e751fa9f8..7bfb42165 100644 --- a/apps/tradinggoose/app/api/workflows/route.ts +++ b/apps/tradinggoose/app/api/workflows/route.ts @@ -8,6 +8,7 @@ import { getStableVibrantColor } from '@/lib/colors' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' +import { refreshWorkflowListForWorkflow } 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' @@ -190,21 +191,19 @@ export async function POST(req: NextRequest) { isDeployed: false, collaborators: [], runCount: 0, - isPublished: false, - marketplaceData: null, }) try { await applyWorkflowState( workflowId, createWorkflowSnapshot(initialState.canonicalState), - remappedVariables, - { name, description, folderId: folderId || null } + remappedVariables ) } catch (error) { await db.delete(workflow).where(eq(workflow.id, workflowId)) throw error } + await refreshWorkflowListForWorkflow(workflowId) logger.info(`[${requestId}] Successfully created workflow ${workflowId}`) diff --git a/apps/tradinggoose/app/api/workspaces/[id]/route.test.ts b/apps/tradinggoose/app/api/workspaces/[id]/route.test.ts index 5c27ae8e6..3c2c12d2c 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/route.test.ts @@ -41,7 +41,6 @@ vi.mock('@tradinggoose/db', () => ({ }, knowledgeBase: {}, permissions: {}, - templates: {}, workflow: { id: 'workflow.id', workspaceId: 'workflow.workspaceId', @@ -57,7 +56,6 @@ vi.mock('drizzle-orm', async () => { ...actual, eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), and: vi.fn((...conditions) => ({ conditions, type: 'and' })), - inArray: vi.fn((field, values) => ({ field, values, type: 'inArray' })), } }) diff --git a/apps/tradinggoose/app/api/workspaces/[id]/route.ts b/apps/tradinggoose/app/api/workspaces/[id]/route.ts index c8c7124b5..be1e9d84d 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/route.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/route.ts @@ -1,5 +1,5 @@ -import { workflow } from '@tradinggoose/db' -import { and, eq, inArray } from 'drizzle-orm' +import { db, knowledgeBase, permissions, workflow, workspace } from '@tradinggoose/db' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' @@ -19,15 +19,13 @@ import { getUserWorkspaces } from '@/lib/workspaces/service' const logger = createLogger('WorkspaceByIdAPI') -import { db, knowledgeBase, permissions, templates, workspace } from '@tradinggoose/db' - const patchWorkspaceSchema = z.object({ name: z.string().trim().min(1).optional(), allowPersonalApiKeys: z.boolean().optional(), billingOwner: workspaceBillingOwnerSchema.optional(), }) -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { +export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params const session = await getSession() @@ -36,9 +34,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } const workspaceId = id - const url = new URL(request.url) - const checkTemplates = url.searchParams.get('check-templates') === 'true' - const access = await checkWorkspaceAccess(workspaceId, session.user.id) if (!access.exists || !access.hasAccess || !access.workspace) { return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) @@ -49,42 +44,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) } - // If checking for published templates before deletion - if (checkTemplates) { - try { - // Get all workflows in this workspace - const workspaceWorkflows = await db - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.workspaceId, workspaceId)) - - if (workspaceWorkflows.length === 0) { - return NextResponse.json({ hasPublishedTemplates: false, publishedTemplates: [] }) - } - - const workflowIds = workspaceWorkflows.map((w) => w.id) - - // Check for published templates that reference these workflows - const publishedTemplates = await db - .select({ - id: templates.id, - name: templates.name, - workflowId: templates.workflowId, - }) - .from(templates) - .where(inArray(templates.workflowId, workflowIds)) - - return NextResponse.json({ - hasPublishedTemplates: publishedTemplates.length > 0, - publishedTemplates, - count: publishedTemplates.length, - }) - } catch (error) { - logger.error(`Error checking published templates for workspace ${workspaceId}:`, error) - return NextResponse.json({ error: 'Failed to check published templates' }, { status: 500 }) - } - } - return NextResponse.json({ workspace: { ...toWorkspaceApiRecord(access.workspace), @@ -187,7 +146,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< } export async function DELETE( - request: NextRequest, + _request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params @@ -198,9 +157,6 @@ export async function DELETE( } const workspaceId = id - const body = await request.json().catch(() => ({})) - const { deleteTemplates = false } = body // User's choice: false = keep templates (recommended), true = delete templates - const existingWorkspace = await getWorkspaceById(workspaceId) if (!existingWorkspace) { return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) @@ -218,39 +174,10 @@ export async function DELETE( } try { - logger.info( - `Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}` - ) + logger.info(`Deleting workspace ${workspaceId} for user ${session.user.id}`) // Delete workspace and all related data in a transaction await db.transaction(async (tx) => { - // Get all workflows in this workspace before deletion - const workspaceWorkflows = await tx - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.workspaceId, workspaceId)) - - if (workspaceWorkflows.length > 0) { - const workflowIds = workspaceWorkflows.map((w) => w.id) - - // Handle templates based on user choice - if (deleteTemplates) { - // Delete published templates that reference these workflows - await tx.delete(templates).where(inArray(templates.workflowId, workflowIds)) - logger.info(`Deleted templates for workflows in workspace ${workspaceId}`) - } else { - // Set workflowId to null for templates to create "orphaned" templates - // This allows templates to remain in marketplace but without source workflows - await tx - .update(templates) - .set({ workflowId: null }) - .where(inArray(templates.workflowId, workflowIds)) - logger.info( - `Updated templates to orphaned status for workflows in workspace ${workspaceId}` - ) - } - } - // Delete live workflow definitions first. Durable execution logs and snapshots // remain workspace-owned until the workspace row is deleted below. await tx.delete(workflow).where(eq(workflow.workspaceId, workspaceId)) diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.tsx index 724904f23..44f1e567c 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/dashboard/dashboard-client.tsx @@ -6,6 +6,7 @@ import { memo, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -18,7 +19,6 @@ import { LibraryBig, ScrollText, Search, - Shapes, } from 'lucide-react' import { useTranslations } from 'next-intl' import { Input } from '@/components/ui/input' @@ -348,7 +348,7 @@ export function DashboardClient({ setIsSearchOpen(false) }, [dashboardIdentity, initialLayouts, initialTree, layoutId]) - useEffect(() => { + useLayoutEffect(() => { hydratePairStoreFromColorPairs(normalizedInitialColorPairs) }, [normalizedInitialColorPairs]) @@ -494,12 +494,6 @@ export function DashboardClient({ icon: LibraryBig, href: `/workspace/${workspaceId}/knowledge`, }, - { - id: 'templates', - name: t('pages.templates'), - icon: Shapes, - href: `/workspace/${workspaceId}/templates`, - }, { id: 'docs', name: t('pages.docs'), diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/templates/[id]/template.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/templates/[id]/template.tsx deleted file mode 100644 index 32ad89859..000000000 --- a/apps/tradinggoose/app/workspace/[workspaceId]/templates/[id]/template.tsx +++ /dev/null @@ -1,341 +0,0 @@ -'use client' - -import { useState } from 'react' -import { - ArrowLeft, - Award, - BarChart3, - Bell, - BookOpen, - Bot, - Brain, - Briefcase, - Calculator, - Cloud, - Code, - Cpu, - CreditCard, - Database, - DollarSign, - Edit, - Eye, - FileText, - Folder, - Globe, - HeadphonesIcon, - Layers, - Lightbulb, - LineChart, - Mail, - Megaphone, - MessageSquare, - NotebookPen, - Phone, - Play, - Search, - Server, - Settings, - ShoppingCart, - Star, - Target, - TrendingUp, - User, - Users, - Workflow, - Wrench, - Zap, -} from 'lucide-react' -import { useTranslations } from 'next-intl' -import { useRouter } from '@/i18n/navigation' -import { Button } from '@/components/ui/button' -import { createLogger } from '@/lib/logs/console/logger' -import { cn } from '@/lib/utils' -import type { Template } from '@/app/workspace/[workspaceId]/templates/templates' -import { WorkflowPreview } from '@/app/workspace/[workspaceId]/components/workflow-preview/workflow-preview' -import type { WorkflowState } from '@/stores/workflows/workflow/types' - -const logger = createLogger('TemplateDetails') - -interface TemplateDetailsProps { - template: Template - workspaceId: string -} - -// Icon mapping - reuse from template-card -const iconMap = { - FileText, - NotebookPen, - BookOpen, - Edit, - BarChart3, - LineChart, - TrendingUp, - Target, - Database, - Server, - Cloud, - Folder, - Megaphone, - Mail, - MessageSquare, - Phone, - Bell, - DollarSign, - CreditCard, - Calculator, - ShoppingCart, - Briefcase, - HeadphonesIcon, - Users, - Settings, - Wrench, - Bot, - Brain, - Cpu, - Code, - Zap, - Workflow, - Search, - Play, - Layers, - Lightbulb, - Globe, - Award, -} - -// Get icon component from template-card logic -const getIconComponent = (icon: string): React.ReactNode => { - const IconComponent = iconMap[icon as keyof typeof iconMap] - return IconComponent ? <IconComponent className='h-6 w-6' /> : <FileText className='h-6 w-6' /> -} - -export default function TemplateDetails({ template, workspaceId }: TemplateDetailsProps) { - const router = useRouter() - const t = useTranslations('workspace.templates') - const [isStarred, setIsStarred] = useState(template?.isStarred || false) - const [starCount, setStarCount] = useState(template?.stars || 0) - const [isStarring, setIsStarring] = useState(false) - const [isUsing, setIsUsing] = useState(false) - - // Defensive check for template after hooks are initialized - if (!template) { - return ( - <div className='flex h-screen items-center justify-center'> - <div className='text-center'> - <h1 className='mb-4 font-bold text-2xl'>{t('detail.notFoundTitle')}</h1> - <p className='text-muted-foreground'>{t('detail.notFoundDescription')}</p> - </div> - </div> - ) - } - - // Render workflow preview exactly like deploy-modal.tsx - const renderWorkflowPreview = () => { - // Follow the same pattern as deployed-workflow-card.tsx - if (!template?.state) { - logger.info('Template has no state:', template) - return ( - <div className='flex h-full items-center justify-center text-center'> - <div className='text-muted-foreground'> - <div className='mb-2 font-medium text-lg'>{t('detail.noWorkflowDataTitle')}</div> - <div className='text-sm'>{t('detail.noWorkflowDataDescription')}</div> - </div> - </div> - ) - } - - logger.info('Template state:', template.state) - logger.info('Template state type:', typeof template.state) - logger.info('Template state blocks:', template.state.blocks) - logger.info('Template state edges:', template.state.edges) - - try { - return ( - <WorkflowPreview - workflowState={template.state as WorkflowState} - workspaceId={workspaceId} - workflowId={template.workflowId ?? undefined} - showSubBlocks={true} - height='100%' - width='100%' - isPannable={true} - defaultPosition={{ x: 0, y: 0 }} - defaultZoom={1} - /> - ) - } catch (error) { - console.error('Error rendering workflow preview:', error) - return ( - <div className='flex h-full items-center justify-center text-center'> - <div className='text-muted-foreground'> - <div className='mb-2 font-medium text-lg'>{t('detail.previewErrorTitle')}</div> - <div className='text-sm'>{t('detail.previewErrorDescription')}</div> - </div> - </div> - ) - } - } - - const handleBack = () => { - router.back() - } - - const handleStarToggle = async () => { - if (isStarring) return - - setIsStarring(true) - try { - const method = isStarred ? 'DELETE' : 'POST' - const response = await fetch(`/api/templates/${template.id}/star`, { method }) - - if (response.ok) { - setIsStarred(!isStarred) - setStarCount((prev) => (isStarred ? prev - 1 : prev + 1)) - } - } catch (error) { - logger.error('Error toggling star:', error) - } finally { - setIsStarring(false) - } - } - - const handleUseTemplate = async () => { - if (isUsing) return - - setIsUsing(true) - try { - // TODO: Implement proper template usage logic - // This should create a new workflow from the template state - // For now, we'll create a basic workflow and navigate to it - logger.info('Using template:', template.id) - - // Create a new workflow - const response = await fetch('/api/workflows', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: `${template.name} (Copy)`, - description: `Created from template: ${template.name}`, - workspaceId, - folderId: null, - }), - }) - - if (!response.ok) { - throw new Error('Failed to create workflow from template') - } - - const newWorkflow = await response.json() - - // Navigate to the new workflow - router.push(`/workspace/${workspaceId}/dashboard`) - } catch (error) { - logger.error('Error using template:', error) - // Show error to user (could implement toast notification) - } finally { - setIsUsing(false) - } - } - - return ( - <div className='flex min-h-screen flex-col'> - {/* Header */} - <div className='border-b bg-background p-6'> - <div className='mx-auto max-w-7xl'> - {/* Back button */} - <button - onClick={handleBack} - className='mb-6 flex items-center gap-2 text-muted-foreground transition-colors hover:text-foreground' - > - <ArrowLeft className='h-4 w-4' /> - <span className='text-sm'>{t('detail.goBack')}</span> - </button> - - {/* Template header */} - <div className='flex items-start justify-between'> - <div className='flex items-start gap-4'> - {/* Icon */} - <div - className='flex h-12 w-12 items-center justify-center rounded-lg' - style={{ backgroundColor: template.color }} - > - {getIconComponent(template.icon)} - </div> - - {/* Title and description */} - <div> - <h1 className='font-bold text-3xl text-foreground'>{template.name}</h1> - <p className='mt-2 max-w-3xl text-lg text-muted-foreground'> - {template.description} - </p> - </div> - </div> - - {/* Action buttons */} - <div className='flex items-center gap-3'> - {/* Star button */} - <Button - variant='outline' - size='sm' - onClick={handleStarToggle} - disabled={isStarring} - className={cn( - 'transition-colors', - isStarred && 'border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100' - )} - > - <Star className={cn('mr-2 h-4 w-4', isStarred && 'fill-current')} /> - {starCount} - </Button> - - {/* Use template button */} - <Button - onClick={handleUseTemplate} - disabled={isUsing} - className='bg-amber-600 text-white hover:bg-amber-700' - > - {t('detail.useThisTemplate')} - </Button> - </div> - </div> - - {/* Tags */} - <div className='mt-6 flex items-center gap-3 text-muted-foreground text-sm'> - {/* Category */} - <div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'> - <span>{t(`categories.${template.category}`)}</span> - </div> - - {/* Views */} - <div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'> - <Eye className='h-3 w-3' /> - <span>{template.views}</span> - </div> - - {/* Stars */} - <div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'> - <Star className='h-3 w-3' /> - <span>{starCount}</span> - </div> - - {/* Author */} - <div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'> - <User className='h-3 w-3' /> - <span> - {t('detail.by')} {template.author} - </span> - </div> - </div> - </div> - </div> - - {/* Workflow preview */} - <div className='flex-1 p-6'> - <div className='mx-auto max-w-7xl'> - <h2 className='mb-4 font-semibold text-xl'>{t('detail.workflowPreview')}</h2> - <div className='h-[600px] w-full'>{renderWorkflowPreview()}</div> - </div> - </div> - </div> - ) -} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/templates/components/navigation-tabs.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/templates/components/navigation-tabs.tsx deleted file mode 100644 index 0ec760ad7..000000000 --- a/apps/tradinggoose/app/workspace/[workspaceId]/templates/components/navigation-tabs.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { cn } from '@/lib/utils' - -interface NavigationTab { - id: string - label: string - count?: number -} - -interface NavigationTabsProps { - tabs: NavigationTab[] - activeTab?: string - onTabClick?: (tabId: string) => void - className?: string -} - -export function NavigationTabs({ tabs, activeTab, onTabClick, className }: NavigationTabsProps) { - return ( - <div className={cn('flex items-center gap-2', className)}> - {tabs.map((tab, index) => ( - <button - key={tab.id} - onClick={() => onTabClick?.(tab.id)} - className={cn( - 'flex h-[38px] items-center gap-1 rounded-lg px-3 font-[440] font-sans text-muted-foreground text-sm transition-all duration-200', - activeTab === tab.id ? 'bg-secondary' : 'bg-transparent hover:bg-secondary/50' - )} - > - <span>{tab.label}</span> - </button> - ))} - </div> - ) -} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/templates/components/template-card.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/templates/components/template-card.tsx deleted file mode 100644 index 0506799c3..000000000 --- a/apps/tradinggoose/app/workspace/[workspaceId]/templates/components/template-card.tsx +++ /dev/null @@ -1,510 +0,0 @@ -import { useState } from 'react' -import { - Award, - BarChart3, - Bell, - BookOpen, - Bot, - Brain, - Briefcase, - Calculator, - Cloud, - Code, - Cpu, - CreditCard, - Database, - DollarSign, - Edit, - FileText, - Folder, - Globe, - HeadphonesIcon, - Layers, - Lightbulb, - LineChart, - Mail, - Megaphone, - MessageSquare, - NotebookPen, - Phone, - Play, - Search, - Server, - Settings, - ShoppingCart, - Star, - Target, - TrendingUp, - User, - Users, - Workflow, - Wrench, - Zap, -} from 'lucide-react' -import { useParams } from 'next/navigation' -import { useTranslations } from 'next-intl' -import { createLogger } from '@/lib/logs/console/logger' -import { useRouter } from '@/i18n/navigation' -import { sanitizeSolidIconColor } from '@/lib/ui/icon-colors' -import { cn } from '@/lib/utils' -import { getBlock } from '@/blocks/registry' - -const logger = createLogger('TemplateCard') - -// Icon mapping for template icons -const iconMap = { - // Content & Documentation - FileText, - NotebookPen, - BookOpen, - Edit, - - // Analytics & Charts - BarChart3, - LineChart, - TrendingUp, - Target, - - // Database & Storage - Database, - Server, - Cloud, - Folder, - - // Marketing & Communication - Megaphone, - Mail, - MessageSquare, - Phone, - Bell, - - // Sales & Finance - DollarSign, - CreditCard, - Calculator, - ShoppingCart, - Briefcase, - - // Support & Service - HeadphonesIcon, - User, - Users, - Settings, - Wrench, - - // AI & Technology - Bot, - Brain, - Cpu, - Code, - Zap, - - // Workflow & Process - Workflow, - Search, - Play, - Layers, - - // General - Lightbulb, - Star, - Globe, - Award, -} - -interface TemplateCardProps { - id: string - title: string - description: string - author: string - usageCount: string - stars?: number - icon?: React.ReactNode | string - iconColor?: string - blocks?: string[] - onClick?: () => void - className?: string - // Add state prop to extract block types - state?: { - blocks?: Record<string, { type: string; name?: string }> - } - isStarred?: boolean - // Optional callback when template is successfully used (for closing modals, etc.) - onTemplateUsed?: () => void - // Callback when star state changes (for parent state updates) - onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void -} - -// Skeleton component for loading states -export function TemplateCardSkeleton({ className }: { className?: string }) { - return ( - <div className={cn('rounded-sm border bg-card shadow-xs', 'flex h-[142px]', className)}> - {/* Left side - Info skeleton */} - <div className='flex min-w-0 flex-1 flex-col justify-between p-4'> - {/* Top section skeleton */} - <div className='space-y-2'> - <div className='flex min-w-0 items-center justify-between gap-2.5'> - <div className='flex min-w-0 items-center gap-2.5'> - {/* Icon skeleton */} - <div className='h-5 w-5 flex-shrink-0 animate-pulse rounded-md bg-gray-200' /> - {/* Title skeleton */} - <div className='h-4 w-32 animate-pulse rounded bg-gray-200' /> - </div> - - {/* Star and Use button skeleton */} - <div className='flex flex-shrink-0 items-center gap-3'> - <div className='h-4 w-4 animate-pulse rounded bg-gray-200' /> - <div className='h-6 w-10 animate-pulse rounded-md bg-gray-200' /> - </div> - </div> - - {/* Description skeleton */} - <div className='space-y-1.5'> - <div className='h-3 w-full animate-pulse rounded bg-gray-200' /> - <div className='h-3 w-4/5 animate-pulse rounded bg-gray-200' /> - <div className='h-3 w-3/5 animate-pulse rounded bg-gray-200' /> - </div> - </div> - - {/* Bottom section skeleton */} - <div className='flex min-w-0 items-center gap-1.5 pt-1.5'> - <div className='h-3 w-6 animate-pulse rounded bg-gray-200' /> - <div className='h-3 w-16 animate-pulse rounded bg-gray-200' /> - <div className='h-2 w-1 animate-pulse rounded bg-gray-200' /> - <div className='h-3 w-3 animate-pulse rounded bg-gray-200' /> - <div className='h-3 w-8 animate-pulse rounded bg-gray-200' /> - {/* Stars section - hidden on smaller screens */} - <div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'> - <div className='h-2 w-1 animate-pulse rounded bg-gray-200' /> - <div className='h-3 w-3 animate-pulse rounded bg-gray-200' /> - <div className='h-3 w-6 animate-pulse rounded bg-gray-200' /> - </div> - </div> - </div> - - {/* Right side - Block Icons skeleton */} - <div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'> - {Array.from({ length: 3 }).map((_, index) => ( - <div - key={index} - className='animate-pulse rounded bg-gray-200' - style={{ width: '30px', height: '30px' }} - /> - ))} - </div> - </div> - ) -} - -// Utility function to extract block types from workflow state -const extractBlockTypesFromState = (state?: { - blocks?: Record<string, { type: string; name?: string }> -}): string[] => { - if (!state?.blocks) return [] - - // Get unique block types from the state, excluding trigger blocks - // Sort the keys to ensure consistent ordering between server and client - const blockTypes = Object.keys(state.blocks) - .sort() // Sort keys to ensure consistent order - .map((key) => state.blocks![key].type) - .filter((type) => { - const block = getBlock(type) - return block?.category !== 'triggers' - }) - return [...new Set(blockTypes)] -} - -// Utility function to get icon component from string or return the component directly -const getIconComponent = (icon: React.ReactNode | string | undefined): React.ReactNode => { - if (typeof icon === 'string') { - const IconComponent = iconMap[icon as keyof typeof iconMap] - return IconComponent ? <IconComponent /> : <FileText /> - } - if (icon) { - return icon - } - // Default fallback icon - return <FileText /> -} - -// Utility function to get the full block config for colored icon display -const getBlockConfig = (blockType: string) => { - const block = getBlock(blockType) - return block -} - -export function TemplateCard({ - id, - title, - description, - author, - usageCount, - stars = 0, - icon, - iconColor = 'bg-blue-500', - blocks = [], - onClick, - className, - state, - isStarred = false, - onTemplateUsed, - onStarChange, -}: TemplateCardProps) { - const t = useTranslations('workspace.templates') - const router = useRouter() - const params = useParams() - - // Local state for optimistic updates - const [localIsStarred, setLocalIsStarred] = useState(isStarred) - const [localStarCount, setLocalStarCount] = useState(stars) - const [isStarLoading, setIsStarLoading] = useState(false) - - // Extract block types from state if provided, otherwise use the blocks prop - // Filter out trigger blocks in both cases and sort for consistent rendering - const blockTypes = state - ? extractBlockTypesFromState(state) - : blocks - .filter((blockType) => { - const block = getBlock(blockType) - return block?.category !== 'triggers' - }) - .sort() - - // Get the icon component - const iconComponent = getIconComponent(icon) - - // Handle star toggle with optimistic updates - const handleStarClick = async (e: React.MouseEvent) => { - e.stopPropagation() - - // Prevent multiple clicks while loading - if (isStarLoading) return - - setIsStarLoading(true) - - // Optimistic update - update UI immediately - const newIsStarred = !localIsStarred - const newStarCount = newIsStarred ? localStarCount + 1 : localStarCount - 1 - - setLocalIsStarred(newIsStarred) - setLocalStarCount(newStarCount) - - // Notify parent component immediately for optimistic update - if (onStarChange) { - onStarChange(id, newIsStarred, newStarCount) - } - - try { - const method = localIsStarred ? 'DELETE' : 'POST' - const response = await fetch(`/api/templates/${id}/star`, { method }) - - if (!response.ok) { - // Rollback on error - setLocalIsStarred(localIsStarred) - setLocalStarCount(localStarCount) - - // Rollback parent state too - if (onStarChange) { - onStarChange(id, localIsStarred, localStarCount) - } - - logger.error('Failed to toggle star:', response.statusText) - } - } catch (error) { - // Rollback on error - setLocalIsStarred(localIsStarred) - setLocalStarCount(localStarCount) - - // Rollback parent state too - if (onStarChange) { - onStarChange(id, localIsStarred, localStarCount) - } - - logger.error('Error toggling star:', error) - } finally { - setIsStarLoading(false) - } - } - - // Handle use template - const handleUseClick = async (e: React.MouseEvent) => { - e.stopPropagation() - try { - const response = await fetch(`/api/templates/${id}/use`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - workspaceId: params.workspaceId, - }), - }) - - if (response.ok) { - const data = await response.json() - logger.info('Template use API response:', data) - - if (!data.workflowId) { - logger.error('No workflowId returned from API:', data) - return - } - - const workflowUrl = `/workspace/${params.workspaceId as string}/dashboard` - logger.info('Template used successfully, navigating to:', workflowUrl) - - // Call the callback if provided (for closing modals, etc.) - if (onTemplateUsed) { - onTemplateUsed() - } - - router.push(workflowUrl) - } else { - const errorText = await response.text() - logger.error('Failed to use template:', response.statusText, errorText) - } - } catch (error) { - logger.error('Error using template:', error) - } - } - - return ( - <div - className={cn( - 'group rounded-sm border bg-card shadow-xs transition-shadow duration-200 hover:border-border/80 hover:shadow-sm', - 'flex h-[142px]', - className - )} - > - {/* Left side - Info */} - <div className='flex min-w-0 flex-1 flex-col justify-between p-4'> - {/* Top section */} - <div className='space-y-2'> - <div className='flex min-w-0 items-center justify-between gap-2.5'> - <div className='flex min-w-0 items-center gap-2.5'> - {/* Icon container */} - <div - className={cn( - 'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-sm', - // Use CSS class if iconColor doesn't start with # - iconColor?.startsWith('#') ? '' : iconColor || 'bg-blue-500' - )} - style={{ - // Use inline style for hex colors - backgroundColor: iconColor?.startsWith('#') ? iconColor : undefined, - }} - > - <div className='h-3 w-3 text-white [&>svg]:h-3 [&>svg]:w-3'>{iconComponent}</div> - </div> - {/* Template name */} - <h3 className='truncate font-medium font-sans text-card-foreground text-sm leading-tight'> - {title} - </h3> - </div> - - {/* Star and Use button */} - <div className='flex flex-shrink-0 items-center gap-3'> - <Star - onClick={handleStarClick} - className={cn( - 'h-4 w-4 cursor-pointer transition-colors duration-50', - localIsStarred - ? 'fill-yellow-400 text-yellow-400' - : 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400', - isStarLoading && 'opacity-50' - )} - /> - <button - onClick={handleUseClick} - className={cn( - 'rounded-sm px-3 py-1 font-medium font-sans text-white text-xs transition-[background-color,box-shadow] duration-200', - 'bg-primary hover:bg-primary-hover', - 'shadow-[0_0_0_0_var(--primary)] ' - )} - > - {t('detail.use')} - </button> - </div> - </div> - - {/* Description */} - <p className='line-clamp-3 break-words font-sans text-muted-foreground text-xs leading-relaxed'> - {description} - </p> - </div> - - {/* Bottom section */} - <div className='flex min-w-0 items-center gap-1.5 pt-1.5 font-sans text-muted-foreground text-xs'> - <span className='flex-shrink-0'>{t('detail.by')}</span> - <span className='min-w-0 truncate'>{author}</span> - <span className='flex-shrink-0'>•</span> - <User className='h-3 w-3 flex-shrink-0' /> - <span className='flex-shrink-0'>{usageCount}</span> - {/* Stars section - hidden on smaller screens when space is constrained */} - <div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'> - <span>•</span> - <Star className='h-3 w-3' /> - <span>{localStarCount}</span> - </div> - </div> - </div> - - {/* Right side - Block Icons */} - <div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'> - {blockTypes.length > 3 ? ( - <> - {/* Show first 2 blocks when there are more than 3 */} - {blockTypes.slice(0, 2).map((blockType, index) => { - const blockConfig = getBlockConfig(blockType) - if (!blockConfig) return null - - return ( - <div key={index} className='flex items-center justify-center'> - <div - className='flex flex-shrink-0 items-center justify-center rounded-sm' - style={{ - backgroundColor: sanitizeSolidIconColor(blockConfig.bgColor) || 'gray', - width: '30px', - height: '30px', - }} - > - <blockConfig.icon className='h-4 w-4 text-white' /> - </div> - </div> - ) - })} - {/* Show +n block for remaining blocks */} - <div className='flex items-center justify-center'> - <div - className='flex flex-shrink-0 items-center justify-center rounded-sm bg-muted-foreground' - style={{ width: '30px', height: '30px' }} - > - <span className='font-medium text-white text-xs'>+{blockTypes.length - 2}</span> - </div> - </div> - </> - ) : ( - /* Show all blocks when 3 or fewer */ - blockTypes.map((blockType, index) => { - const blockConfig = getBlockConfig(blockType) - if (!blockConfig) return null - - return ( - <div key={index} className='flex items-center justify-center'> - <div - className='flex flex-shrink-0 items-center justify-center rounded-sm' - style={{ - backgroundColor: sanitizeSolidIconColor(blockConfig.bgColor) || 'gray', - width: '30px', - height: '30px', - }} - > - <blockConfig.icon className='h-4 w-4 text-white' /> - </div> - </div> - ) - }) - )} - </div> - </div> - ) -} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/templates/templates.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/templates/templates.tsx deleted file mode 100644 index 16c03568d..000000000 --- a/apps/tradinggoose/app/workspace/[workspaceId]/templates/templates.tsx +++ /dev/null @@ -1,389 +0,0 @@ -'use client' - -import { useEffect, useRef, useState } from 'react' -import { ChevronRight, Search } from 'lucide-react' -import { useTranslations } from 'next-intl' -import { Input } from '@/components/ui/input' -import { NavigationTabs } from '@/app/workspace/[workspaceId]/templates/components/navigation-tabs' -import { - TemplateCard, - TemplateCardSkeleton, -} from '@/app/workspace/[workspaceId]/templates/components/template-card' -import type { WorkflowState } from '@/stores/workflows/workflow/types' - -// Shared categories definition -export const categories = [ - { value: 'marketing' }, - { value: 'sales' }, - { value: 'finance' }, - { value: 'support' }, - { value: 'artificial-intelligence' }, - { value: 'other' }, -] as const - -export type CategoryValue = (typeof categories)[number]['value'] - -// Template data structure -export interface Template { - id: string - workflowId: string | null - userId: string - name: string - description: string | null - author: string - views: number - stars: number - color: string - icon: string - category: CategoryValue - state: WorkflowState - createdAt: Date | string - updatedAt: Date | string - isStarred: boolean -} - -interface TemplatesProps { - initialTemplates: Template[] - currentUserId: string -} - -export default function Templates({ initialTemplates, currentUserId }: TemplatesProps) { - const t = useTranslations('workspace.templates') - const [searchQuery, setSearchQuery] = useState('') - const [activeTab, setActiveTab] = useState('your') - const [templates, setTemplates] = useState<Template[]>(initialTemplates) - const [loading, setLoading] = useState(false) - - // Refs for scrolling to sections - const sectionRefs = { - your: useRef<HTMLDivElement>(null), - recent: useRef<HTMLDivElement>(null), - marketing: useRef<HTMLDivElement>(null), - sales: useRef<HTMLDivElement>(null), - finance: useRef<HTMLDivElement>(null), - support: useRef<HTMLDivElement>(null), - 'artificial-intelligence': useRef<HTMLDivElement>(null), - other: useRef<HTMLDivElement>(null), - } - - // Get your templates count (created by user OR starred by user) - const yourTemplatesCount = templates.filter( - (template) => template.userId === currentUserId || template.isStarred === true - ).length - - // Handle case where active tab is "your" but user has no templates - useEffect(() => { - if (!loading && activeTab === 'your' && yourTemplatesCount === 0) { - setActiveTab('recent') // Switch to recent tab - } - }, [loading, activeTab, yourTemplatesCount]) - - const handleTabClick = (tabId: string) => { - setActiveTab(tabId) - const sectionRef = sectionRefs[tabId as keyof typeof sectionRefs] - if (sectionRef.current) { - sectionRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }) - } - } - - // Handle star change callback from template card - const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => { - setTemplates((prevTemplates) => - prevTemplates.map((template) => - template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template - ) - ) - } - - const filteredTemplates = (category: CategoryValue | 'your' | 'recent') => { - let filteredByCategory = templates - - if (category === 'your') { - // For "your" templates, show templates created by you OR starred by you - filteredByCategory = templates.filter( - (template) => template.userId === currentUserId || template.isStarred === true - ) - } else if (category === 'recent') { - // For "recent" templates, show the 8 most recent templates - filteredByCategory = templates - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - .slice(0, 8) - } else { - filteredByCategory = templates.filter((template) => template.category === category) - } - - if (!searchQuery) return filteredByCategory - - return filteredByCategory.filter( - (template) => - template.name.toLowerCase().includes(searchQuery.toLowerCase()) || - template.description?.toLowerCase().includes(searchQuery.toLowerCase()) || - template.author.toLowerCase().includes(searchQuery.toLowerCase()) - ) - } - - // Helper function to render template cards with proper type handling - const renderTemplateCard = (template: Template) => ( - <TemplateCard - key={template.id} - id={template.id} - title={template.name} - description={template.description || ''} - author={template.author} - usageCount={template.views.toString()} - stars={template.stars} - icon={template.icon} - iconColor={template.color} - state={template.state as { blocks?: Record<string, { type: string; name?: string }> }} - isStarred={template.isStarred} - onStarChange={handleStarChange} - /> - ) - - const getCategoryLabel = (category: CategoryValue) => t(`categories.${category}`) - - // Group templates by category for display - const getTemplatesByCategory = (category: CategoryValue | 'your' | 'recent') => { - return filteredTemplates(category) - } - - // Render skeleton cards for loading state - const renderSkeletonCards = () => { - return Array.from({ length: 8 }).map((_, index) => ( - <TemplateCardSkeleton key={`skeleton-${index}`} /> - )) - } - - // Calculate navigation tabs with real counts or skeleton counts - const navigationTabs = [ - // Only include "Your templates" tab if user has created or starred templates - ...(yourTemplatesCount > 0 || loading - ? [ - { - id: 'your', - label: t('sections.your'), - count: loading ? 8 : getTemplatesByCategory('your').length, - }, - ] - : []), - { - id: 'recent', - label: t('sections.recent'), - count: loading ? 8 : getTemplatesByCategory('recent').length, - }, - { - id: 'marketing', - label: getCategoryLabel('marketing'), - count: loading ? 8 : getTemplatesByCategory('marketing').length, - }, - { - id: 'sales', - label: getCategoryLabel('sales'), - count: loading ? 8 : getTemplatesByCategory('sales').length, - }, - { - id: 'finance', - label: getCategoryLabel('finance'), - count: loading ? 8 : getTemplatesByCategory('finance').length, - }, - { - id: 'support', - label: getCategoryLabel('support'), - count: loading ? 8 : getTemplatesByCategory('support').length, - }, - { - id: 'artificial-intelligence', - label: getCategoryLabel('artificial-intelligence'), - count: loading ? 8 : getTemplatesByCategory('artificial-intelligence').length, - }, - { - id: 'other', - label: getCategoryLabel('other'), - count: loading ? 8 : getTemplatesByCategory('other').length, - }, - ] - - return ( - <div className='flex h-[100vh] flex-col '> - <div className='flex flex-1 overflow-hidden'> - <div className='flex flex-1 flex-col overflow-auto p-6'> - {/* Header */} - <div className='mb-6'> - <h1 className='mb-2 font-sans font-semibold text-3xl text-foreground tracking-[0.01em]'> - {t('title')} - </h1> - <p className='whitespace-pre-line font-[350] font-sans text-muted-foreground text-sm leading-[1.5] tracking-[0.01em]'> - {t('description')} - </p> - </div> - - {/* Search and Create New */} - <div className='mb-6 flex items-center justify-between'> - <div className='flex h-9 w-[460px] items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'> - <Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} /> - <Input - placeholder={t('searchPlaceholder')} - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - className='flex-1 border-0 bg-transparent px-0 font-normal font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' - /> - </div> - {/* <Button - onClick={handleCreateNew} - className='flex h-9 items-center gap-2 rounded-lg bg-primary px-4 py-2 font-normal font-sans text-sm text-white hover:bg-[#601EE0]' - > - <Plus className='h-4 w-4' /> - Create New - </Button> */} - </div> - - {/* Navigation */} - <div className='mb-6'> - <NavigationTabs - tabs={navigationTabs} - activeTab={activeTab} - onTabClick={handleTabClick} - /> - </div> - - {/* Your Templates Section */} - {yourTemplatesCount > 0 || loading ? ( - <div ref={sectionRefs.your} className='mb-8'> - <div className='mb-4 flex items-center gap-2'> - <h2 className='font-medium font-sans text-foreground text-lg'> - {t('sections.your')} - </h2> - <ChevronRight className='h-4 w-4 text-muted-foreground' /> - </div> - - <div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'> - {loading - ? renderSkeletonCards() - : getTemplatesByCategory('your').map((template) => renderTemplateCard(template))} - </div> - </div> - ) : null} - - {/* Recent Templates Section */} - <div ref={sectionRefs.recent} className='mb-8'> - <div className='mb-4 flex items-center gap-2'> - <h2 className='font-medium font-sans text-foreground text-lg'> - {t('sections.recent')} - </h2> - <ChevronRight className='h-4 w-4 text-muted-foreground' /> - </div> - - <div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'> - {loading - ? renderSkeletonCards() - : getTemplatesByCategory('recent').map((template) => renderTemplateCard(template))} - </div> - </div> - - {/* Marketing Section */} - <div ref={sectionRefs.marketing} className='mb-8'> - <div className='mb-4 flex items-center gap-2'> - <h2 className='font-medium font-sans text-foreground text-lg'> - {getCategoryLabel('marketing')} - </h2> - <ChevronRight className='h-4 w-4 text-muted-foreground' /> - </div> - - <div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'> - {loading - ? renderSkeletonCards() - : getTemplatesByCategory('marketing').map((template) => - renderTemplateCard(template) - )} - </div> - </div> - - {/* Sales Section */} - <div ref={sectionRefs.sales} className='mb-8'> - <div className='mb-4 flex items-center gap-2'> - <h2 className='font-medium font-sans text-foreground text-lg'> - {getCategoryLabel('sales')} - </h2> - <ChevronRight className='h-4 w-4 text-muted-foreground' /> - </div> - - <div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'> - {loading - ? renderSkeletonCards() - : getTemplatesByCategory('sales').map((template) => renderTemplateCard(template))} - </div> - </div> - - {/* Finance Section */} - <div ref={sectionRefs.finance} className='mb-8'> - <div className='mb-4 flex items-center gap-2'> - <h2 className='font-medium font-sans text-foreground text-lg'> - {getCategoryLabel('finance')} - </h2> - <ChevronRight className='h-4 w-4 text-muted-foreground' /> - </div> - - <div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'> - {loading - ? renderSkeletonCards() - : getTemplatesByCategory('finance').map((template) => renderTemplateCard(template))} - </div> - </div> - - {/* Support Section */} - <div ref={sectionRefs.support} className='mb-8'> - <div className='mb-4 flex items-center gap-2'> - <h2 className='font-medium font-sans text-foreground text-lg'> - {getCategoryLabel('support')} - </h2> - <ChevronRight className='h-4 w-4 text-muted-foreground' /> - </div> - - <div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'> - {loading - ? renderSkeletonCards() - : getTemplatesByCategory('support').map((template) => renderTemplateCard(template))} - </div> - </div> - - {/* Artificial Intelligence Section */} - <div ref={sectionRefs['artificial-intelligence']} className='mb-8'> - <div className='mb-4 flex items-center gap-2'> - <h2 className='font-medium font-sans text-foreground text-lg'> - {getCategoryLabel('artificial-intelligence')} - </h2> - <ChevronRight className='h-4 w-4 text-muted-foreground' /> - </div> - - <div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'> - {loading - ? renderSkeletonCards() - : getTemplatesByCategory('artificial-intelligence').map((template) => - renderTemplateCard(template) - )} - </div> - </div> - - {/* Other Section */} - <div ref={sectionRefs.other} className='mb-8'> - <div className='mb-4 flex items-center gap-2'> - <h2 className='font-medium font-sans text-foreground text-lg'> - {getCategoryLabel('other')} - </h2> - <ChevronRight className='h-4 w-4 text-muted-foreground' /> - </div> - - <div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'> - {loading - ? renderSkeletonCards() - : getTemplatesByCategory('other').map((template) => renderTemplateCard(template))} - </div> - </div> - </div> - </div> - </div> - ) -} diff --git a/apps/tradinggoose/blocks/blocks/workflow.ts b/apps/tradinggoose/blocks/blocks/workflow.ts index d8d647a1e..6d9158799 100644 --- a/apps/tradinggoose/blocks/blocks/workflow.ts +++ b/apps/tradinggoose/blocks/blocks/workflow.ts @@ -1,37 +1,5 @@ import { WorkflowIcon } from '@/components/icons/icons' -import { createLogger } from '@/lib/logs/console/logger' -import type { BlockConfig, BlockOptionLoaderContext } from '@/blocks/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -const logger = createLogger('WorkflowBlock') - -// Helper function to get available workflows for the dropdown -const getAvailableWorkflows = (channelId: string): Array<{ label: string; id: string }> => { - try { - const { workflows } = useWorkflowRegistry.getState() - const activeWorkflowId = useWorkflowRegistry.getState().getActiveWorkflowId(channelId) - - // Filter out the current workflow to prevent recursion - const availableWorkflows = Object.entries(workflows) - .filter(([id]) => id !== activeWorkflowId) - .map(([id, workflow]) => ({ - label: workflow.name || `Workflow ${id.slice(0, 8)}`, - id: id, - })) - .sort((a, b) => a.label.localeCompare(b.label)) - - return availableWorkflows - } catch (error) { - logger.error('Error getting available workflows:', error) - return [] - } -} - -const fetchAvailableWorkflows = async ( - _blockId: string, - _subBlockId: string, - context: BlockOptionLoaderContext -) => getAvailableWorkflows(context.channelId) +import type { BlockConfig } from '@/blocks/types' export const WorkflowBlock: BlockConfig = { type: 'workflow', @@ -46,7 +14,6 @@ export const WorkflowBlock: BlockConfig = { id: 'workflowId', title: 'Select Workflow', type: 'dropdown', - fetchOptions: fetchAvailableWorkflows, required: true, }, { diff --git a/apps/tradinggoose/blocks/blocks/workflow_input.ts b/apps/tradinggoose/blocks/blocks/workflow_input.ts index 49a970627..64593ee05 100644 --- a/apps/tradinggoose/blocks/blocks/workflow_input.ts +++ b/apps/tradinggoose/blocks/blocks/workflow_input.ts @@ -1,26 +1,5 @@ import { WorkflowIcon } from '@/components/icons/icons' -import type { BlockConfig, BlockOptionLoaderContext } from '@/blocks/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -// Helper: list workflows excluding self -const getAvailableWorkflows = (channelId: string): Array<{ label: string; id: string }> => { - try { - const { workflows } = useWorkflowRegistry.getState() - const activeWorkflowId = useWorkflowRegistry.getState().getActiveWorkflowId(channelId) - return Object.entries(workflows) - .filter(([id]) => id !== activeWorkflowId) - .map(([id, w]) => ({ label: w.name || `Workflow ${id.slice(0, 8)}`, id })) - .sort((a, b) => a.label.localeCompare(b.label)) - } catch { - return [] - } -} - -const fetchAvailableWorkflows = async ( - _blockId: string, - _subBlockId: string, - context: BlockOptionLoaderContext -) => getAvailableWorkflows(context.channelId) +import type { BlockConfig } from '@/blocks/types' // New workflow block variant that visualizes child Input Trigger schema for mapping export const WorkflowInputBlock: BlockConfig = { @@ -40,7 +19,6 @@ export const WorkflowInputBlock: BlockConfig = { id: 'workflowId', title: 'Select Workflow', type: 'dropdown', - fetchOptions: fetchAvailableWorkflows, required: true, }, // Renders dynamic mapping UI based on selected child workflow's Input Trigger inputFormat diff --git a/apps/tradinggoose/components/ui/tag-dropdown.test.tsx b/apps/tradinggoose/components/ui/tag-dropdown.test.tsx index 00aa16b85..3c0997aa5 100644 --- a/apps/tradinggoose/components/ui/tag-dropdown.test.tsx +++ b/apps/tradinggoose/components/ui/tag-dropdown.test.tsx @@ -113,10 +113,6 @@ vi.mock('@/lib/get-block', () => ({ }), })) -vi.mock('@/stores/workflows/workflow/store-client', () => ({ - DEFAULT_WORKFLOW_CHANNEL_ID: 'default', -})) - vi.mock('@/stores/workflows/registry/store', () => ({ useWorkflowRegistry: vi.fn((selector?: (state: any) => any) => { const state = { diff --git a/apps/tradinggoose/executor/__test-utils__/mock-dependencies.ts b/apps/tradinggoose/executor/__test-utils__/mock-dependencies.ts index a53e5e4f9..82b0509c9 100644 --- a/apps/tradinggoose/executor/__test-utils__/mock-dependencies.ts +++ b/apps/tradinggoose/executor/__test-utils__/mock-dependencies.ts @@ -20,7 +20,6 @@ vi.mock('@/blocks/index', () => ({ // Tools vi.mock('@/tools/utils', () => ({ getTool: vi.fn(), - getToolAsync: vi.fn(), validateToolRequest: vi.fn(), // Keep for backward compatibility formatRequestParams: vi.fn(), transformTable: vi.fn(), diff --git a/apps/tradinggoose/executor/handlers/agent/agent-handler.test.ts b/apps/tradinggoose/executor/handlers/agent/agent-handler.test.ts index 3416f8c43..584b0cfb4 100644 --- a/apps/tradinggoose/executor/handlers/agent/agent-handler.test.ts +++ b/apps/tradinggoose/executor/handlers/agent/agent-handler.test.ts @@ -31,6 +31,7 @@ vi.mock('@/blocks', () => ({ vi.mock('@/tools', () => ({ executeTool: vi.fn(), + getToolAsync: vi.fn(), })) vi.mock('@/executor/handlers/agent/skills-resolver', () => ({ @@ -188,6 +189,7 @@ describe('AgentBlockHandler', () => { } mockGetProviderFromModel.mockReturnValue('openai') + mockContext.isDeployedContext = false const expectedOutput = { content: 'Mocked response content', @@ -203,6 +205,8 @@ describe('AgentBlockHandler', () => { expect(mockGetProviderFromModel).toHaveBeenCalledWith('gpt-4o') expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.any(Object)) + const [, init] = mockFetch.mock.calls.find(([url]) => String(url).includes('/api/providers'))! + expect(JSON.parse(String(init.body)).isDeployedContext).toBe(false) expect(result).toEqual(expectedOutput) }) @@ -310,6 +314,29 @@ describe('AgentBlockHandler', () => { expect(systemMessage?.content).toContain('tradinggoose_internal_load_skill_2') }) + it('executes without a skill loader when selected skills are unavailable', async () => { + mockResolveSkillMetadata.mockResolvedValueOnce([]) + + await handler.execute( + mockBlock, + { + model: 'gpt-4o', + userPrompt: 'Use the selected skill.', + apiKey: 'test-api-key', + skills: [{ skillId: 'deleted-skill' }], + }, + mockContext + ) + + const [, init] = mockFetch.mock.calls.find(([url]) => String(url).includes('/api/providers'))! + const providerRequest = JSON.parse(String(init.body)) + + expect(providerRequest.tools).toEqual([]) + expect(providerRequest.messages).toEqual([ + { role: 'user', content: 'Use the selected skill.' }, + ]) + }) + it('should preserve executeFunction for custom tools with different usageControl settings', async () => { let capturedTools: any[] = [] @@ -702,6 +729,83 @@ describe('AgentBlockHandler', () => { expect(result).toEqual(expectedOutput) }) + it('skips selected tools that cannot be hydrated', async () => { + const inputs = { + model: 'gpt-4o', + userPrompt: 'Analyze this data.', + apiKey: 'test-api-key', + tools: [ + { + id: 'block_tool_1', + title: 'Data Analysis Tool', + operation: 'analyze', + }, + { + id: 'missing_tool', + title: 'Missing Tool', + operation: 'analyze', + }, + ], + } + + mockTransformBlockTool.mockImplementation((tool: any) => + tool.id === 'block_tool_1' + ? { + id: 'transformed_block_tool_1', + name: 'block_tool_1_analyze', + description: 'Transformed tool', + parameters: { type: 'object', properties: {} }, + } + : null + ) + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(mockBlock, inputs, mockContext) + + expect(mockTransformBlockTool).toHaveBeenCalledWith( + inputs.tools[0], + expect.objectContaining({ selectedOperation: 'analyze' }) + ) + expect(mockTransformBlockTool).toHaveBeenCalledWith( + inputs.tools[1], + expect.objectContaining({ selectedOperation: 'analyze' }) + ) + const [, init] = mockFetch.mock.calls.find(([url]) => String(url).includes('/api/providers'))! + const providerRequest = JSON.parse(String(init.body)) + + expect(providerRequest.tools).toHaveLength(1) + expect(providerRequest.tools[0].id).toBe('transformed_block_tool_1') + }) + + it('skips unavailable MCP tool selections before provider startup', async () => { + const inputs = { + model: 'gpt-4o', + userPrompt: 'Use the MCP tool if available.', + apiKey: 'test-api-key', + tools: [ + { + type: 'mcp', + title: 'Read Files', + params: { + serverId: 'deleted-server', + toolName: 'read_file', + }, + }, + ], + } + + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(mockBlock, inputs, mockContext) + const calledUrls = mockFetch.mock.calls.map(([url]) => String(url)) + const [, init] = mockFetch.mock.calls.find(([url]) => String(url).includes('/api/providers'))! + const providerRequest = JSON.parse(String(init.body)) + + expect(calledUrls.some((url) => url.includes('/api/mcp/tools/discover'))).toBe(true) + expect(calledUrls.some((url) => url.includes('/api/providers'))).toBe(true) + expect(providerRequest.tools).toEqual([]) + }) + it('should execute with custom tools (schema only and with code)', async () => { const inputs = { model: 'gpt-4o', diff --git a/apps/tradinggoose/executor/handlers/agent/agent-handler.ts b/apps/tradinggoose/executor/handlers/agent/agent-handler.ts index 44dd3c8a0..2b5cdae76 100644 --- a/apps/tradinggoose/executor/handlers/agent/agent-handler.ts +++ b/apps/tradinggoose/executor/handlers/agent/agent-handler.ts @@ -18,9 +18,9 @@ import { getBlockToolExecutionId } from '@/executor/handlers/tool-execution-cont import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/executor/types' import { getProviderFromModel, transformBlockTool } from '@/providers/ai/utils' import type { SerializedBlock } from '@/serializer/types' -import { executeTool } from '@/tools' +import { executeTool, getToolAsync } from '@/tools' import { createLLMToolSchema } from '@/tools/params' -import { getTool, getToolAsync } from '@/tools/utils' +import { getTool } from '@/tools/utils' import { buildLoadSkillTool, buildSkillsSystemPromptSection, @@ -85,7 +85,11 @@ export class AgentBlockHandler implements BlockHandler { : [] const skillMetadata = skillInputs.length > 0 && context.workspaceId - ? await resolveSkillMetadata(skillInputs, context.workspaceId) + ? await resolveSkillMetadata( + skillInputs, + context.workspaceId, + context.isDeployedContext !== false + ) : [] const skillLoaderToolId = skillMetadata.length > 0 @@ -197,19 +201,18 @@ export class AgentBlockHandler implements BlockHandler { if (tool.type === 'mcp') { return await this.createMcpTool(tool, context) } - return this.transformBlockTool(tool, context) + return await this.transformBlockTool(tool, context) } catch (error) { - logger.error(`[AgentHandler] Error creating tool:`, { tool, error }) + logger.warn( + `Skipping unavailable agent tool ${tool.title || tool.toolId || tool.type}:`, + error + ) return null } }) ) - const filteredTools = tools.filter( - (tool): tool is NonNullable<typeof tool> => tool !== null && tool !== undefined - ) - - return filteredTools + return tools.filter((tool): tool is NonNullable<typeof tool> => tool !== null) } private async createCustomTool(tool: ToolInput, context: ExecutionContext): Promise<any> { @@ -274,131 +277,121 @@ export class AgentBlockHandler implements BlockHandler { const { serverId, toolName, ...userProvidedParams } = tool.params || {} if (!serverId || !toolName) { - logger.error('MCP tool missing required parameters:', { serverId, toolName }) - return null + throw new Error('MCP tool selection is missing serverId or toolName') } - try { - const headers: Record<string, string> = { 'Content-Type': 'application/json' } - - if (typeof window === 'undefined') { - try { - const { generateInternalToken } = await import('@/lib/auth/internal') - const internalToken = await generateInternalToken(context.userId) - headers.Authorization = `Bearer ${internalToken}` - } catch (error) { - logger.error(`Failed to generate internal token for MCP tool discovery:`, error) - } - } + const headers: Record<string, string> = { 'Content-Type': 'application/json' } - const url = new URL('/api/mcp/tools/discover', getBaseUrl()) - url.searchParams.set('serverId', serverId) - if (context.workspaceId) { - url.searchParams.set('workspaceId', context.workspaceId) - } else { - throw new Error('workspaceId is required for MCP tool discovery') - } - if (context.workflowId) { - url.searchParams.set('workflowId', context.workflowId) - } else { - throw new Error('workflowId is required for internal JWT authentication') + if (typeof window === 'undefined') { + try { + const { generateInternalToken } = await import('@/lib/auth/internal') + const internalToken = await generateInternalToken(context.userId) + headers.Authorization = `Bearer ${internalToken}` + } catch (error) { + logger.error(`Failed to generate internal token for MCP tool discovery:`, error) } + } - const response = await fetch(url.toString(), { - method: 'GET', - headers, - }) - if (!response.ok) { - const errorText = await response.text().catch(() => '') - logger.warn( - `Failed to discover tools from server ${serverId} (status ${response.status})`, - { errorText } - ) - return null - } + const url = new URL('/api/mcp/tools/discover', getBaseUrl()) + url.searchParams.set('serverId', serverId) + if (context.workspaceId) { + url.searchParams.set('workspaceId', context.workspaceId) + } else { + throw new Error('workspaceId is required for MCP tool discovery') + } + if (context.workflowId) { + url.searchParams.set('workflowId', context.workflowId) + } else { + throw new Error('workflowId is required for internal JWT authentication') + } + url.searchParams.set('isDeployedContext', String(context.isDeployedContext !== false)) - const data = await response.json() - if (!data.success) { - logger.warn(`MCP discovery returned unsuccessful for ${serverId}`, { - error: data.error, - }) - return null - } + const response = await fetch(url.toString(), { + method: 'GET', + headers, + }) + if (!response.ok) { + const errorText = await response.text().catch(() => '') + throw new Error( + `Failed to discover tools from MCP server ${serverId} (status ${response.status})${errorText ? `: ${errorText}` : ''}` + ) + } - const mcpTool = data.data.tools.find((t: any) => t.name === toolName) - if (!mcpTool) { - logger.warn(`MCP tool ${toolName} not found on server ${serverId}`) - return null - } + const data = await response.json() + if (!data.success) { + throw new Error(data.error || `MCP tool discovery failed for server ${serverId}`) + } - const toolId = createMcpToolId(serverId, toolName) + const mcpTool = data.data.tools.find((t: any) => t.name === toolName) + if (!mcpTool) { + throw new Error(`MCP tool ${toolName} not found on server ${serverId}`) + } - const { filterSchemaForLLM } = await import('@/tools/params') - const filteredSchema = filterSchemaForLLM( - mcpTool.inputSchema || { type: 'object', properties: {} }, - userProvidedParams - ) + const toolId = createMcpToolId(serverId, toolName) - return { - id: toolId, - name: toolName, - description: mcpTool.description || `MCP tool ${toolName} from ${mcpTool.serverName}`, - parameters: filteredSchema, - params: userProvidedParams, - usageControl: tool.usageControl || 'auto', - executeFunction: async (callParams: Record<string, any>) => { - logger.info(`Executing MCP tool ${toolName} on server ${serverId}`) - - const headers: Record<string, string> = { 'Content-Type': 'application/json' } - - if (typeof window === 'undefined') { - try { - const { generateInternalToken } = await import('@/lib/auth/internal') - const internalToken = await generateInternalToken(context.userId) - headers.Authorization = `Bearer ${internalToken}` - } catch (error) { - logger.error(`Failed to generate internal token for MCP tool ${toolName}:`, error) - } - } + const { filterSchemaForLLM } = await import('@/tools/params') + const filteredSchema = filterSchemaForLLM( + mcpTool.inputSchema || { type: 'object', properties: {} }, + userProvidedParams + ) - const execResponse = await fetch(`${getBaseUrl()}/api/mcp/tools/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - serverId, - toolName, - arguments: callParams, - workspaceId: context.workspaceId, - workflowId: context.workflowId, - }), - }) - - if (!execResponse.ok) { - throw new Error( - `MCP tool execution failed: ${execResponse.status} ${execResponse.statusText}` - ) - } + return { + id: toolId, + name: toolName, + description: mcpTool.description || `MCP tool ${toolName} from ${mcpTool.serverName}`, + parameters: filteredSchema, + params: userProvidedParams, + usageControl: tool.usageControl || 'auto', + executeFunction: async (callParams: Record<string, any>) => { + logger.info(`Executing MCP tool ${toolName} on server ${serverId}`) - const result = await execResponse.json() - if (!result.success) { - throw new Error(result.error || 'MCP tool execution failed') - } + const headers: Record<string, string> = { 'Content-Type': 'application/json' } - return { - success: true, - output: result.data.output || {}, - metadata: { - source: 'mcp', - serverId, - serverName: mcpTool.serverName, - toolName, - }, + if (typeof window === 'undefined') { + try { + const { generateInternalToken } = await import('@/lib/auth/internal') + const internalToken = await generateInternalToken(context.userId) + headers.Authorization = `Bearer ${internalToken}` + } catch (error) { + logger.error(`Failed to generate internal token for MCP tool ${toolName}:`, error) } - }, - } - } catch (error) { - logger.warn(`Failed to create MCP tool ${toolName} from server ${serverId}:`, error) - return null + } + + const execResponse = await fetch(`${getBaseUrl()}/api/mcp/tools/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + serverId, + toolName, + arguments: callParams, + workspaceId: context.workspaceId, + workflowId: context.workflowId, + isDeployedContext: context.isDeployedContext !== false, + }), + }) + + if (!execResponse.ok) { + throw new Error( + `MCP tool execution failed: ${execResponse.status} ${execResponse.statusText}` + ) + } + + const result = await execResponse.json() + if (!result.success) { + throw new Error(result.error || 'MCP tool execution failed') + } + + return { + success: true, + output: result.data.output || {}, + metadata: { + source: 'mcp', + serverId, + serverName: mcpTool.serverName, + toolName, + }, + } + }, } } @@ -407,14 +400,20 @@ export class AgentBlockHandler implements BlockHandler { selectedOperation: tool.operation, getAllBlocks, getToolAsync: (toolId: string) => - getToolAsync(toolId, context.workflowId, context.workspaceId, context.userId), + getToolAsync( + toolId, + context.workflowId, + context.workspaceId, + context.isDeployedContext !== false + ), getTool, createLLMToolSchema, }) - if (transformedTool) { - transformedTool.usageControl = tool.usageControl || 'auto' + if (!transformedTool) { + throw new Error(`Agent tool ${tool.title || tool.toolId || tool.type} could not be resolved`) } + transformedTool.usageControl = tool.usageControl || 'auto' return transformedTool } @@ -608,6 +607,7 @@ export class AgentBlockHandler implements BlockHandler { workflowVariables: context.workflowVariables || {}, blockData, blockNameMapping, + isDeployedContext: context.isDeployedContext !== false, reasoningEffort: inputs.reasoningEffort, verbosity: inputs.verbosity, } diff --git a/apps/tradinggoose/executor/handlers/agent/skills-resolver.test.ts b/apps/tradinggoose/executor/handlers/agent/skills-resolver.test.ts new file mode 100644 index 000000000..c0b3f90ea --- /dev/null +++ b/apps/tradinggoose/executor/handlers/agent/skills-resolver.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveSkillMetadata } from './skills-resolver' + +const readSavedEntityFieldsForExecutionMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ + readSavedEntityFieldsForExecution: readSavedEntityFieldsForExecutionMock, +})) + +describe('resolveSkillMetadata', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('skips unavailable selected skills without aborting agent startup', async () => { + readSavedEntityFieldsForExecutionMock.mockImplementation( + async (_entityKind, skillId: string) => { + if (skillId === 'deleted-skill') { + const error = new Error('Saved skill deleted-skill was not found') + Object.assign(error, { status: 404 }) + throw error + } + + return { + name: 'Market Research', + description: 'Research market setup before acting', + } + } + ) + + await expect( + resolveSkillMetadata( + [{ skillId: 'skill-1' }, { skillId: 'deleted-skill' }], + 'workspace-1', + true + ) + ).resolves.toEqual([ + { + id: 'skill-1', + name: 'Market Research', + description: 'Research market setup before acting', + }, + ]) + }) +}) diff --git a/apps/tradinggoose/executor/handlers/agent/skills-resolver.ts b/apps/tradinggoose/executor/handlers/agent/skills-resolver.ts index 4367a3dc7..ff690fb9e 100644 --- a/apps/tradinggoose/executor/handlers/agent/skills-resolver.ts +++ b/apps/tradinggoose/executor/handlers/agent/skills-resolver.ts @@ -1,13 +1,14 @@ import { createLogger } from '@/lib/logs/console/logger' -import { listSkills } from '@/lib/skills/operations' +import { readSavedEntityFieldsForExecution } from '@/lib/yjs/server/bootstrap-review-target' import type { SkillInput } from '@/executor/handlers/agent/types' import type { SkillMetadata } from './skill-loader' -const logger = createLogger('SkillsResolver') +const logger = createLogger('AgentSkillsResolver') export async function resolveSkillMetadata( skillInputs: SkillInput[], - workspaceId: string + workspaceId: string, + isDeployedContext: boolean ): Promise<SkillMetadata[]> { const skillIds = skillInputs .map((skillInput) => skillInput.skillId) @@ -17,38 +18,39 @@ export async function resolveSkillMetadata( return [] } - try { - const skills = await listSkills({ workspaceId }) - const selectedSkillIds = new Set(skillIds) - return skills - .filter((skill) => selectedSkillIds.has(skill.id)) - .map((skill) => ({ id: skill.id, name: skill.name, description: skill.description })) - } catch (error) { - logger.error('Failed to resolve skill metadata', { error, skillIds, workspaceId }) + const results = await Promise.allSettled( + skillIds.map(async (skillId) => { + const fields = await readSavedEntityFieldsForExecution( + 'skill', + skillId, + workspaceId, + isDeployedContext + ) + return { + id: skillId, + name: String(fields.name ?? ''), + description: String(fields.description ?? ''), + } + }) + ) + + return results.flatMap((result, index) => { + if (result.status === 'fulfilled') return [result.value] + logger.warn(`Skipping unavailable agent skill ${skillIds[index]}:`, result.reason) return [] - } + }) } export async function resolveSkillContent( skillId: string, - workspaceId: string -): Promise<string | null> { - if (!skillId || !workspaceId) { - return null - } - - try { - const rows = await listSkills({ workspaceId }) - const skill = rows.find((row) => row.id === skillId) - - if (!skill) { - logger.warn('Skill not found', { skillId, workspaceId }) - return null - } - - return skill.content - } catch (error) { - logger.error('Failed to resolve skill content', { error, skillId, workspaceId }) - return null - } + workspaceId: string, + isDeployedContext: boolean +): Promise<string> { + const fields = await readSavedEntityFieldsForExecution( + 'skill', + skillId, + workspaceId, + isDeployedContext + ) + return String(fields.content ?? '') } diff --git a/apps/tradinggoose/hooks/queries/skills.test.tsx b/apps/tradinggoose/hooks/queries/skills.test.tsx deleted file mode 100644 index 2141472b3..000000000 --- a/apps/tradinggoose/hooks/queries/skills.test.tsx +++ /dev/null @@ -1,211 +0,0 @@ -/** - * @vitest-environment jsdom - */ - -import { act } from 'react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useSkills } from '@/hooks/queries/skills' -import { useSkillsStore } from '@/stores/skills/store' - -interface ApiSkill { - id: string - name: string - description: string - content: string - workspaceId?: string - userId?: string | null - createdAt?: string - updatedAt?: string -} - -interface PendingRequest { - resolve: (response: { ok: boolean; json: () => Promise<{ data: ApiSkill[] }> }) => void -} - -function SkillsHarness({ workspaceId }: { workspaceId: string }) { - useSkills(workspaceId) - return null -} - -function createSkill(overrides: Partial<ApiSkill>): ApiSkill { - return { - id: 'skill-1', - name: 'market-research', - description: 'Research the market before taking action.', - content: 'Investigate the market and summarize the setup.', - ...overrides, - } -} - -describe('useSkills', () => { - let container: HTMLDivElement - let root: Root - let queryClient: QueryClient - const pendingRequests = new Map<string, PendingRequest[]>() - - const flushAsyncWork = async () => { - await Promise.resolve() - await Promise.resolve() - await new Promise((resolve) => setTimeout(resolve, 0)) - } - - const waitFor = async (predicate: () => boolean, timeoutMs = 1000) => { - const startTime = Date.now() - - while (!predicate()) { - if (Date.now() - startTime > timeoutMs) { - throw new Error('Timed out waiting for condition') - } - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)) - }) - } - } - - const renderHarness = async (workspaceId: string) => { - act(() => { - root.render( - <QueryClientProvider client={queryClient}> - <SkillsHarness workspaceId={workspaceId} /> - </QueryClientProvider> - ) - }) - - await act(async () => { - await flushAsyncWork() - }) - } - - const resolveWorkspaceRequest = async (workspaceId: string, data: ApiSkill[]) => { - const queue = pendingRequests.get(workspaceId) - const nextRequest = queue?.shift() - - if (!nextRequest) { - throw new Error(`No pending request for workspace ${workspaceId}`) - } - - if (queue && queue.length === 0) { - pendingRequests.delete(workspaceId) - } - - await act(async () => { - nextRequest.resolve({ - ok: true, - json: async () => ({ data }), - }) - await flushAsyncWork() - }) - } - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - gcTime: 0, - }, - }, - }) - - useSkillsStore.getState().resetAll() - pendingRequests.clear() - ;( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true - - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - - global.fetch = vi.fn((input) => { - const rawUrl = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url - const url = new URL(rawUrl, 'http://localhost') - const workspaceId = url.searchParams.get('workspaceId') - - if (!workspaceId) { - throw new Error('Missing workspaceId in fetch mock') - } - - return new Promise((resolve) => { - const queue = pendingRequests.get(workspaceId) ?? [] - queue.push({ - resolve: resolve as PendingRequest['resolve'], - }) - pendingRequests.set(workspaceId, queue) - }) - }) as typeof fetch - }) - - afterEach(() => { - act(() => { - root.unmount() - }) - queryClient.clear() - container.remove() - useSkillsStore.getState().resetAll() - vi.restoreAllMocks() - }) - - it('does not sync previous-workspace data into the next workspace bucket while fetching', async () => { - await renderHarness('workspace-a') - await resolveWorkspaceRequest('workspace-a', [ - createSkill({ - id: 'skill-a', - name: 'alpha', - description: 'Alpha skill', - content: 'Alpha instructions', - }), - ]) - - await waitFor(() => useSkillsStore.getState().getAllSkills('workspace-a').length === 1) - - await renderHarness('workspace-b') - - expect(useSkillsStore.getState().getAllSkills('workspace-b')).toEqual([]) - - await resolveWorkspaceRequest('workspace-b', [ - createSkill({ - id: 'skill-b', - name: 'beta', - description: 'Beta skill', - content: 'Beta instructions', - }), - ]) - - await waitFor(() => useSkillsStore.getState().getAllSkills('workspace-b').length === 1) - - expect( - useSkillsStore - .getState() - .getAllSkills('workspace-b') - .map((skill) => skill.name) - ).toEqual(['beta']) - }) - - it('syncs the new workspace result even when the skill payload matches the previous workspace', async () => { - const sharedSkill = createSkill({ - id: 'shared-skill', - name: 'shared-skill', - description: 'Shared skill', - content: 'Shared instructions', - }) - - await renderHarness('workspace-a') - await resolveWorkspaceRequest('workspace-a', [sharedSkill]) - - await renderHarness('workspace-b') - - await resolveWorkspaceRequest('workspace-b', [sharedSkill]) - - await waitFor(() => useSkillsStore.getState().getAllSkills('workspace-b').length === 1) - - const workspaceBSkills = useSkillsStore.getState().getAllSkills('workspace-b') - expect(workspaceBSkills).toHaveLength(1) - expect(workspaceBSkills[0]?.workspaceId).toBe('workspace-b') - expect(workspaceBSkills[0]?.name).toBe('shared-skill') - }) -}) diff --git a/apps/tradinggoose/hooks/queries/skills.ts b/apps/tradinggoose/hooks/queries/skills.ts index 797e23bfc..e38b01386 100644 --- a/apps/tradinggoose/hooks/queries/skills.ts +++ b/apps/tradinggoose/hooks/queries/skills.ts @@ -1,9 +1,7 @@ -import { useEffect, useRef } from 'react' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { createLogger } from '@/lib/logs/console/logger' import { type ImportedSkillTransferRecord, SKILL_NAME_MAX_LENGTH } from '@/lib/skills/import-export' -import { useSkillsStore } from '@/stores/skills/store' -import type { SkillDefinition } from '@/stores/skills/types' +import type { SkillDefinition } from '@/lib/skills/types' const logger = createLogger('SkillsQueries') const API_ENDPOINT = '/api/skills' @@ -46,12 +44,9 @@ function normalizeSkill( } } -function syncSkillsToStore(workspaceId: string, skills: SkillDefinition[]) { - useSkillsStore.getState().setSkills(workspaceId, skills) -} - -async function fetchSkills(workspaceId: string): Promise<SkillDefinition[]> { - const response = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`) +export async function fetchSkills(workspaceId: string): Promise<SkillDefinition[]> { + const params = new URLSearchParams({ workspaceId }) + const response = await fetch(`${API_ENDPOINT}?${params}`) if (!response.ok) { const errorData = await response.json().catch(() => ({})) @@ -97,43 +92,6 @@ async function fetchSkills(workspaceId: string): Promise<SkillDefinition[]> { return normalizedSkills } -export function useSkills(workspaceId: string) { - const query = useQuery<SkillDefinition[]>({ - queryKey: skillsKeys.list(workspaceId), - queryFn: () => fetchSkills(workspaceId), - enabled: !!workspaceId, - staleTime: 60 * 1000, - }) - - const lastSyncRef = useRef<string>('') - - useEffect(() => { - lastSyncRef.current = '' - }, [workspaceId]) - - useEffect(() => { - if (!workspaceId || !query.data) return - - const signature = query.data - .map((skill) => { - const updatedAt = - typeof skill.updatedAt === 'string' ? skill.updatedAt : (skill.createdAt ?? '') - return `${skill.id}:${updatedAt}:${skill.name}:${skill.description}:${skill.content}` - }) - .join('|') - const syncKey = `${workspaceId}:${signature}` - - if (syncKey === lastSyncRef.current) { - return - } - - lastSyncRef.current = syncKey - syncSkillsToStore(workspaceId, query.data) - }, [query.data, workspaceId]) - - return query -} - interface CreateSkillParams { workspaceId: string skill: { diff --git a/apps/tradinggoose/hooks/queries/templates.ts b/apps/tradinggoose/hooks/queries/templates.ts deleted file mode 100644 index 1b3824a24..000000000 --- a/apps/tradinggoose/hooks/queries/templates.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { createLogger } from '@/lib/logs/console/logger' - -const logger = createLogger('TemplateQueries') - -export const templateKeys = { - all: ['templates'] as const, - lists: () => [...templateKeys.all, 'list'] as const, - list: (filters?: TemplateListFilters) => [...templateKeys.lists(), filters ?? {}] as const, - details: () => [...templateKeys.all, 'detail'] as const, - detail: (templateId?: string) => [...templateKeys.details(), templateId ?? ''] as const, - byWorkflow: (workflowId?: string) => - [...templateKeys.all, 'byWorkflow', workflowId ?? ''] as const, -} - -export interface TemplateListFilters { - search?: string - status?: 'pending' | 'approved' | 'rejected' - workflowId?: string - limit?: number - offset?: number - includeAllStatuses?: boolean -} - -export interface TemplateCreator { - id: string - name: string - referenceType: 'user' | 'organization' - referenceId: string - email?: string - website?: string - profileImageUrl?: string | null - details?: { - about?: string - xUrl?: string - linkedinUrl?: string - websiteUrl?: string - contactEmail?: string - } | null - createdAt: string - updatedAt: string -} - -export interface Template { - id: string - workflowId: string - name: string - details?: { - tagline?: string - about?: string - } - creatorId?: string - creator?: TemplateCreator - views: number - stars: number - status: 'pending' | 'approved' | 'rejected' - tags: string[] - requiredCredentials: Record<string, any> - state: any - createdAt: string - updatedAt: string - isStarred?: boolean - isSuperUser?: boolean -} - -export interface TemplatesResponse { - data: Template[] - pagination: { - total: number - limit: number - offset: number - page: number - totalPages: number - } -} - -export interface TemplateDetailResponse { - data: Template -} - -export interface CreateTemplateInput { - workflowId: string - name: string - details?: { - tagline?: string - about?: string - } - creatorId?: string - tags?: string[] -} - -export interface UpdateTemplateInput { - name?: string - details?: { - tagline?: string - about?: string - } - creatorId?: string - tags?: string[] - updateState?: boolean -} - -async function fetchTemplates(filters?: TemplateListFilters): Promise<TemplatesResponse> { - const params = new URLSearchParams() - - if (filters?.search) params.set('search', filters.search) - if (filters?.status) params.set('status', filters.status) - if (filters?.workflowId) params.set('workflowId', filters.workflowId) - if (filters?.includeAllStatuses) params.set('includeAllStatuses', 'true') - params.set('limit', (filters?.limit ?? 50).toString()) - params.set('offset', (filters?.offset ?? 0).toString()) - - const response = await fetch(`/api/templates?${params.toString()}`) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || 'Failed to fetch templates') - } - - return response.json() -} - -async function fetchTemplate(templateId: string): Promise<TemplateDetailResponse> { - const response = await fetch(`/api/templates/${templateId}`) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || 'Failed to fetch template') - } - - return response.json() -} - -async function fetchTemplateByWorkflow(workflowId: string): Promise<Template | null> { - const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || 'Failed to fetch template') - } - - const result: TemplatesResponse = await response.json() - return result.data?.[0] || null -} - -export function useTemplates( - filters?: TemplateListFilters, - options?: { - enabled?: boolean - } -) { - return useQuery({ - queryKey: templateKeys.list(filters), - queryFn: () => fetchTemplates(filters), - enabled: options?.enabled ?? true, - staleTime: 5 * 60 * 1000, // 5 minutes - templates don't change frequently - placeholderData: keepPreviousData, - }) -} - -export function useTemplate( - templateId?: string, - options?: { - enabled?: boolean - } -) { - return useQuery({ - queryKey: templateKeys.detail(templateId), - queryFn: () => fetchTemplate(templateId as string), - enabled: (options?.enabled ?? true) && Boolean(templateId), - staleTime: 10 * 60 * 1000, // 10 minutes - individual templates are fairly static - select: (data) => data.data, - }) -} - -export function useTemplateByWorkflow( - workflowId?: string, - options?: { - enabled?: boolean - } -) { - return useQuery({ - queryKey: templateKeys.byWorkflow(workflowId), - queryFn: () => fetchTemplateByWorkflow(workflowId as string), - enabled: (options?.enabled ?? true) && Boolean(workflowId), - staleTime: 5 * 60 * 1000, // 5 minutes - }) -} - -export function useCreateTemplate() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async (data: CreateTemplateInput) => { - const response = await fetch('/api/templates', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || 'Failed to create template') - } - - return response.json() - }, - onSuccess: (_, variables) => { - queryClient.invalidateQueries({ queryKey: templateKeys.lists() }) - queryClient.invalidateQueries({ queryKey: templateKeys.byWorkflow(variables.workflowId) }) - logger.info('Template created successfully') - }, - onError: (error) => { - logger.error('Failed to create template', error) - }, - }) -} - -export function useUpdateTemplate() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ({ id, data }: { id: string; data: UpdateTemplateInput }) => { - const response = await fetch(`/api/templates/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || 'Failed to update template') - } - - return response.json() - }, - onMutate: async ({ id, data }) => { - await queryClient.cancelQueries({ queryKey: templateKeys.detail(id) }) - - const previousTemplate = queryClient.getQueryData<TemplateDetailResponse>( - templateKeys.detail(id) - ) - - if (previousTemplate) { - queryClient.setQueryData<TemplateDetailResponse>(templateKeys.detail(id), { - ...previousTemplate, - data: { - ...previousTemplate.data, - ...data, - updatedAt: new Date().toISOString(), - }, - }) - } - - return { previousTemplate } - }, - onError: (error, { id }, context) => { - if (context?.previousTemplate) { - queryClient.setQueryData(templateKeys.detail(id), context.previousTemplate) - } - logger.error('Failed to update template', error) - }, - onSuccess: (result, { id }) => { - queryClient.setQueryData<TemplateDetailResponse>(templateKeys.detail(id), result) - - queryClient.invalidateQueries({ queryKey: templateKeys.lists() }) - - if (result.data?.workflowId) { - queryClient.invalidateQueries({ - queryKey: templateKeys.byWorkflow(result.data.workflowId), - }) - } - - logger.info('Template updated successfully') - }, - }) -} - -export function useDeleteTemplate() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async (templateId: string) => { - const response = await fetch(`/api/templates/${templateId}`, { - method: 'DELETE', - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || 'Failed to delete template') - } - - return response.json() - }, - onSuccess: (_, templateId) => { - queryClient.removeQueries({ queryKey: templateKeys.detail(templateId) }) - - queryClient.invalidateQueries({ queryKey: templateKeys.lists() }) - - queryClient.invalidateQueries({ - queryKey: [...templateKeys.all, 'byWorkflow'], - exact: false, - }) - - logger.info('Template deleted successfully') - }, - onError: (error) => { - logger.error('Failed to delete template', error) - }, - }) -} - -export function useStarTemplate() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ({ - templateId, - action, - }: { - templateId: string - action: 'add' | 'remove' - }) => { - const method = action === 'add' ? 'POST' : 'DELETE' - const response = await fetch(`/api/templates/${templateId}/star`, { method }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || 'Failed to toggle star') - } - - return response.json() - }, - onMutate: async ({ templateId, action }) => { - await queryClient.cancelQueries({ queryKey: templateKeys.detail(templateId) }) - - const previousTemplate = queryClient.getQueryData<TemplateDetailResponse>( - templateKeys.detail(templateId) - ) - - if (previousTemplate) { - const newStarCount = - action === 'add' - ? previousTemplate.data.stars + 1 - : Math.max(0, previousTemplate.data.stars - 1) - - queryClient.setQueryData<TemplateDetailResponse>(templateKeys.detail(templateId), { - ...previousTemplate, - data: { - ...previousTemplate.data, - stars: newStarCount, - isStarred: action === 'add', - }, - }) - } - - const listQueries = queryClient.getQueriesData<TemplatesResponse>({ - queryKey: templateKeys.lists(), - }) - - listQueries.forEach(([key, data]) => { - if (!data) return - queryClient.setQueryData<TemplatesResponse>(key, { - ...data, - data: data.data.map((template) => { - if (template.id === templateId) { - const newStarCount = - action === 'add' ? template.stars + 1 : Math.max(0, template.stars - 1) - return { - ...template, - stars: newStarCount, - isStarred: action === 'add', - } - } - return template - }), - }) - }) - - return { previousTemplate } - }, - onError: (error, { templateId }, context) => { - if (context?.previousTemplate) { - queryClient.setQueryData(templateKeys.detail(templateId), context.previousTemplate) - } - - queryClient.invalidateQueries({ queryKey: templateKeys.lists() }) - - logger.error('Failed to toggle star', error) - }, - onSettled: (_, __, { templateId }) => { - queryClient.invalidateQueries({ queryKey: templateKeys.detail(templateId) }) - queryClient.invalidateQueries({ queryKey: templateKeys.lists() }) - }, - }) -} diff --git a/apps/tradinggoose/hooks/use-mcp-tools.ts b/apps/tradinggoose/hooks/use-mcp-tools.ts index 95b10d27d..e8aed4ed2 100644 --- a/apps/tradinggoose/hooks/use-mcp-tools.ts +++ b/apps/tradinggoose/hooks/use-mcp-tools.ts @@ -6,12 +6,12 @@ */ import type React from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 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 { MCP_TOOLS_CHANGED_EVENT, useMcpServersStore } from '@/stores/mcp-servers/store' +import { createMcpToolId, MCP_TOOLS_CHANGED_EVENT } from '@/lib/mcp/utils' +import { useEntityList } from '@/lib/yjs/use-entity-fields' const logger = createLogger('useMcpTools') const DISCOVERY_CACHE_MS = 5 * 60 * 1000 @@ -39,21 +39,23 @@ export interface UseMcpToolsResult { const discoveryCache = new Map<string, { expiresAt: number; tools: McpToolForUI[] }>() const discoveryRequests = new Map<string, Promise<McpToolForUI[]>>() -async function discoverMcpTools( - workspaceId: string, - serversFingerprint: string, - force: boolean -) { +async function discoverMcpTools(workspaceId: string, serversFingerprint: string, force: boolean) { const cacheKey = `${workspaceId}:${serversFingerprint}` - const pending = discoveryRequests.get(cacheKey) - if (pending) return pending + if (force) { + discoveryCache.delete(cacheKey) + } else { + const pending = discoveryRequests.get(cacheKey) + if (pending) return pending + } const cached = discoveryCache.get(cacheKey) - if (!force && cached && cached.expiresAt > Date.now()) { + if (cached && cached.expiresAt > Date.now()) { return cached.tools } - const request = fetch(`/api/mcp/tools/discover?workspaceId=${encodeURIComponent(workspaceId)}`) + const request = fetch( + `/api/mcp/tools/discover?workspaceId=${encodeURIComponent(workspaceId)}&isDeployedContext=false` + ) .then(async (response) => { if (!response.ok) { throw new Error(`Failed to discover MCP tools: ${response.status} ${response.statusText}`) @@ -76,12 +78,16 @@ async function discoverMcpTools( icon: WrenchIcon, })) - discoveryCache.set(cacheKey, { expiresAt: Date.now() + DISCOVERY_CACHE_MS, tools }) + if (discoveryRequests.get(cacheKey) === request) { + discoveryCache.set(cacheKey, { expiresAt: Date.now() + DISCOVERY_CACHE_MS, tools }) + } logger.info(`Discovered ${tools.length} MCP tools`) return tools }) .finally(() => { - discoveryRequests.delete(cacheKey) + if (discoveryRequests.get(cacheKey) === request) { + discoveryRequests.delete(cacheKey) + } }) discoveryRequests.set(cacheKey, request) @@ -92,27 +98,54 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { const [mcpTools, setMcpTools] = useState<McpToolForUI[]>([]) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState<string | null>(null) + const loadIdRef = useRef(0) const normalizedWorkspaceId = workspaceId.trim() - const servers = useMcpServersStore((state) => state.servers) + const { + members: serverMembers, + isLoading: isServerListLoading, + error: serverListError, + } = useEntityList('mcp_server', normalizedWorkspaceId || null) - // Create a stable server fingerprint const serversFingerprint = useMemo(() => { - return servers - .filter((s) => !s.deletedAt) - .map((s) => `${s.id}:${s.enabled !== false ? '1' : '0'}:${s.updatedAt ?? ''}`) + return serverMembers + .filter((member) => member.enabled !== false) + .map((member) => `${member.entityId}:${member.entityName}:${member.updatedAt ?? ''}`) .sort() .join('|') - }, [servers]) + }, [serverMembers]) const hasEnabledServers = useMemo( - () => servers.some((server) => !server.deletedAt && server.enabled !== false), - [servers] + () => serverMembers.some((member) => member.enabled !== false), + [serverMembers] ) const loadTools = useCallback( async (force = false) => { - if (!normalizedWorkspaceId || !hasEnabledServers) { + const loadId = ++loadIdRef.current + + if (!normalizedWorkspaceId) { + setMcpTools([]) + setError(null) + setIsLoading(false) + return + } + + if (serverListError) { + setMcpTools([]) + setError(serverListError) + setIsLoading(false) + return + } + + if (isServerListLoading) { + setMcpTools([]) + setError(null) + setIsLoading(true) + return + } + + if (!hasEnabledServers) { setMcpTools([]) setError(null) setIsLoading(false) @@ -124,17 +157,28 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { try { logger.info('Discovering MCP tools', { workspaceId: normalizedWorkspaceId }) - setMcpTools(await discoverMcpTools(normalizedWorkspaceId, serversFingerprint, force)) + const tools = await discoverMcpTools(normalizedWorkspaceId, serversFingerprint, force) + if (loadId !== loadIdRef.current) return + setMcpTools(tools) } catch (err) { + if (loadId !== loadIdRef.current) return const errorMessage = err instanceof Error ? err.message : 'Failed to discover MCP tools' logger.error('Error discovering MCP tools:', err) setError(errorMessage) setMcpTools([]) } finally { - setIsLoading(false) + if (loadId === loadIdRef.current) { + setIsLoading(false) + } } }, - [hasEnabledServers, normalizedWorkspaceId, serversFingerprint] + [ + hasEnabledServers, + isServerListLoading, + normalizedWorkspaceId, + serverListError, + serversFingerprint, + ] ) const refreshTools = useCallback(() => loadTools(true), [loadTools]) @@ -176,14 +220,14 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult { const interval = setInterval( () => { if (!isLoading && normalizedWorkspaceId) { - void loadTools() + void loadTools(Boolean(serverListError)) } }, 5 * 60 * 1000 ) return () => clearInterval(interval) - }, [isLoading, loadTools, normalizedWorkspaceId]) + }, [isLoading, loadTools, normalizedWorkspaceId, serverListError]) return { mcpTools, diff --git a/apps/tradinggoose/hooks/workflow/use-workflow-editor-actions.ts b/apps/tradinggoose/hooks/workflow/use-workflow-editor-actions.ts index 26fd090ba..142055798 100644 --- a/apps/tradinggoose/hooks/workflow/use-workflow-editor-actions.ts +++ b/apps/tradinggoose/hooks/workflow/use-workflow-editor-actions.ts @@ -5,10 +5,8 @@ import type { YjsOrigin } from '@/lib/yjs/transaction-origins' import { useWorkflowMutations } from '@/lib/yjs/use-workflow-doc' import { useWorkflowSession } from '@/lib/yjs/workflow-session-host' import { getBlock } from '@/blocks' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { getUniqueBlockName } from '@/stores/workflows/utils' import type { Position } from '@/stores/workflows/workflow/types' -import { DEFAULT_WORKFLOW_CHANNEL_ID } from '@/stores/workflows/workflow/types' import { useOptionalWorkflowRoute } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' const logger = createLogger('WorkflowEditorActions') @@ -18,13 +16,9 @@ const logger = createLogger('WorkflowEditorActions') */ export function useWorkflowEditorActions() { const workflowRoute = useOptionalWorkflowRoute() - const channelId = workflowRoute?.channelId ?? DEFAULT_WORKFLOW_CHANNEL_ID const routeWorkflowId = workflowRoute?.workflowId ?? null - const activeWorkflowId = useWorkflowRegistry( - useCallback((state) => state.getActiveWorkflowId(channelId), [channelId]) - ) - const { readWorkflowSnapshot } = useWorkflowSession() + const { doc, readWorkflowSnapshot } = useWorkflowSession() const mutations = useWorkflowMutations() const getBlocksSnapshot = useCallback(() => { @@ -35,8 +29,7 @@ export function useWorkflowEditorActions() { return readWorkflowSnapshot()?.edges ?? [] }, [readWorkflowSnapshot]) - // Derive connection status from whether we have an active workflow - const isConnectedToWorkflow = !!activeWorkflowId + const isConnectedToWorkflow = Boolean(routeWorkflowId && doc) const collaborativeAddBlock = useCallback( ( @@ -366,7 +359,7 @@ export function useWorkflowEditorActions() { const collaborativeAddVariable = useCallback( (variableData: { name: string; type: any; value: any; workflowId: string }) => { - const workflowId = variableData.workflowId || routeWorkflowId || activeWorkflowId + const workflowId = variableData.workflowId || routeWorkflowId if (!workflowId) { return '' } @@ -376,7 +369,7 @@ export function useWorkflowEditorActions() { workflowId, }) }, - [activeWorkflowId, mutations, routeWorkflowId] + [mutations, routeWorkflowId] ) const collaborativeDeleteVariable = useCallback( diff --git a/apps/tradinggoose/i18n/messages/en.json b/apps/tradinggoose/i18n/messages/en.json index 41ef3b9e8..116216d1f 100644 --- a/apps/tradinggoose/i18n/messages/en.json +++ b/apps/tradinggoose/i18n/messages/en.json @@ -997,46 +997,9 @@ "monitor": "Monitor", "logs": "Logs", "knowledge": "Knowledge", - "templates": "Templates", "docs": "Docs" } }, - "templates": { - "title": "Templates", - "description": "Grab a template and start building,\nor make one from scratch.", - "searchPlaceholder": "Search templates...", - "sections": { - "your": "Your templates", - "recent": "Recent" - }, - "categories": { - "marketing": "Marketing", - "sales": "Sales", - "finance": "Finance", - "support": "Support", - "artificial-intelligence": "Artificial Intelligence", - "other": "Other" - }, - "detail": { - "notFoundTitle": "Template Not Found", - "notFoundDescription": "The template you're looking for doesn't exist.", - "noWorkflowDataTitle": "No Workflow Data", - "noWorkflowDataDescription": "This template doesn't contain workflow state data.", - "previewErrorTitle": "Preview Error", - "previewErrorDescription": "Unable to render workflow preview", - "goBack": "Go back", - "workflowPreview": "Workflow Preview", - "useThisTemplate": "Use this template", - "use": "Use", - "by": "by" - }, - "loginRequired": "Please log in to view templates", - "errorPage": { - "title": "Error Loading Template", - "description": "There was an error loading this template.", - "templateIdLabel": "Template ID:" - } - }, "knowledge": { "title": "Knowledge", "searchPlaceholder": "Search knowledge bases...", @@ -2692,7 +2655,6 @@ "selectWorkspace": "Select a workspace to load workflows.", "noWorkflows": "No workflows available in this workspace.", "unableToLoadWorkflows": "Unable to load workflows", - "authenticationRequiredToLoadWorkflows": "Authentication required to load workflows", "filters": "Filters", "status": "Status", "error": "Error", @@ -2740,7 +2702,6 @@ "selectWorkspace": "Select a workspace to load workflows.", "noWorkflows": "No workflows available in this workspace.", "unableToLoadWorkflows": "Unable to load workflows", - "authenticationRequiredToLoadWorkflows": "Authentication required to load workflows", "selectOutputs": "Select outputs", "selectWorkflowOutputs": "Select workflow outputs", "clearChat": "Clear chat", @@ -2772,7 +2733,6 @@ "selectWorkspace": "Select a workspace to load workflows.", "noWorkflows": "No workflows available in this workspace.", "unableToLoadWorkflows": "Unable to load workflows", - "authenticationRequiredToLoadWorkflows": "Authentication required to load workflows", "addVariable": "Add variable", "selectWorkflowToAddVariables": "Select a workflow to add variables", "addWorkflowVariable": "Add workflow variable", @@ -18527,111 +18487,6 @@ "duplicateDescription": "A workflow can only have one {triggerName} trigger block. Please remove the existing one before adding a new one.", "dismiss": "Got it" }, - "templateModal": { - "title": { - "loading": "Loading...", - "update": "Update Template", - "publish": "Publish Template" - }, - "fields": { - "icon": "Icon", - "color": "Color", - "name": "Name", - "author": "Author", - "category": "Category", - "description": "Description" - }, - "placeholders": { - "name": "Enter template name", - "author": "Enter author name", - "category": "Select a category", - "description": "Describe what this template does..." - }, - "actions": { - "close": "Close", - "delete": "Delete" - }, - "submit": { - "updating": "Updating...", - "publishing": "Publishing...", - "update": "Update Template", - "publish": "Publish Template" - }, - "delete": { - "title": "Delete Template?", - "description": "Deleting this template will remove it from the gallery. This action cannot be undone.", - "cancel": "Cancel", - "confirm": "Delete", - "deleting": "Deleting..." - }, - "validation": { - "nameRequired": "Name is required", - "nameTooLong": "Name must be less than 100 characters", - "descriptionRequired": "Description is required", - "descriptionTooLong": "Description must be less than 500 characters", - "authorRequired": "Author is required", - "authorTooLong": "Author must be less than 100 characters", - "categoryRequired": "Category is required", - "iconRequired": "Icon is required", - "colorInvalid": "Color must be a valid hex color (e.g., #3972F6)" - }, - "errors": { - "workflowNotReady": "Workflow session is not ready yet. Wait for the editor to finish loading.", - "create": "Failed to create template", - "update": "Failed to update template", - "delete": "Failed to delete template" - }, - "categories": { - "marketing": "Marketing", - "sales": "Sales", - "finance": "Finance", - "support": "Support", - "artificialIntelligence": "Artificial Intelligence", - "other": "Other" - }, - "icons": { - "fileText": "File Text", - "notebook": "Notebook", - "book": "Book", - "edit": "Edit", - "barChart": "Bar Chart", - "lineChart": "Line Chart", - "trendingUp": "Trending Up", - "target": "Target", - "database": "Database", - "server": "Server", - "cloud": "Cloud", - "folder": "Folder", - "megaphone": "Megaphone", - "mail": "Mail", - "message": "Message", - "phone": "Phone", - "bell": "Bell", - "dollarSign": "Dollar Sign", - "creditCard": "Credit Card", - "calculator": "Calculator", - "shoppingCart": "Shopping Cart", - "briefcase": "Briefcase", - "headphones": "Headphones", - "user": "User", - "users": "Users", - "settings": "Settings", - "wrench": "Wrench", - "bot": "Bot", - "brain": "Brain", - "cpu": "CPU", - "code": "Code", - "zap": "Zap", - "workflow": "Workflow", - "search": "Search", - "play": "Play", - "layers": "Layers", - "lightbulb": "Lightbulb", - "star": "Star", - "globe": "Globe", - "award": "Award" - } - }, "webhookSettings": { "title": "Webhook Notifications", "searchPlaceholder": "Search webhooks...", @@ -20432,7 +20287,6 @@ "selectWorkspaceToLoadWorkflows": "Select a workspace to load workflows.", "noWorkflowsAvailable": "No workflows available in this workspace.", "unableToLoadWorkflows": "Unable to load workflows", - "authenticationRequiredToLoadWorkflows": "Authentication required to load workflows", "noSharedWorkflowSelected": "This color has no shared workflow selected yet.", "error": { "title": "Workflow Error", @@ -21388,7 +21242,7 @@ } }, "tipPrefix": "Tip: Use", - "tipSuffix": "to reference chats, workflows, knowledge, blocks, or templates", + "tipSuffix": "to reference chats, workflows, knowledge, or blocks", "shiftEnter": "Shift+Enter for newline" }, "input": { diff --git a/apps/tradinggoose/i18n/messages/es.json b/apps/tradinggoose/i18n/messages/es.json index dfc5b4696..c4cc2cbc1 100644 --- a/apps/tradinggoose/i18n/messages/es.json +++ b/apps/tradinggoose/i18n/messages/es.json @@ -997,46 +997,9 @@ "monitor": "Monitoreo", "logs": "Registros", "knowledge": "Conocimiento", - "templates": "Plantillas", "docs": "Documentos" } }, - "templates": { - "title": "Plantillas", - "description": "Toma una plantilla y empieza a construir,\no crea una desde cero.", - "searchPlaceholder": "Buscar plantillas...", - "sections": { - "your": "Tus plantillas", - "recent": "Recientes" - }, - "categories": { - "marketing": "Marketing", - "sales": "Ventas", - "finance": "Finanzas", - "support": "Soporte", - "artificial-intelligence": "Inteligencia artificial", - "other": "Otro" - }, - "detail": { - "notFoundTitle": "Plantilla no encontrada", - "notFoundDescription": "La plantilla que buscas no existe.", - "noWorkflowDataTitle": "Sin datos del flujo de trabajo", - "noWorkflowDataDescription": "Esta plantilla no contiene datos del estado del flujo de trabajo.", - "previewErrorTitle": "Error de vista previa", - "previewErrorDescription": "No se pudo renderizar la vista previa del flujo de trabajo", - "goBack": "Volver", - "workflowPreview": "Vista previa del flujo de trabajo", - "useThisTemplate": "Usar esta plantilla", - "use": "Usar", - "by": "por" - }, - "loginRequired": "Inicia sesión para ver las plantillas", - "errorPage": { - "title": "Error al cargar la plantilla", - "description": "Hubo un error al cargar esta plantilla.", - "templateIdLabel": "ID de plantilla:" - } - }, "knowledge": { "title": "Conocimiento", "searchPlaceholder": "Buscar bases de conocimiento...", @@ -2692,7 +2655,6 @@ "selectWorkspace": "Selecciona un espacio de trabajo para cargar los flujos.", "noWorkflows": "No hay flujos de trabajo disponibles en este espacio.", "unableToLoadWorkflows": "No se pudieron cargar los flujos", - "authenticationRequiredToLoadWorkflows": "Se requiere autenticación para cargar los flujos", "filters": "Filtros", "status": "Estado", "error": "Error", @@ -2740,7 +2702,6 @@ "selectWorkspace": "Selecciona un espacio de trabajo para cargar flujos de trabajo.", "noWorkflows": "No hay flujos de trabajo disponibles en este espacio de trabajo.", "unableToLoadWorkflows": "No se pudieron cargar los flujos de trabajo", - "authenticationRequiredToLoadWorkflows": "Se requiere autenticación para cargar los flujos de trabajo", "selectOutputs": "Seleccionar salidas", "selectWorkflowOutputs": "Seleccionar salidas del flujo de trabajo", "clearChat": "Limpiar chat", @@ -2772,7 +2733,6 @@ "selectWorkspace": "Selecciona un espacio de trabajo para cargar flujos de trabajo.", "noWorkflows": "No hay flujos de trabajo disponibles en este espacio de trabajo.", "unableToLoadWorkflows": "No se pudieron cargar los flujos de trabajo", - "authenticationRequiredToLoadWorkflows": "Se requiere autenticación para cargar los flujos de trabajo", "addVariable": "Agregar variable", "selectWorkflowToAddVariables": "Selecciona un flujo de trabajo para agregar variables", "addWorkflowVariable": "Agregar variable del flujo de trabajo", @@ -18527,111 +18487,6 @@ "duplicateDescription": "Un flujo solo puede tener un bloque disparador de {triggerName}. Elimina el existente antes de agregar uno nuevo.", "dismiss": "Entendido" }, - "templateModal": { - "title": { - "loading": "Cargando...", - "update": "Actualizar plantilla", - "publish": "Publicar plantilla" - }, - "fields": { - "icon": "Icono", - "color": "Color", - "name": "Nombre", - "author": "Autor", - "category": "Categoría", - "description": "Descripción" - }, - "placeholders": { - "name": "Ingresa el nombre de la plantilla", - "author": "Ingresa el nombre del autor", - "category": "Selecciona una categoría", - "description": "Describe lo que hace esta plantilla..." - }, - "actions": { - "close": "Cerrar", - "delete": "Eliminar" - }, - "submit": { - "updating": "Actualizando...", - "publishing": "Publicando...", - "update": "Actualizar plantilla", - "publish": "Publicar plantilla" - }, - "delete": { - "title": "¿Eliminar plantilla?", - "description": "Eliminar esta plantilla la quitará de la galería. Esta acción no se puede deshacer.", - "cancel": "Cancelar", - "confirm": "Eliminar", - "deleting": "Eliminando..." - }, - "validation": { - "nameRequired": "El nombre es obligatorio", - "nameTooLong": "El nombre debe tener menos de 100 caracteres", - "descriptionRequired": "La descripción es obligatoria", - "descriptionTooLong": "La descripción debe tener menos de 500 caracteres", - "authorRequired": "El autor es obligatorio", - "authorTooLong": "El autor debe tener menos de 100 caracteres", - "categoryRequired": "La categoría es obligatoria", - "iconRequired": "El icono es obligatorio", - "colorInvalid": "El color debe ser un valor hexadecimal válido (por ejemplo, #3972F6)" - }, - "errors": { - "workflowNotReady": "La sesión del flujo aún no está lista. Espera a que el editor termine de cargar.", - "create": "No se pudo crear la plantilla", - "update": "No se pudo actualizar la plantilla", - "delete": "No se pudo eliminar la plantilla" - }, - "categories": { - "marketing": "Marketing", - "sales": "Ventas", - "finance": "Finanzas", - "support": "Soporte", - "artificialIntelligence": "Inteligencia artificial", - "other": "Otro" - }, - "icons": { - "fileText": "Archivo de texto", - "notebook": "Cuaderno", - "book": "Libro", - "edit": "Editar", - "barChart": "Gráfico de barras", - "lineChart": "Gráfico de líneas", - "trendingUp": "Tendencia alcista", - "target": "Objetivo", - "database": "Base de datos", - "server": "Servidor", - "cloud": "Nube", - "folder": "Carpeta", - "megaphone": "Megáfono", - "mail": "Correo", - "message": "Mensaje", - "phone": "Teléfono", - "bell": "Campana", - "dollarSign": "Signo de dólar", - "creditCard": "Tarjeta de crédito", - "calculator": "Calculadora", - "shoppingCart": "Carrito de compras", - "briefcase": "Maletín", - "headphones": "Auriculares", - "user": "Usuario", - "users": "Usuarios", - "settings": "Configuración", - "wrench": "Llave", - "bot": "Bot", - "brain": "Cerebro", - "cpu": "CPU", - "code": "Código", - "zap": "Rayo", - "workflow": "Flujo de trabajo", - "search": "Buscar", - "play": "Reproducir", - "layers": "Capas", - "lightbulb": "Bombilla", - "star": "Estrella", - "globe": "Globo", - "award": "Premio" - } - }, "webhookSettings": { "title": "Notificaciones por webhook", "searchPlaceholder": "Buscar webhooks...", @@ -20432,7 +20287,6 @@ "selectWorkspaceToLoadWorkflows": "Selecciona un espacio de trabajo para cargar flujos de trabajo.", "noWorkflowsAvailable": "No hay flujos de trabajo disponibles en este espacio de trabajo.", "unableToLoadWorkflows": "No se pudieron cargar los flujos de trabajo", - "authenticationRequiredToLoadWorkflows": "Se requiere autenticación para cargar los flujos de trabajo", "noSharedWorkflowSelected": "Este color aún no tiene un flujo de trabajo compartido seleccionado.", "error": { "title": "Error del flujo de trabajo", @@ -21388,7 +21242,7 @@ } }, "tipPrefix": "Consejo: usa", - "tipSuffix": "para hacer referencia a chats, flujos de trabajo, conocimiento, bloques o plantillas", + "tipSuffix": "para hacer referencia a chats, flujos de trabajo, conocimiento o bloques", "shiftEnter": "Shift+Enter para nueva línea" }, "input": { diff --git a/apps/tradinggoose/i18n/messages/zh.json b/apps/tradinggoose/i18n/messages/zh.json index f434ac41e..786033d56 100644 --- a/apps/tradinggoose/i18n/messages/zh.json +++ b/apps/tradinggoose/i18n/messages/zh.json @@ -984,46 +984,9 @@ "monitor": "监控", "logs": "日志", "knowledge": "知识", - "templates": "模板", "docs": "文档" } }, - "templates": { - "title": "模板", - "description": "选择一个模板开始构建,\n或者从零开始创建一个。", - "searchPlaceholder": "搜索模板...", - "sections": { - "your": "我的模板", - "recent": "最近使用" - }, - "categories": { - "marketing": "市场营销", - "sales": "销售", - "finance": "财务", - "support": "支持", - "artificial-intelligence": "人工智能", - "other": "其他" - }, - "detail": { - "notFoundTitle": "模板未找到", - "notFoundDescription": "您查找的模板不存在。", - "noWorkflowDataTitle": "无工作流数据", - "noWorkflowDataDescription": "此模板不包含工作流状态数据。", - "previewErrorTitle": "预览错误", - "previewErrorDescription": "无法渲染工作流预览", - "goBack": "返回", - "workflowPreview": "工作流预览", - "useThisTemplate": "使用此模板", - "use": "使用", - "by": "由" - }, - "loginRequired": "请登录以查看模板", - "errorPage": { - "title": "加载模板出错", - "description": "加载此模板时出错。", - "templateIdLabel": "模板ID:" - } - }, "knowledge": { "title": "知识库", "searchPlaceholder": "搜索知识库...", @@ -2679,7 +2642,6 @@ "selectWorkspace": "请选择一个工作空间以加载工作流。", "noWorkflows": "此工作空间中暂无可用工作流。", "unableToLoadWorkflows": "无法加载工作流", - "authenticationRequiredToLoadWorkflows": "需要身份验证才能加载工作流", "filters": "过滤器", "status": "状态", "error": "错误", @@ -2727,7 +2689,6 @@ "selectWorkspace": "选择一个工作区以加载工作流。", "noWorkflows": "该工作区中没有可用的工作流。", "unableToLoadWorkflows": "无法加载工作流", - "authenticationRequiredToLoadWorkflows": "加载工作流需要身份验证", "selectOutputs": "选择输出", "selectWorkflowOutputs": "选择工作流输出", "clearChat": "清除聊天", @@ -2759,7 +2720,6 @@ "selectWorkspace": "选择一个工作区以加载工作流。", "noWorkflows": "此工作区中暂无可用工作流。", "unableToLoadWorkflows": "无法加载工作流", - "authenticationRequiredToLoadWorkflows": "需要身份验证才能加载工作流", "addVariable": "添加变量", "selectWorkflowToAddVariables": "选择一个工作流以添加变量", "addWorkflowVariable": "添加工作流变量", @@ -18514,111 +18474,6 @@ "duplicateDescription": "一个工作流只能有一个 {triggerName} 触发器块。请先删除已有的触发器,再添加新的。", "dismiss": "知道了" }, - "templateModal": { - "title": { - "loading": "加载中...", - "update": "更新模板", - "publish": "发布模板" - }, - "fields": { - "icon": "图标", - "color": "颜色", - "name": "名称", - "author": "作者", - "category": "分类", - "description": "描述" - }, - "placeholders": { - "name": "输入模板名称", - "author": "输入作者名称", - "category": "选择一个分类", - "description": "描述此模板的作用..." - }, - "actions": { - "close": "关闭", - "delete": "删除" - }, - "submit": { - "updating": "更新中...", - "publishing": "发布中...", - "update": "更新模板", - "publish": "发布模板" - }, - "delete": { - "title": "删除模板?", - "description": "删除此模板后,它将从模板库中移除。此操作无法撤销。", - "cancel": "取消", - "confirm": "删除", - "deleting": "删除中..." - }, - "validation": { - "nameRequired": "名称为必填项", - "nameTooLong": "名称必须少于 100 个字符", - "descriptionRequired": "描述为必填项", - "descriptionTooLong": "描述必须少于 500 个字符", - "authorRequired": "作者为必填项", - "authorTooLong": "作者必须少于 100 个字符", - "categoryRequired": "分类为必填项", - "iconRequired": "图标为必填项", - "colorInvalid": "颜色必须是有效的十六进制颜色(例如 #3972F6)" - }, - "errors": { - "workflowNotReady": "工作流会话尚未准备好。请等待编辑器完成加载。", - "create": "无法创建模板", - "update": "无法更新模板", - "delete": "无法删除模板" - }, - "categories": { - "marketing": "营销", - "sales": "销售", - "finance": "金融", - "support": "支持", - "artificialIntelligence": "人工智能", - "other": "其他" - }, - "icons": { - "fileText": "文本文档", - "notebook": "笔记本", - "book": "书籍", - "edit": "编辑", - "barChart": "柱状图", - "lineChart": "折线图", - "trendingUp": "上升趋势", - "target": "目标", - "database": "数据库", - "server": "服务器", - "cloud": "云", - "folder": "文件夹", - "megaphone": "扩音器", - "mail": "邮件", - "message": "消息", - "phone": "电话", - "bell": "铃铛", - "dollarSign": "美元符号", - "creditCard": "信用卡", - "calculator": "计算器", - "shoppingCart": "购物车", - "briefcase": "公文包", - "headphones": "耳机", - "user": "用户", - "users": "用户组", - "settings": "设置", - "wrench": "扳手", - "bot": "机器人", - "brain": "大脑", - "cpu": "CPU", - "code": "代码", - "zap": "闪电", - "workflow": "工作流", - "search": "搜索", - "play": "播放", - "layers": "图层", - "lightbulb": "灯泡", - "star": "星标", - "globe": "地球", - "award": "奖项" - } - }, "webhookSettings": { "title": "Webhook 通知", "searchPlaceholder": "搜索 Webhook...", @@ -20419,7 +20274,6 @@ "selectWorkspaceToLoadWorkflows": "选择一个工作区以加载工作流。", "noWorkflowsAvailable": "此工作区没有可用的工作流。", "unableToLoadWorkflows": "无法加载工作流", - "authenticationRequiredToLoadWorkflows": "加载工作流需要身份验证", "noSharedWorkflowSelected": "此颜色尚未选择共享工作流。", "error": { "title": "工作流错误", @@ -21375,7 +21229,7 @@ } }, "tipPrefix": "提示:使用", - "tipSuffix": "来引用聊天、工作流、知识、区块或模板", + "tipSuffix": "来引用聊天、工作流、知识或区块", "shiftEnter": "Shift+Enter 换行" }, "input": { diff --git a/apps/tradinggoose/i18n/public-copy.test.ts b/apps/tradinggoose/i18n/public-copy.test.ts index f22baf2bc..06ed971b1 100644 --- a/apps/tradinggoose/i18n/public-copy.test.ts +++ b/apps/tradinggoose/i18n/public-copy.test.ts @@ -116,9 +116,7 @@ describe('public copy', () => { const zhCopilot = getPublicCopy('zh').workspace.widgets.copilot expect(enCopilot.welcome.cards.reviewChangesSafely.title).toBe('Review changes safely') - expect(esCopilot.welcome.cards.reviewChangesSafely.title).toBe( - 'Revisar cambios con seguridad' - ) + expect(esCopilot.welcome.cards.reviewChangesSafely.title).toBe('Revisar cambios con seguridad') expect(zhCopilot.input.attachFile).toBe('附加文件') expect(esCopilot.message.copy).toBe('Copiar') expect(zhCopilot.message.sources).toBe('来源:') @@ -211,8 +209,6 @@ describe('public copy', () => { getPublicCopy('zh').workspace.widgets.blockEditor.blockLongDescriptions.stagehand_agent ).toContain('浏览网页并执行任务') expect(getPublicCopy('en').workspace.knowledge.title).toBe('Knowledge') - expect(getPublicCopy('en').workspace.templates.title).toBe('Templates') - expect(getPublicCopy('es').workspace.templates.sections.your).toBe('Tus plantillas') expect(getPublicCopy('zh').workspace.layoutTabs.renameAriaLabel).toContain('{name}') expect(getPublicCopy('zh').workspace.logs.title.logs).toBe('日志') expect(getPublicCopy('en').workspace.widgets.selector.selectWidget).toBe('Select widget') @@ -256,9 +252,6 @@ describe('public copy', () => { expect(getPublicCopy('en').workspace.widgets.workflowVariables.unableToLoadWorkflows).toBe( 'Unable to load workflows' ) - expect( - getPublicCopy('es').workspace.widgets.workflowVariables.authenticationRequiredToLoadWorkflows - ).toContain('autenticación') expect(getPublicCopy('zh').workspace.widgets.workflowEditor.whileConditionPlaceholder).toBe( '<counter.value> < 10' ) @@ -278,9 +271,6 @@ describe('public copy', () => { '{used} of {total} tag slots used' ) expect(getPublicCopy('zh').workspace.widgets.workflowVariables.addVariable).toBe('添加变量') - expect(getPublicCopy('en').workspace.widgets.blockEditor.templateModal.title.publish).toBe( - 'Publish Template' - ) expect(getPublicCopy('en').workspace.widgets.blockEditor.dropdown.failedToFetchOptions).toBe( 'Failed to fetch options' ) diff --git a/apps/tradinggoose/lib/copilot/chat-contexts.ts b/apps/tradinggoose/lib/copilot/chat-contexts.ts index af079b818..5781f019a 100644 --- a/apps/tradinggoose/lib/copilot/chat-contexts.ts +++ b/apps/tradinggoose/lib/copilot/chat-contexts.ts @@ -39,8 +39,6 @@ export const buildCopilotContextIdentityKey = (context: ChatContext): string => return `blocks:${[...(context.blockTypes ?? [])].sort().join(',')}` case 'knowledge': return `knowledge:${context.knowledgeId ?? context.label}` - case 'templates': - return `templates:${context.templateId ?? context.label}` case 'docs': return 'docs' case 'logs': diff --git a/apps/tradinggoose/lib/copilot/entity-documents.ts b/apps/tradinggoose/lib/copilot/entity-documents.ts index 1f02ddd37..75a951ce2 100644 --- a/apps/tradinggoose/lib/copilot/entity-documents.ts +++ b/apps/tradinggoose/lib/copilot/entity-documents.ts @@ -1,6 +1,5 @@ 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 @@ -154,12 +153,10 @@ export function normalizeEntityFields( } } case 'indicator': { - const pineCode = typeof source.pineCode === 'string' ? source.pineCode : '' return { name: typeof source.name === 'string' ? source.name.trim() : '', color: typeof source.color === 'string' ? source.color.trim() : '', - pineCode, - inputMeta: inferInputMetaFromPineCode(pineCode) ?? null, + pineCode: typeof source.pineCode === 'string' ? source.pineCode : '', } } case 'mcp_server': { diff --git a/apps/tradinggoose/lib/copilot/process-contents.test.ts b/apps/tradinggoose/lib/copilot/process-contents.test.ts index cf483bcec..cf9465105 100644 --- a/apps/tradinggoose/lib/copilot/process-contents.test.ts +++ b/apps/tradinggoose/lib/copilot/process-contents.test.ts @@ -39,7 +39,6 @@ vi.mock('@tradinggoose/db/schema', () => ({ entityId: 'permissions.entityId', userId: 'permissions.userId', }, - templates: {}, workflow: { id: 'workflow.id', name: 'workflow.name', diff --git a/apps/tradinggoose/lib/copilot/process-contents.ts b/apps/tradinggoose/lib/copilot/process-contents.ts index 4c8aa0d01..34b946834 100644 --- a/apps/tradinggoose/lib/copilot/process-contents.ts +++ b/apps/tradinggoose/lib/copilot/process-contents.ts @@ -4,7 +4,6 @@ import { copilotReviewSessions, document, permissions, - templates, workflow, workflowExecutionLogs, workspace, @@ -23,6 +22,7 @@ import { buildWorkspaceAccessScope } from '@/lib/permissions/utils' import { escapeRegExp } from '@/lib/utils' import { sanitizeForCopilot } from '@/lib/workflows/json-sanitizer' import { + ReviewTargetBootstrapError, readBootstrappedReviewTargetSnapshot, readBootstrappedSavedEntityFields, } from '@/lib/yjs/server/bootstrap-review-target' @@ -45,7 +45,6 @@ export type AgentContextType = | 'blocks' | 'logs' | 'knowledge' - | 'templates' | 'workflow_block' | 'docs' @@ -109,9 +108,6 @@ export async function processContextsServer( if (ctx.kind === 'blocks') { return await processBlocksMetadata(ctx.blockTypes ?? [], ctx.label ? `@${ctx.label}` : '@') } - if (ctx.kind === 'templates' && ctx.templateId) { - return await processTemplateContext(ctx.templateId, ctx.label ? `@${ctx.label}` : '@') - } if (ctx.kind === 'logs' && ctx.executionId) { return await processExecutionLogContext( ctx.executionId, @@ -207,13 +203,17 @@ async function processEntityContext(params: { ), } } catch (error) { - logger.error('Error processing entity context', { - entityKind: params.entityKind, - entityId: params.entityId, - workspaceId: params.workspaceId, - error, - }) - return null + // Only a genuinely missing entity degrades to "no context"; realtime + // failures must surface instead of silently omitting attached context. + if (error instanceof ReviewTargetBootstrapError && error.status === 404) { + logger.warn('Skipping missing copilot entity context', { + entityKind: params.entityKind, + entityId: params.entityId, + workspaceId: params.workspaceId, + }) + return null + } + throw error } } @@ -503,8 +503,11 @@ async function processKnowledgeContext( const content = JSON.stringify(summary, null, 2) return { type: 'knowledge', tag, content } } catch (error) { - logger.error('Error processing knowledge context', { knowledgeBaseId, error }) - return null + if (error instanceof ReviewTargetBootstrapError && error.status === 404) { + logger.warn('Skipping missing knowledge context', { knowledgeBaseId }) + return null + } + throw error } } @@ -535,45 +538,6 @@ async function processBlocksMetadata( } } -async function processTemplateContext( - templateId: string, - tag: string -): Promise<AgentContext | null> { - try { - const rows = await db - .select({ - id: templates.id, - name: templates.name, - description: templates.description, - category: templates.category, - author: templates.author, - stars: templates.stars, - state: templates.state, - }) - .from(templates) - .where(eq(templates.id, templateId)) - .limit(1) - const t = rows?.[0] - if (!t) return null - const workflowState = (t as any).state || {} - // Match read-workflow format: just the workflow state JSON - const summary = { - id: t.id, - name: t.name, - description: t.description || '', - category: t.category, - author: t.author, - stars: t.stars || 0, - workflow: workflowState, - } - const content = JSON.stringify(summary) - return { type: 'templates', tag, content } - } catch (error) { - logger.error('Error processing template context', { templateId, error }) - return null - } -} - async function processWorkflowBlockContext( workflowId: string, blockId: string, diff --git a/apps/tradinggoose/lib/copilot/registry.ts b/apps/tradinggoose/lib/copilot/registry.ts index 05ec807b5..630878302 100644 --- a/apps/tradinggoose/lib/copilot/registry.ts +++ b/apps/tradinggoose/lib/copilot/registry.ts @@ -716,6 +716,7 @@ const WorkflowVariableDocumentEnvelope = WorkflowTargetEnvelope.extend({ const GenericEntityListEntry = z.object({ entityId: z.string(), entityName: z.string().optional(), + entityDescription: z.string().optional(), enabled: z.boolean().optional(), }) diff --git a/apps/tradinggoose/lib/copilot/review-sessions/identity.ts b/apps/tradinggoose/lib/copilot/review-sessions/identity.ts index ff571c637..19a1fbf8b 100644 --- a/apps/tradinggoose/lib/copilot/review-sessions/identity.ts +++ b/apps/tradinggoose/lib/copilot/review-sessions/identity.ts @@ -56,7 +56,7 @@ export function buildSavedEntityDescriptor( const ENTITY_LIST_SESSION_PREFIX = 'list:' -function buildEntityListSessionId(entityKind: SavedEntityKind, workspaceId: string): string { +function buildEntityListSessionId(entityKind: ReviewEntityKind, workspaceId: string): string { return `${ENTITY_LIST_SESSION_PREFIX}${entityKind}:${workspaceId}` } @@ -65,7 +65,7 @@ export function isEntityListSessionId(sessionId: string): boolean { } export function buildEntityListDescriptor( - entityKind: SavedEntityKind, + entityKind: ReviewEntityKind, workspaceId: string ): ReviewTargetDescriptor { return { @@ -108,10 +108,6 @@ export function buildReviewTargetDescriptorFromEnvelope( envelope: YjsTransportEnvelope ): ReviewTargetDescriptor { if (envelope.targetKind === 'entity_list') { - if (envelope.entityKind === 'workflow') { - throw new Error('Entity-list Yjs envelope cannot use entityKind="workflow"') - } - if (!envelope.workspaceId) { throw new Error('Entity-list Yjs envelope requires workspaceId') } diff --git a/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts b/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts index c3c173b9d..3445da388 100644 --- a/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts +++ b/apps/tradinggoose/lib/copilot/review-sessions/permissions.ts @@ -265,6 +265,14 @@ export async function verifyReviewTargetAccess( reviewTarget: ReviewTargetAccessInput | ReviewTargetDescriptor, accessMode: ReviewAccessMode ): Promise<ReviewAccessResult> { + 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.entityKind === 'workflow') { if (!reviewTarget.entityId) { logger.warn('Workflow review target missing workflow id', { userId, reviewTarget }) @@ -283,14 +291,6 @@ export async function verifyReviewTargetAccess( return access } - 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) { return verifySavedEntityTargetAccess(userId, reviewTarget, accessMode) } diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/workflow-review-tool-utils.test.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/workflow-review-tool-utils.test.ts index 6156c5541..f460714f8 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/workflow-review-tool-utils.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/workflow/workflow-review-tool-utils.test.ts @@ -55,7 +55,6 @@ describe('workflow-review-tool-utils', () => { mockGetRegisteredWorkflowSession.mockReturnValue({ workflowId: 'workflow-live', - entityName: 'Live Workflow', workspaceId: 'workspace-live', doc, }) @@ -70,7 +69,6 @@ describe('workflow-review-tool-utils', () => { 'workflow-live' ) expect(result.workflowId).toBe('workflow-live') - expect(result.entityName).toBe('Live Workflow') expect(result.workspaceId).toBe('workspace-live') expect(result.workflowState.blocks['block-1']).toMatchObject({ type: 'agent', @@ -92,7 +90,6 @@ describe('workflow-review-tool-utils', () => { mockAcquireWritableWorkflowSessionLease.mockResolvedValue({ session: { workflowId: 'workflow-db', - entityName: 'Background Workflow', workspaceId: 'workspace-db', doc, }, @@ -109,7 +106,6 @@ describe('workflow-review-tool-utils', () => { 'workflow-db' ) expect(result.workflowId).toBe('workflow-db') - expect(result.entityName).toBe('Background Workflow') expect(result.workspaceId).toBe('workspace-db') expect(mockAcquireWritableWorkflowSessionLease).toHaveBeenCalledWith({ workflowId: 'workflow-db', diff --git a/apps/tradinggoose/lib/copilot/tools/client/workflow/workflow-review-tool-utils.ts b/apps/tradinggoose/lib/copilot/tools/client/workflow/workflow-review-tool-utils.ts index 87df58714..77ce8620a 100644 --- a/apps/tradinggoose/lib/copilot/tools/client/workflow/workflow-review-tool-utils.ts +++ b/apps/tradinggoose/lib/copilot/tools/client/workflow/workflow-review-tool-utils.ts @@ -203,10 +203,8 @@ export async function getReadableWorkflowState( const liveSession = getRegisteredWorkflowSession(resolvedWorkflowId) if (liveSession) { - const entityName = normalizeWorkflowTargetValue(liveSession.entityName) return { workflowId: liveSession.workflowId, - ...(entityName ? { entityName } : {}), workflowState: readWorkflowSnapshot(liveSession.doc), workspaceId: liveSession.workspaceId ?? null, variables: getVariablesSnapshot(liveSession.doc), @@ -218,10 +216,8 @@ export async function getReadableWorkflowState( workspaceId: executionContext.workspaceId ?? null, }) try { - const entityName = normalizeWorkflowTargetValue(lease.session.entityName) return { workflowId: lease.session.workflowId, - ...(entityName ? { entityName } : {}), workflowState: readWorkflowSnapshot(lease.session.doc), workspaceId: lease.session.workspaceId ?? null, variables: getVariablesSnapshot(lease.session.doc), 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 959ccd113..b616ba7c4 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,8 +1,5 @@ import { CopilotTool } from '@/lib/copilot/registry' -import { - type BaseServerTool, - withWorkspaceArgContext, -} from '@/lib/copilot/tools/server/base-tool' +import { type BaseServerTool, withWorkspaceArgContext } from '@/lib/copilot/tools/server/base-tool' import { listWorkflowBlockCatalogItems } from '@/lib/copilot/tools/server/blocks/block-mermaid-catalog' import type { GetAgentAccessoryCatalogInputType, @@ -91,7 +88,7 @@ export const getAgentAccessoryCatalogServerTool: BaseServerTool< const [blockToolOptions, customToolRows, mcpToolRows, skillRows] = await Promise.all([ getBlockToolOptions(), listCustomTools({ workspaceId }), - mcpService.discoverTools(scopedContext.userId, workspaceId), + mcpService.discoverTools(scopedContext.userId, workspaceId, false), listSkills({ workspaceId }), ]) 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 a759292c8..924c521f5 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/indicator.test.ts @@ -4,8 +4,8 @@ import { DEFAULT_INDICATOR_RUNTIME_ENTRIES } from '@/lib/indicators/default/runt import { listIndicatorsServerTool, readIndicatorServerTool } from './indicator' const mockCheckWorkspaceAccess = vi.hoisted(() => vi.fn()) -const mockReadBootstrappedEntityListMembers = vi.hoisted(() => vi.fn()) const mockReadBootstrappedSavedEntityFields = vi.hoisted(() => vi.fn()) +const mockReadEntityListMembersFromDb = vi.hoisted(() => vi.fn()) const mockVerifyReviewTargetAccess = vi.hoisted(() => vi.fn()) vi.mock('@/lib/permissions/utils', () => ({ @@ -17,12 +17,14 @@ vi.mock('@/lib/copilot/review-sessions/permissions', () => ({ })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ - requireSavedEntityRealtimeListMembers: (...args: unknown[]) => - mockReadBootstrappedEntityListMembers(...args), readBootstrappedSavedEntityFields: (...args: unknown[]) => mockReadBootstrappedSavedEntityFields(...args), })) +vi.mock('@/lib/yjs/server/entity-loaders', () => ({ + readEntityListMembersFromDb: (...args: unknown[]) => mockReadEntityListMembersFromDb(...args), +})) + describe('indicator server tools', () => { beforeEach(() => { vi.clearAllMocks() @@ -31,10 +33,10 @@ describe('indicator server tools', () => { hasAccess: true, canWrite: true, }) - mockReadBootstrappedEntityListMembers.mockResolvedValue([ + mockReadEntityListMembersFromDb.mockResolvedValue([ { - entityId: 'indicator-custom-1', - entityName: 'Custom Momentum', + id: 'indicator-custom-1', + name: 'Custom Momentum', }, ]) mockVerifyReviewTargetAccess.mockResolvedValue({ 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 4efadde9c..8bffca79c 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.test.ts @@ -9,6 +9,7 @@ import { hashServerToolReviewBase } from '@/lib/copilot/tools/server/base-tool' import { buildDocumentEnvelope, buildReviewDocumentDiff, + buildSavedEntityListInfo, executeCreateEntityDocumentMutation, executeUpdateEntityDocumentMutation, } from './shared' @@ -17,8 +18,8 @@ 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 mockReadEntityListMembersFromDb = vi.hoisted(() => vi.fn()) const mockVerifyReviewTargetAccess = vi.hoisted(() => vi.fn()) vi.mock('@/lib/permissions/utils', () => ({ @@ -34,12 +35,14 @@ vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ - requireSavedEntityRealtimeListMembers: (...args: unknown[]) => - mockReadBootstrappedEntityListMembers(...args), readBootstrappedSavedEntityFields: (...args: unknown[]) => mockReadBootstrappedSavedEntityFields(...args), })) +vi.mock('@/lib/yjs/server/entity-loaders', () => ({ + readEntityListMembersFromDb: (...args: unknown[]) => mockReadEntityListMembersFromDb(...args), +})) + describe('entity document mutation helpers', () => { beforeEach(() => { vi.clearAllMocks() @@ -52,7 +55,28 @@ describe('entity document mutation helpers', () => { hasAccess: true, workspaceId: 'workspace-1', }) - mockReadBootstrappedEntityListMembers.mockResolvedValue([]) + mockReadEntityListMembersFromDb.mockResolvedValue([]) + }) + + it('builds server list entries from canonical DB membership', async () => { + mockReadEntityListMembersFromDb.mockResolvedValueOnce([ + { + id: 'skill-1', + name: 'Skill 1', + description: 'Use Skill 1 for summaries.', + color: '#10b981', + }, + ]) + + await expect(buildSavedEntityListInfo('skill', 'workspace-1')).resolves.toEqual([ + { + entityId: 'skill-1', + entityName: 'Skill 1', + entityDescription: 'Use Skill 1 for summaries.', + color: '#10b981', + }, + ]) + expect(mockReadEntityListMembersFromDb).toHaveBeenCalledWith('skill', 'workspace-1') }) 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 801a70615..7d36ad906 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts @@ -20,10 +20,8 @@ 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 { - readBootstrappedSavedEntityFields, - requireSavedEntityRealtimeListMembers, -} from '@/lib/yjs/server/bootstrap-review-target' +import { readBootstrappedSavedEntityFields } from '@/lib/yjs/server/bootstrap-review-target' +import { readEntityListMembersFromDb } from '@/lib/yjs/server/entity-loaders' export type SavedEntityDocumentKind = EntityDocumentKind export type EntityDocumentArgs = { @@ -41,7 +39,10 @@ export type EntityDocumentArgs = { export type EntityListEntry = { entityId: string entityName: string + entityDescription?: string enabled?: boolean + color?: string + connectionStatus?: string } export type CopilotIndicatorListEntry = { @@ -198,15 +199,25 @@ export async function readSavedEntityDocumentFields( } /** - * Canonical read for every saved-entity list_* tool: the workspace's membership - * 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. + * Canonical read for every server-side saved-entity list_* tool: the workspace's + * active rows. Live Yjs list sessions are the browser realtime projection; server + * tools must not return a stale projection when that session is degraded. */ -export function buildSavedEntityListInfo( +export async function buildSavedEntityListInfo( entityKind: SavedEntityKind, workspaceId: string ): Promise<EntityListEntry[]> { - return requireSavedEntityRealtimeListMembers(entityKind, workspaceId) + const members = await readEntityListMembersFromDb(entityKind, workspaceId) + return members.map((member) => ({ + entityId: member.id, + entityName: member.name, + ...(typeof member.description === 'string' ? { entityDescription: member.description } : {}), + ...(typeof member.enabled === 'boolean' ? { enabled: member.enabled } : {}), + ...(typeof member.color === 'string' ? { color: member.color } : {}), + ...(typeof member.connectionStatus === 'string' + ? { connectionStatus: member.connectionStatus } + : {}), + })) } async function hashCreateEntityReviewBase( 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 807a016bd..a65f6d5f2 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 @@ -36,7 +36,6 @@ vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ vi.mock('@/lib/yjs/server/apply-workflow-state', () => ({ applyWorkflowState: (...args: any[]) => mockApplyWorkflowState(...args), - applyWorkflowMetadata: vi.fn(), })) vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ diff --git a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts index 608f40107..db63a02c3 100644 --- a/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts +++ b/apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts @@ -21,6 +21,7 @@ import { import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow' import { editWorkflowBlockServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow-block' import { VariableManager } from '@/lib/variables/variable-manager' +import { refreshWorkflowListForWorkflow } from '@/lib/workflows/db-helpers' import { TG_MERMAID_DOCUMENT_FORMAT } from '@/lib/workflows/document-format' import { readWorkflowContainerBoundaryEdgeViolation, @@ -28,13 +29,13 @@ import { serializeWorkflowToTgMermaid, } from '@/lib/workflows/studio-workflow-mermaid' import { isWorkflowVariableType, type WorkflowVariableType } from '@/lib/workflows/value-types' -import { applyWorkflowMetadata, applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' +import { readEntityListMembersFromDb } from '@/lib/yjs/server/entity-loaders' import { applyWorkflowPatchInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot, getVariablesSnapshot, - readWorkflowEntityMetadata, readWorkflowSnapshot, type WorkflowSnapshot, } from '@/lib/yjs/workflow-session' @@ -248,10 +249,9 @@ 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: metadata.name ?? workflowRow.name ?? undefined, + entityName: workflowRow.name ?? undefined, workspaceId: workflowRow.workspaceId ?? null, workflowState: readWorkflowSnapshot(doc), variables: getVariablesSnapshot(doc), @@ -350,25 +350,23 @@ export const listWorkflowsServerTool: BaseServerTool<{ workspaceId?: string }, a withWorkspaceArgContext(context, args), 'read' ) - const rows = await db - .select({ - id: workflow.id, - name: workflow.name, - workspaceId: workflow.workspaceId, - description: workflow.description, + const entities = (await readEntityListMembersFromDb(ENTITY_KIND_WORKFLOW, workspaceId)).map( + (member) => ({ + entityId: member.id, + entityName: member.name, + ...(typeof member.description === 'string' + ? { entityDescription: member.description } + : {}), + ...('folderId' in member ? { folderId: member.folderId ?? null } : {}), + ...(typeof member.color === 'string' ? { color: member.color } : {}), + ...(typeof member.createdAt === 'string' ? { createdAt: member.createdAt } : {}), }) - .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, + entities, + count: entities.length, } }, } @@ -528,21 +526,15 @@ export const createWorkflowServerTool: BaseServerTool< isDeployed: false, collaborators: [], runCount: 0, - isPublished: false, - marketplaceData: null, }) try { - await applyWorkflowState( - workflowId, - workflowState, - {}, - { name, description, folderId: args.folderId || null } - ) + await applyWorkflowState(workflowId, workflowState, {}) } catch (error) { await db.delete(workflow).where(eq(workflow.id, workflowId)) throw error } + await refreshWorkflowListForWorkflow(workflowId) return { success: true, @@ -598,7 +590,15 @@ export const renameWorkflowServerTool: BaseServerTool<{ entityId: string; name: } assertAcceptedServerToolReviewBase(context, currentNameBaseHash) - const updatedWorkflow = await applyWorkflowMetadata(workflowId, { name: nextName }) + 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') + } + await refreshWorkflowListForWorkflow(workflowId) return { success: true, diff --git a/apps/tradinggoose/lib/custom-tools/operations.ts b/apps/tradinggoose/lib/custom-tools/operations.ts index d2016f010..aebd285f9 100644 --- a/apps/tradinggoose/lib/custom-tools/operations.ts +++ b/apps/tradinggoose/lib/custom-tools/operations.ts @@ -9,11 +9,9 @@ import { import { parseCustomToolSchemaText } from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { - applySavedEntityState, - publishCreatedSavedEntityListMembers, -} from '@/lib/yjs/server/apply-entity-state' -import { requireSavedEntityRealtimeListFields } from '@/lib/yjs/server/bootstrap-review-target' +import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { readSavedEntityListFieldsForExecution } from '@/lib/yjs/server/bootstrap-review-target' +import { refreshEntityListSession } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('CustomToolsOperations') @@ -47,7 +45,11 @@ interface ImportCustomToolsParams { } export async function listCustomTools(params: { workspaceId: string }) { - const entries = await requireSavedEntityRealtimeListFields('custom_tool', params.workspaceId) + const entries = await readSavedEntityListFieldsForExecution( + 'custom_tool', + params.workspaceId, + false + ) return entries.map(({ entityId, fields }) => ({ id: entityId, workspaceId: params.workspaceId, @@ -106,11 +108,7 @@ export async function createCustomTools({ return createdTools }) - await publishCreatedSavedEntityListMembers( - 'custom_tool', - workspaceId, - created.map((createdTool) => ({ id: createdTool.id, name: createdTool.title })) - ) + await refreshEntityListSession('custom_tool', workspaceId) logger.info(`[${requestId}] Created ${created.length} custom tool(s)`) return created } @@ -180,11 +178,7 @@ export async function importCustomTools({ } }) - await publishCreatedSavedEntityListMembers( - 'custom_tool', - workspaceId, - result.tools.map((importedTool) => ({ id: importedTool.id, name: importedTool.title })) - ) + await refreshEntityListSession('custom_tool', workspaceId) logger.info(`[${requestId}] Imported ${result.tools.length} custom tool(s)`, { workspaceId, renamedCount: result.renamedCount, diff --git a/apps/tradinggoose/lib/function/execution.ts b/apps/tradinggoose/lib/function/execution.ts index b197a51c1..a37ef77ec 100644 --- a/apps/tradinggoose/lib/function/execution.ts +++ b/apps/tradinggoose/lib/function/execution.ts @@ -40,6 +40,7 @@ export type FunctionExecutionPayload = { workflowId?: string | null workspaceId: string isCustomTool?: boolean + isDeployedContext?: boolean } type FunctionExecutionResponseBody = { @@ -123,6 +124,7 @@ export async function executeFunctionRequest( workflowId, workspaceId, isCustomTool = false, + isDeployedContext = true, } = payload const e2bUserScope = payload.userId @@ -155,7 +157,7 @@ export async function executeFunctionRequest( pineCode, inputMeta, })), - ...(await listCustomIndicatorRuntimeEntries(workspaceId)), + ...(await listCustomIndicatorRuntimeEntries(workspaceId, isDeployedContext)), ], } diff --git a/apps/tradinggoose/lib/indicators/custom/operations.ts b/apps/tradinggoose/lib/indicators/custom/operations.ts index 9864d5805..42667f8cc 100644 --- a/apps/tradinggoose/lib/indicators/custom/operations.ts +++ b/apps/tradinggoose/lib/indicators/custom/operations.ts @@ -6,37 +6,52 @@ import { type IndicatorTransferRecord, resolveImportedIndicatorName, } from '@/lib/indicators/import-export' -import { inferInputMetaFromPineCode, normalizeInputMetaMap } from '@/lib/indicators/input-meta' +import { inferInputMetaFromPineCode } from '@/lib/indicators/input-meta' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { - applySavedEntityState, - publishCreatedSavedEntityListMembers, -} from '@/lib/yjs/server/apply-entity-state' -import { requireSavedEntityRealtimeListFields } from '@/lib/yjs/server/bootstrap-review-target' +import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { readSavedEntityListFieldsForExecution } from '@/lib/yjs/server/bootstrap-review-target' +import { refreshEntityListSession } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('IndicatorsOperations') -export async function listCustomIndicatorRuntimeEntries(workspaceId: string) { - const entries = await requireSavedEntityRealtimeListFields('indicator', workspaceId) - return entries.map(({ entityId, fields }) => ({ - id: entityId, - pineCode: String(fields.pineCode ?? ''), - inputMeta: normalizeInputMetaMap(fields.inputMeta), - })) +export async function listCustomIndicatorRuntimeEntries( + workspaceId: string, + isDeployedContext: boolean +) { + const entries = await readSavedEntityListFieldsForExecution( + 'indicator', + workspaceId, + isDeployedContext + ) + return entries.map(({ entityId, fields }) => { + const pineCode = String(fields.pineCode ?? '') + return { + id: entityId, + pineCode, + inputMeta: inferInputMetaFromPineCode(pineCode), + } + }) } export async function listIndicators(params: { workspaceId: string }) { - const entries = await requireSavedEntityRealtimeListFields('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), - })) + const entries = await readSavedEntityListFieldsForExecution( + 'indicator', + params.workspaceId, + false + ) + return entries.map(({ entityId, fields }) => { + const pineCode = String(fields.pineCode ?? '') + return { + id: entityId, + workspaceId: params.workspaceId, + userId: null, + name: String(fields.name ?? ''), + color: String(fields.color ?? ''), + pineCode, + inputMeta: inferInputMetaFromPineCode(pineCode), + } + }) } interface CreateIndicatorsParams { @@ -90,7 +105,6 @@ export async function createIndicators({ name: indicator.name, color: indicator.color?.trim() || getStableVibrantColor(indicatorId), pineCode: indicator.pineCode, - inputMeta: inferInputMetaFromPineCode(indicator.pineCode) ?? null, createdAt: nowTime, updatedAt: nowTime, }) @@ -100,11 +114,7 @@ export async function createIndicators({ return createdIndicators }) - await publishCreatedSavedEntityListMembers( - 'indicator', - workspaceId, - created.map((createdIndicator) => ({ id: createdIndicator.id, name: createdIndicator.name })) - ) + await refreshEntityListSession('indicator', workspaceId) logger.info(`[${requestId}] Created ${created.length} indicator(s)`) return created } @@ -169,7 +179,6 @@ export async function importIndicators({ name: nextName, color: getStableVibrantColor(indicatorId), pineCode: indicator.pineCode, - inputMeta: inferInputMetaFromPineCode(indicator.pineCode) ?? null, createdAt: nowTime, updatedAt: nowTime, } @@ -184,11 +193,7 @@ export async function importIndicators({ } }) - await publishCreatedSavedEntityListMembers( - 'indicator', - workspaceId, - result.indicators.map((imported) => ({ id: imported.id, name: imported.name })) - ) + await refreshEntityListSession('indicator', workspaceId) logger.info(`[${requestId}] Imported ${result.indicators.length} indicator(s)`, { workspaceId, renamedCount: result.renamedCount, diff --git a/apps/tradinggoose/lib/knowledge/service.ts b/apps/tradinggoose/lib/knowledge/service.ts index e0a88162b..1063c46b1 100644 --- a/apps/tradinggoose/lib/knowledge/service.ts +++ b/apps/tradinggoose/lib/knowledge/service.ts @@ -21,14 +21,10 @@ import type { } from '@/lib/knowledge/types' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess, getUserEntityPermissions } from '@/lib/permissions/utils' -import { - applySavedEntityState, - publishCreatedSavedEntityListMembers, -} from '@/lib/yjs/server/apply-entity-state' -import { requireSavedEntityRealtimeListFields } from '@/lib/yjs/server/bootstrap-review-target' +import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' import { deleteYjsSessionInSocketServer, - notifyEntityListMemberRemoved, + refreshEntityListSession, } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('KnowledgeBaseService') @@ -45,16 +41,22 @@ export async function getKnowledgeBases( return [] } - const entries = await requireSavedEntityRealtimeListFields('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, - workspaceId, + const rows = await db + .select() + .from(knowledgeBase) + .where(and(eq(knowledgeBase.workspaceId, workspaceId), isNull(knowledgeBase.deletedAt))) + + return rows.map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + tokenCount: row.tokenCount, + embeddingModel: row.embeddingModel, + embeddingDimension: row.embeddingDimension, + chunkingConfig: row.chunkingConfig as ChunkingConfig, + workspaceId: row.workspaceId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, })) } @@ -103,9 +105,7 @@ export async function createKnowledgeBase( } await db.insert(knowledgeBase).values(newKnowledgeBase) - await publishCreatedSavedEntityListMembers('knowledge_base', data.workspaceId, [ - { id: created.id, name: created.name }, - ]) + await refreshEntityListSession('knowledge_base', data.workspaceId) logger.info(`[${requestId}] Created knowledge base: ${data.name} (${kbId})`) return created @@ -329,16 +329,7 @@ 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)) - } - } - ) + await refreshEntityListSession('knowledge_base', targetWorkspaceId) if (totalDocumentSize > 0) { try { @@ -444,10 +435,8 @@ export async function deleteKnowledgeBase( .where(eq(knowledgeBase.id, knowledgeBaseId)) if (existing?.workspaceId) { - await Promise.allSettled([ - deleteYjsSessionInSocketServer(knowledgeBaseId), - notifyEntityListMemberRemoved('knowledge_base', existing.workspaceId, knowledgeBaseId), - ]) + await refreshEntityListSession('knowledge_base', existing.workspaceId) + await Promise.allSettled([deleteYjsSessionInSocketServer(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 9cde48c22..0d8613bf7 100644 --- a/apps/tradinggoose/lib/mcp/service.ts +++ b/apps/tradinggoose/lib/mcp/service.ts @@ -1,13 +1,11 @@ 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 { getEffectiveDecryptedEnv } from '@/lib/environment/utils' import { createLogger } from '@/lib/logs/console/logger' import { McpClient } from '@/lib/mcp/client' import type { McpServerConfig, - McpServerSummary, McpTool, McpToolCall, McpToolResult, @@ -15,7 +13,11 @@ import type { } from '@/lib/mcp/types' import { generateRequestId } from '@/lib/utils' import { savedEntityRowToFields } from '@/lib/yjs/entity-state' -import { publishCreatedSavedEntityListMembers } from '@/lib/yjs/server/apply-entity-state' +import { + readSavedEntityFieldsForExecution, + readSavedEntityListFieldsForExecution, +} from '@/lib/yjs/server/bootstrap-review-target' +import { refreshEntityListSession } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('McpService') @@ -155,52 +157,57 @@ class McpService { throw new Error('Created MCP server was not returned from canonical insert') } - await publishCreatedSavedEntityListMembers('mcp_server', input.workspaceId, [ - { - id: entityId, - name: String(normalized.name ?? ''), - enabled: normalized.enabled !== false, - }, - ]) + await refreshEntityListSession('mcp_server', input.workspaceId) return { entityId, fields: savedEntityRowToFields('mcp_server', row) } } private async getServerConfig( serverId: string, - workspaceId: string + workspaceId: string, + isDeployedContext = true ): Promise<McpServerConfig | null> { - 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 readSavedEntityFieldsForExecution( + 'mcp_server', + serverId, + workspaceId, + isDeployedContext ) ) - .limit(1) - if (!server) { - return null + return fields.enabled === false ? null : this.toServerConfig(serverId, fields) + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + (error as { status?: number }).status === 404 + ) { + return null + } + throw error } - - const fields = normalizeEntityFields('mcp_server', savedEntityRowToFields('mcp_server', server)) - return fields.enabled === false ? null : this.toServerConfig(serverId, fields) } - private async getWorkspaceServers(workspaceId: string): Promise<McpServerConfig[]> { - const servers = await db - .select() - .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) - - return servers.flatMap((server) => { - const fields = normalizeEntityFields( - 'mcp_server', - savedEntityRowToFields('mcp_server', server) - ) - return fields.enabled === false ? [] : [this.toServerConfig(server.id, fields)] + private async getWorkspaceServers( + workspaceId: string, + isDeployedContext = true + ): Promise<McpServerConfig[]> { + const servers = await readSavedEntityListFieldsForExecution( + 'mcp_server', + workspaceId, + isDeployedContext + ) + + return servers.flatMap(({ entityId, fields: rawFields }) => { + try { + const fields = normalizeEntityFields('mcp_server', rawFields) + return fields.enabled === false ? [] : [this.toServerConfig(entityId, fields)] + } catch (error) { + logger.warn(`Skipping invalid MCP server ${entityId} during workspace discovery:`, error) + return [] + } }) } @@ -227,7 +234,8 @@ class McpService { userId: string, serverId: string, toolCall: McpToolCall, - workspaceId: string + workspaceId: string, + isDeployedContext = true ): Promise<McpToolResult> { const requestId = generateRequestId() @@ -236,7 +244,7 @@ class McpService { `[${requestId}] Executing MCP tool ${toolCall.name} on server ${serverId} for user ${userId}` ) - const config = await this.getServerConfig(serverId, workspaceId) + const config = await this.getServerConfig(serverId, workspaceId, isDeployedContext) if (!config) { throw new McpServerNotFoundError(serverId) } @@ -264,13 +272,17 @@ class McpService { /** * Discover tools from all workspace servers */ - async discoverTools(userId: string, workspaceId: string): Promise<McpTool[]> { + async discoverTools( + userId: string, + workspaceId: string, + isDeployedContext = true + ): Promise<McpTool[]> { const requestId = generateRequestId() try { logger.info(`[${requestId}] Discovering MCP tools for workspace ${workspaceId}`) - const servers = await this.getWorkspaceServers(workspaceId) + const servers = await this.getWorkspaceServers(workspaceId, isDeployedContext) if (servers.length === 0) { logger.info(`[${requestId}] No servers found for workspace ${workspaceId}`) @@ -321,14 +333,15 @@ class McpService { async discoverServerTools( userId: string, serverId: string, - workspaceId: string + workspaceId: string, + isDeployedContext = true ): Promise<McpTool[]> { const requestId = generateRequestId() try { logger.info(`[${requestId}] Discovering tools from server ${serverId} for user ${userId}`) - const config = await this.getServerConfig(serverId, workspaceId) + const config = await this.getServerConfig(serverId, workspaceId, isDeployedContext) if (!config) { throw new McpServerNotFoundError(serverId) } @@ -349,57 +362,6 @@ class McpService { throw error } } - - /** - * Get server summaries for a user - */ - async getServerSummaries(userId: string, workspaceId: string): Promise<McpServerSummary[]> { - const requestId = generateRequestId() - - try { - logger.info(`[${requestId}] Getting server summaries for workspace ${workspaceId}`) - - const servers = await this.getWorkspaceServers(workspaceId) - const summaries: McpServerSummary[] = [] - - for (const config of servers) { - try { - const resolvedConfig = await this.resolveConfigEnvVars(config, userId, workspaceId) - const client = await this.createClient(resolvedConfig) - const tools = await client.listTools() - await client.disconnect() - - summaries.push({ - id: config.id, - name: config.name, - url: config.url, - transport: config.transport, - status: 'connected', - toolCount: tools.length, - lastSeen: new Date(), - error: undefined, - }) - } catch (error) { - summaries.push({ - id: config.id, - name: config.name, - url: config.url, - transport: config.transport, - status: 'error', - toolCount: 0, - lastSeen: undefined, - error: error instanceof Error ? error.message : 'Connection failed', - }) - } - } - - return summaries - } catch (error) { - logger.error(`[${requestId}] Failed to get server summaries for user ${userId}:`, error) - throw error - } - } - } export const mcpService = new McpService() diff --git a/apps/tradinggoose/lib/mcp/utils.ts b/apps/tradinggoose/lib/mcp/utils.ts index d3ee5d2d5..e39aa70c9 100644 --- a/apps/tradinggoose/lib/mcp/utils.ts +++ b/apps/tradinggoose/lib/mcp/utils.ts @@ -18,6 +18,8 @@ export const MCP_CLIENT_CONSTANTS = { AUTO_REFRESH_INTERVAL: 5 * 60 * 1000, } as const +export const MCP_TOOLS_CHANGED_EVENT = 'tradinggoose:mcp-tools-changed' + /** * Create standardized MCP error response */ diff --git a/apps/tradinggoose/lib/naming.ts b/apps/tradinggoose/lib/naming.ts index 17c249920..944eec02c 100644 --- a/apps/tradinggoose/lib/naming.ts +++ b/apps/tradinggoose/lib/naming.ts @@ -185,6 +185,15 @@ export function generateIncrementalName<T extends NameableEntity>( return `${prefix} ${nextNumber}` } +export function generateAvailableName(existingNames: string[], baseName: string): string { + return existingNames.includes(baseName) + ? generateIncrementalName( + existingNames.map((name) => ({ name })), + baseName + ) + : baseName +} + /** * Generates the next workspace name */ diff --git a/apps/tradinggoose/lib/skills/import-export.ts b/apps/tradinggoose/lib/skills/import-export.ts index 835d013b6..16678c5b0 100644 --- a/apps/tradinggoose/lib/skills/import-export.ts +++ b/apps/tradinggoose/lib/skills/import-export.ts @@ -3,7 +3,7 @@ import { createTradingGooseExportFile, TradingGooseExportEnvelopeSchema, } from '@/lib/import-export/trading-goose' -import type { SkillDefinition } from '@/stores/skills/types' +import type { SkillDefinition } from '@/lib/skills/types' export const SKILL_NAME_MAX_LENGTH = 64 export const SKILL_DESCRIPTION_MAX_LENGTH = 1024 diff --git a/apps/tradinggoose/lib/skills/operations.test.ts b/apps/tradinggoose/lib/skills/operations.test.ts index fb80f3bb7..a20d17827 100644 --- a/apps/tradinggoose/lib/skills/operations.test.ts +++ b/apps/tradinggoose/lib/skills/operations.test.ts @@ -32,17 +32,15 @@ vi.mock('nanoid', () => ({ vi.mock('@/lib/yjs/server/apply-entity-state', () => ({ applySavedEntityState: vi.fn(), - publishCreatedSavedEntityListMembers: mockNotifyEntityListMembersUpserted, })) vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ - requireSavedEntityRealtimeListFields: vi.fn(), + readSavedEntityListFieldsForExecution: vi.fn(), })) vi.mock('@/lib/yjs/server/snapshot-bridge', () => ({ deleteYjsSessionInSocketServer: vi.fn(), - notifyEntityListMemberRemoved: vi.fn(), - notifyEntityListMembersUpserted: mockNotifyEntityListMembersUpserted, + refreshEntityListSession: mockNotifyEntityListMembersUpserted, })) import { importSkills } from '@/lib/skills/operations' diff --git a/apps/tradinggoose/lib/skills/operations.ts b/apps/tradinggoose/lib/skills/operations.ts index 5c7a31027..b6ee6cf90 100644 --- a/apps/tradinggoose/lib/skills/operations.ts +++ b/apps/tradinggoose/lib/skills/operations.ts @@ -9,14 +9,11 @@ import { type SkillTransferRecord, } from '@/lib/skills/import-export' import { generateRequestId } from '@/lib/utils' -import { - applySavedEntityState, - publishCreatedSavedEntityListMembers, -} from '@/lib/yjs/server/apply-entity-state' -import { requireSavedEntityRealtimeListFields } from '@/lib/yjs/server/bootstrap-review-target' +import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' +import { readSavedEntityListFieldsForExecution } from '@/lib/yjs/server/bootstrap-review-target' import { deleteYjsSessionInSocketServer, - notifyEntityListMemberRemoved, + refreshEntityListSession, } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('SkillsOperations') @@ -51,7 +48,7 @@ interface ImportSkillsParams { } export async function listSkills(params: { workspaceId: string }) { - const entries = await requireSavedEntityRealtimeListFields('skill', params.workspaceId) + const entries = await readSavedEntityListFieldsForExecution('skill', params.workspaceId, false) return entries.map(({ entityId, fields }) => ({ id: entityId, workspaceId: params.workspaceId, @@ -80,10 +77,8 @@ export async function deleteSkill(params: { .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), - ]) + await refreshEntityListSession('skill', params.workspaceId) + await Promise.allSettled([deleteYjsSessionInSocketServer(params.skillId)]) logger.info(`Deleted skill ${params.skillId}`) return true @@ -141,11 +136,7 @@ export async function createSkills({ return createdSkills }) - await publishCreatedSavedEntityListMembers( - 'skill', - workspaceId, - created.map((createdSkill) => ({ id: createdSkill.id, name: createdSkill.name })) - ) + await refreshEntityListSession('skill', workspaceId) logger.info(`[${requestId}] Created ${created.length} skill(s)`) return created } @@ -228,11 +219,7 @@ export async function importSkills({ } }) - await publishCreatedSavedEntityListMembers( - 'skill', - workspaceId, - result.skills.map((importedSkill) => ({ id: importedSkill.id, name: importedSkill.name })) - ) + await refreshEntityListSession('skill', workspaceId) logger.info(`[${requestId}] Imported ${result.skills.length} skill(s)`, { workspaceId, renamedCount: result.renamedCount, diff --git a/apps/tradinggoose/lib/skills/types.ts b/apps/tradinggoose/lib/skills/types.ts new file mode 100644 index 000000000..5b0009198 --- /dev/null +++ b/apps/tradinggoose/lib/skills/types.ts @@ -0,0 +1,10 @@ +export interface SkillDefinition { + id: string + workspaceId: string + userId: string | null + name: string + description: string + content: string + createdAt?: string + updatedAt?: string +} diff --git a/apps/tradinggoose/lib/telemetry/signoz-tradinggoose-overview.json b/apps/tradinggoose/lib/telemetry/signoz-tradinggoose-overview.json index 47fd49866..0a07b30b7 100644 --- a/apps/tradinggoose/lib/telemetry/signoz-tradinggoose-overview.json +++ b/apps/tradinggoose/lib/telemetry/signoz-tradinggoose-overview.json @@ -7,20 +7,132 @@ "variables": {}, "panelMap": {}, "layout": [ - { "h": 3, "i": "d1a2b3c4-1111-4a11-b111-000000000001", "moved": false, "static": false, "w": 3, "x": 0, "y": 0 }, - { "h": 3, "i": "d1a2b3c4-1111-4a11-b111-000000000002", "moved": false, "static": false, "w": 3, "x": 3, "y": 0 }, - { "h": 3, "i": "d1a2b3c4-1111-4a11-b111-000000000003", "moved": false, "static": false, "w": 3, "x": 6, "y": 0 }, - { "h": 3, "i": "d1a2b3c4-1111-4a11-b111-000000000004", "moved": false, "static": false, "w": 3, "x": 9, "y": 0 }, - { "h": 6, "i": "d1a2b3c4-2222-4a22-b222-000000000005", "moved": false, "static": false, "w": 6, "x": 0, "y": 3 }, - { "h": 6, "i": "d1a2b3c4-2222-4a22-b222-000000000006", "moved": false, "static": false, "w": 6, "x": 6, "y": 3 }, - { "h": 6, "i": "d1a2b3c4-3333-4a33-b333-000000000007", "moved": false, "static": false, "w": 6, "x": 0, "y": 9 }, - { "h": 6, "i": "d1a2b3c4-3333-4a33-b333-000000000008", "moved": false, "static": false, "w": 6, "x": 6, "y": 9 }, - { "h": 6, "i": "d1a2b3c4-4444-4a44-b444-000000000009", "moved": false, "static": false, "w": 6, "x": 0, "y": 15 }, - { "h": 6, "i": "d1a2b3c4-4444-4a44-b444-000000000010", "moved": false, "static": false, "w": 6, "x": 6, "y": 15 }, - { "h": 6, "i": "d1a2b3c4-5555-4a55-b555-000000000011", "moved": false, "static": false, "w": 6, "x": 0, "y": 21 }, - { "h": 6, "i": "d1a2b3c4-5555-4a55-b555-000000000012", "moved": false, "static": false, "w": 6, "x": 6, "y": 21 }, - { "h": 6, "i": "d1a2b3c4-6666-4a66-b666-000000000013", "moved": false, "static": false, "w": 6, "x": 0, "y": 27 }, - { "h": 6, "i": "d1a2b3c4-6666-4a66-b666-000000000014", "moved": false, "static": false, "w": 6, "x": 6, "y": 27 } + { + "h": 3, + "i": "d1a2b3c4-1111-4a11-b111-000000000001", + "moved": false, + "static": false, + "w": 3, + "x": 0, + "y": 0 + }, + { + "h": 3, + "i": "d1a2b3c4-1111-4a11-b111-000000000002", + "moved": false, + "static": false, + "w": 3, + "x": 3, + "y": 0 + }, + { + "h": 3, + "i": "d1a2b3c4-1111-4a11-b111-000000000003", + "moved": false, + "static": false, + "w": 3, + "x": 6, + "y": 0 + }, + { + "h": 3, + "i": "d1a2b3c4-1111-4a11-b111-000000000004", + "moved": false, + "static": false, + "w": 3, + "x": 9, + "y": 0 + }, + { + "h": 6, + "i": "d1a2b3c4-2222-4a22-b222-000000000005", + "moved": false, + "static": false, + "w": 6, + "x": 0, + "y": 3 + }, + { + "h": 6, + "i": "d1a2b3c4-2222-4a22-b222-000000000006", + "moved": false, + "static": false, + "w": 6, + "x": 6, + "y": 3 + }, + { + "h": 6, + "i": "d1a2b3c4-3333-4a33-b333-000000000007", + "moved": false, + "static": false, + "w": 6, + "x": 0, + "y": 9 + }, + { + "h": 6, + "i": "d1a2b3c4-3333-4a33-b333-000000000008", + "moved": false, + "static": false, + "w": 6, + "x": 6, + "y": 9 + }, + { + "h": 6, + "i": "d1a2b3c4-4444-4a44-b444-000000000009", + "moved": false, + "static": false, + "w": 6, + "x": 0, + "y": 15 + }, + { + "h": 6, + "i": "d1a2b3c4-4444-4a44-b444-000000000010", + "moved": false, + "static": false, + "w": 6, + "x": 6, + "y": 15 + }, + { + "h": 6, + "i": "d1a2b3c4-5555-4a55-b555-000000000011", + "moved": false, + "static": false, + "w": 6, + "x": 0, + "y": 21 + }, + { + "h": 6, + "i": "d1a2b3c4-5555-4a55-b555-000000000012", + "moved": false, + "static": false, + "w": 6, + "x": 6, + "y": 21 + }, + { + "h": 6, + "i": "d1a2b3c4-6666-4a66-b666-000000000013", + "moved": false, + "static": false, + "w": 6, + "x": 0, + "y": 27 + }, + { + "h": 6, + "i": "d1a2b3c4-6666-4a66-b666-000000000014", + "moved": false, + "static": false, + "w": 6, + "x": 6, + "y": 27 + } ], "widgets": [ { @@ -48,15 +160,46 @@ "timePreferance": "GLOBAL_TIME", "yAxisUnit": "none", "selectedLogFields": [ - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "timestamp", "signal": "logs", "type": "log" }, - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "body", "signal": "logs", "type": "log" } + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "timestamp", + "signal": "logs", + "type": "log" + }, + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "body", + "signal": "logs", + "type": "log" + } ], "selectedTracesFields": [ - { "fieldContext": "resource", "fieldDataType": "string", "name": "service.name", "signal": "traces" }, + { + "fieldContext": "resource", + "fieldDataType": "string", + "name": "service.name", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "string", "name": "name", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "duration_nano", "signal": "traces" }, + { + "fieldContext": "span", + "fieldDataType": "", + "name": "duration_nano", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "", "name": "http_method", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "response_status_code", "signal": "traces" } + { + "fieldContext": "span", + "fieldDataType": "", + "name": "response_status_code", + "signal": "traces" + } ], "query": { "builder": { "queryData": [], "queryFormulas": [], "queryTraceOperator": [] }, @@ -99,15 +242,46 @@ "timePreferance": "GLOBAL_TIME", "yAxisUnit": "none", "selectedLogFields": [ - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "timestamp", "signal": "logs", "type": "log" }, - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "body", "signal": "logs", "type": "log" } + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "timestamp", + "signal": "logs", + "type": "log" + }, + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "body", + "signal": "logs", + "type": "log" + } ], "selectedTracesFields": [ - { "fieldContext": "resource", "fieldDataType": "string", "name": "service.name", "signal": "traces" }, + { + "fieldContext": "resource", + "fieldDataType": "string", + "name": "service.name", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "string", "name": "name", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "duration_nano", "signal": "traces" }, + { + "fieldContext": "span", + "fieldDataType": "", + "name": "duration_nano", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "", "name": "http_method", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "response_status_code", "signal": "traces" } + { + "fieldContext": "span", + "fieldDataType": "", + "name": "response_status_code", + "signal": "traces" + } ], "query": { "builder": { "queryData": [], "queryFormulas": [], "queryTraceOperator": [] }, @@ -150,15 +324,46 @@ "timePreferance": "GLOBAL_TIME", "yAxisUnit": "none", "selectedLogFields": [ - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "timestamp", "signal": "logs", "type": "log" }, - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "body", "signal": "logs", "type": "log" } + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "timestamp", + "signal": "logs", + "type": "log" + }, + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "body", + "signal": "logs", + "type": "log" + } ], "selectedTracesFields": [ - { "fieldContext": "resource", "fieldDataType": "string", "name": "service.name", "signal": "traces" }, + { + "fieldContext": "resource", + "fieldDataType": "string", + "name": "service.name", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "string", "name": "name", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "duration_nano", "signal": "traces" }, + { + "fieldContext": "span", + "fieldDataType": "", + "name": "duration_nano", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "", "name": "http_method", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "response_status_code", "signal": "traces" } + { + "fieldContext": "span", + "fieldDataType": "", + "name": "response_status_code", + "signal": "traces" + } ], "query": { "builder": { "queryData": [], "queryFormulas": [], "queryTraceOperator": [] }, @@ -201,15 +406,46 @@ "timePreferance": "GLOBAL_TIME", "yAxisUnit": "none", "selectedLogFields": [ - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "timestamp", "signal": "logs", "type": "log" }, - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "body", "signal": "logs", "type": "log" } + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "timestamp", + "signal": "logs", + "type": "log" + }, + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "body", + "signal": "logs", + "type": "log" + } ], "selectedTracesFields": [ - { "fieldContext": "resource", "fieldDataType": "string", "name": "service.name", "signal": "traces" }, + { + "fieldContext": "resource", + "fieldDataType": "string", + "name": "service.name", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "string", "name": "name", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "duration_nano", "signal": "traces" }, + { + "fieldContext": "span", + "fieldDataType": "", + "name": "duration_nano", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "", "name": "http_method", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "response_status_code", "signal": "traces" } + { + "fieldContext": "span", + "fieldDataType": "", + "name": "response_status_code", + "signal": "traces" + } ], "query": { "builder": { "queryData": [], "queryFormulas": [], "queryTraceOperator": [] }, @@ -252,15 +488,46 @@ "timePreferance": "GLOBAL_TIME", "yAxisUnit": "none", "selectedLogFields": [ - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "timestamp", "signal": "logs", "type": "log" }, - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "body", "signal": "logs", "type": "log" } + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "timestamp", + "signal": "logs", + "type": "log" + }, + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "body", + "signal": "logs", + "type": "log" + } ], "selectedTracesFields": [ - { "fieldContext": "resource", "fieldDataType": "string", "name": "service.name", "signal": "traces" }, + { + "fieldContext": "resource", + "fieldDataType": "string", + "name": "service.name", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "string", "name": "name", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "duration_nano", "signal": "traces" }, + { + "fieldContext": "span", + "fieldDataType": "", + "name": "duration_nano", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "", "name": "http_method", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "response_status_code", "signal": "traces" } + { + "fieldContext": "span", + "fieldDataType": "", + "name": "response_status_code", + "signal": "traces" + } ], "query": { "builder": { "queryData": [], "queryFormulas": [], "queryTraceOperator": [] }, @@ -303,15 +570,46 @@ "timePreferance": "GLOBAL_TIME", "yAxisUnit": "ms", "selectedLogFields": [ - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "timestamp", "signal": "logs", "type": "log" }, - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "body", "signal": "logs", "type": "log" } + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "timestamp", + "signal": "logs", + "type": "log" + }, + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "body", + "signal": "logs", + "type": "log" + } ], "selectedTracesFields": [ - { "fieldContext": "resource", "fieldDataType": "string", "name": "service.name", "signal": "traces" }, + { + "fieldContext": "resource", + "fieldDataType": "string", + "name": "service.name", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "string", "name": "name", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "duration_nano", "signal": "traces" }, + { + "fieldContext": "span", + "fieldDataType": "", + "name": "duration_nano", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "", "name": "http_method", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "response_status_code", "signal": "traces" } + { + "fieldContext": "span", + "fieldDataType": "", + "name": "response_status_code", + "signal": "traces" + } ], "query": { "builder": { "queryData": [], "queryFormulas": [], "queryTraceOperator": [] }, @@ -360,15 +658,46 @@ "timePreferance": "GLOBAL_TIME", "yAxisUnit": "none", "selectedLogFields": [ - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "timestamp", "signal": "logs", "type": "log" }, - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "body", "signal": "logs", "type": "log" } + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "timestamp", + "signal": "logs", + "type": "log" + }, + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "body", + "signal": "logs", + "type": "log" + } ], "selectedTracesFields": [ - { "fieldContext": "resource", "fieldDataType": "string", "name": "service.name", "signal": "traces" }, + { + "fieldContext": "resource", + "fieldDataType": "string", + "name": "service.name", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "string", "name": "name", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "duration_nano", "signal": "traces" }, + { + "fieldContext": "span", + "fieldDataType": "", + "name": "duration_nano", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "", "name": "http_method", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "response_status_code", "signal": "traces" } + { + "fieldContext": "span", + "fieldDataType": "", + "name": "response_status_code", + "signal": "traces" + } ], "query": { "builder": { "queryData": [], "queryFormulas": [], "queryTraceOperator": [] }, @@ -411,15 +740,46 @@ "timePreferance": "GLOBAL_TIME", "yAxisUnit": "none", "selectedLogFields": [ - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "timestamp", "signal": "logs", "type": "log" }, - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "body", "signal": "logs", "type": "log" } + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "timestamp", + "signal": "logs", + "type": "log" + }, + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "body", + "signal": "logs", + "type": "log" + } ], "selectedTracesFields": [ - { "fieldContext": "resource", "fieldDataType": "string", "name": "service.name", "signal": "traces" }, + { + "fieldContext": "resource", + "fieldDataType": "string", + "name": "service.name", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "string", "name": "name", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "duration_nano", "signal": "traces" }, + { + "fieldContext": "span", + "fieldDataType": "", + "name": "duration_nano", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "", "name": "http_method", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "response_status_code", "signal": "traces" } + { + "fieldContext": "span", + "fieldDataType": "", + "name": "response_status_code", + "signal": "traces" + } ], "query": { "builder": { "queryData": [], "queryFormulas": [], "queryTraceOperator": [] }, @@ -462,15 +822,46 @@ "timePreferance": "GLOBAL_TIME", "yAxisUnit": "none", "selectedLogFields": [ - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "timestamp", "signal": "logs", "type": "log" }, - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "body", "signal": "logs", "type": "log" } + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "timestamp", + "signal": "logs", + "type": "log" + }, + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "body", + "signal": "logs", + "type": "log" + } ], "selectedTracesFields": [ - { "fieldContext": "resource", "fieldDataType": "string", "name": "service.name", "signal": "traces" }, + { + "fieldContext": "resource", + "fieldDataType": "string", + "name": "service.name", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "string", "name": "name", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "duration_nano", "signal": "traces" }, + { + "fieldContext": "span", + "fieldDataType": "", + "name": "duration_nano", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "", "name": "http_method", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "response_status_code", "signal": "traces" } + { + "fieldContext": "span", + "fieldDataType": "", + "name": "response_status_code", + "signal": "traces" + } ], "query": { "builder": { "queryData": [], "queryFormulas": [], "queryTraceOperator": [] }, @@ -513,15 +904,46 @@ "timePreferance": "GLOBAL_TIME", "yAxisUnit": "none", "selectedLogFields": [ - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "timestamp", "signal": "logs", "type": "log" }, - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "body", "signal": "logs", "type": "log" } + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "timestamp", + "signal": "logs", + "type": "log" + }, + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "body", + "signal": "logs", + "type": "log" + } ], "selectedTracesFields": [ - { "fieldContext": "resource", "fieldDataType": "string", "name": "service.name", "signal": "traces" }, + { + "fieldContext": "resource", + "fieldDataType": "string", + "name": "service.name", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "string", "name": "name", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "duration_nano", "signal": "traces" }, + { + "fieldContext": "span", + "fieldDataType": "", + "name": "duration_nano", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "", "name": "http_method", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "response_status_code", "signal": "traces" } + { + "fieldContext": "span", + "fieldDataType": "", + "name": "response_status_code", + "signal": "traces" + } ], "query": { "builder": { "queryData": [], "queryFormulas": [], "queryTraceOperator": [] }, @@ -564,15 +986,46 @@ "timePreferance": "GLOBAL_TIME", "yAxisUnit": "none", "selectedLogFields": [ - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "timestamp", "signal": "logs", "type": "log" }, - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "body", "signal": "logs", "type": "log" } + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "timestamp", + "signal": "logs", + "type": "log" + }, + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "body", + "signal": "logs", + "type": "log" + } ], "selectedTracesFields": [ - { "fieldContext": "resource", "fieldDataType": "string", "name": "service.name", "signal": "traces" }, + { + "fieldContext": "resource", + "fieldDataType": "string", + "name": "service.name", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "string", "name": "name", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "duration_nano", "signal": "traces" }, + { + "fieldContext": "span", + "fieldDataType": "", + "name": "duration_nano", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "", "name": "http_method", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "response_status_code", "signal": "traces" } + { + "fieldContext": "span", + "fieldDataType": "", + "name": "response_status_code", + "signal": "traces" + } ], "query": { "builder": { "queryData": [], "queryFormulas": [], "queryTraceOperator": [] }, @@ -581,7 +1034,7 @@ "disabled": false, "legend": "{{event_name}}", "name": "A", - "query": "WITH __resource_filter AS (\n SELECT fingerprint\n FROM signoz_traces.distributed_traces_v3_resource\n WHERE (simpleJSONExtractString(labels, 'service.name') = 'tradinggoose-studio')\n AND seen_at_ts_bucket_start BETWEEN $start_timestamp - 1800 AND $end_timestamp\n)\nSELECT\n toStartOfInterval(timestamp, toIntervalSecond(60)) AS ts,\n name AS event_name,\n count() AS value\nFROM signoz_traces.distributed_signoz_index_v3\nWHERE resource_fingerprint GLOBAL IN __resource_filter\n AND timestamp BETWEEN $start_datetime AND $end_datetime\n AND ts_bucket_start BETWEEN $start_timestamp - 1800 AND $end_timestamp\n AND name IN (\n 'platform.workflow.created',\n 'platform.workflow.deployed',\n 'platform.workflow.undeployed',\n 'platform.workflow.executed',\n 'platform.template.used',\n 'platform.schedule.created',\n 'platform.mcp.server_added',\n 'platform.mcp.tool_executed',\n 'platform.knowledge_base.documents_uploaded'\n )\nGROUP BY ts, event_name\nORDER BY ts ASC" + "query": "WITH __resource_filter AS (\n SELECT fingerprint\n FROM signoz_traces.distributed_traces_v3_resource\n WHERE (simpleJSONExtractString(labels, 'service.name') = 'tradinggoose-studio')\n AND seen_at_ts_bucket_start BETWEEN $start_timestamp - 1800 AND $end_timestamp\n)\nSELECT\n toStartOfInterval(timestamp, toIntervalSecond(60)) AS ts,\n name AS event_name,\n count() AS value\nFROM signoz_traces.distributed_signoz_index_v3\nWHERE resource_fingerprint GLOBAL IN __resource_filter\n AND timestamp BETWEEN $start_datetime AND $end_datetime\n AND ts_bucket_start BETWEEN $start_timestamp - 1800 AND $end_timestamp\n AND name IN (\n 'platform.workflow.created',\n 'platform.workflow.deployed',\n 'platform.workflow.undeployed',\n 'platform.workflow.executed',\n 'platform.schedule.created',\n 'platform.mcp.server_added',\n 'platform.mcp.tool_executed',\n 'platform.knowledge_base.documents_uploaded'\n )\nGROUP BY ts, event_name\nORDER BY ts ASC" } ], "id": "f7a1b2c3-0011-4d11-a011-aaaaaaaaa011", @@ -615,15 +1068,46 @@ "timePreferance": "GLOBAL_TIME", "yAxisUnit": "none", "selectedLogFields": [ - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "timestamp", "signal": "logs", "type": "log" }, - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "body", "signal": "logs", "type": "log" } + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "timestamp", + "signal": "logs", + "type": "log" + }, + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "body", + "signal": "logs", + "type": "log" + } ], "selectedTracesFields": [ - { "fieldContext": "resource", "fieldDataType": "string", "name": "service.name", "signal": "traces" }, + { + "fieldContext": "resource", + "fieldDataType": "string", + "name": "service.name", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "string", "name": "name", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "duration_nano", "signal": "traces" }, + { + "fieldContext": "span", + "fieldDataType": "", + "name": "duration_nano", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "", "name": "http_method", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "response_status_code", "signal": "traces" } + { + "fieldContext": "span", + "fieldDataType": "", + "name": "response_status_code", + "signal": "traces" + } ], "query": { "builder": { "queryData": [], "queryFormulas": [], "queryTraceOperator": [] }, @@ -666,15 +1150,46 @@ "timePreferance": "GLOBAL_TIME", "yAxisUnit": "none", "selectedLogFields": [ - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "timestamp", "signal": "logs", "type": "log" }, - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "body", "signal": "logs", "type": "log" } + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "timestamp", + "signal": "logs", + "type": "log" + }, + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "body", + "signal": "logs", + "type": "log" + } ], "selectedTracesFields": [ - { "fieldContext": "resource", "fieldDataType": "string", "name": "service.name", "signal": "traces" }, + { + "fieldContext": "resource", + "fieldDataType": "string", + "name": "service.name", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "string", "name": "name", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "duration_nano", "signal": "traces" }, + { + "fieldContext": "span", + "fieldDataType": "", + "name": "duration_nano", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "", "name": "http_method", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "response_status_code", "signal": "traces" } + { + "fieldContext": "span", + "fieldDataType": "", + "name": "response_status_code", + "signal": "traces" + } ], "query": { "builder": { "queryData": [], "queryFormulas": [], "queryTraceOperator": [] }, @@ -717,15 +1232,46 @@ "timePreferance": "GLOBAL_TIME", "yAxisUnit": "none", "selectedLogFields": [ - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "timestamp", "signal": "logs", "type": "log" }, - { "dataType": "", "fieldContext": "log", "fieldDataType": "", "isIndexed": false, "name": "body", "signal": "logs", "type": "log" } + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "timestamp", + "signal": "logs", + "type": "log" + }, + { + "dataType": "", + "fieldContext": "log", + "fieldDataType": "", + "isIndexed": false, + "name": "body", + "signal": "logs", + "type": "log" + } ], "selectedTracesFields": [ - { "fieldContext": "resource", "fieldDataType": "string", "name": "service.name", "signal": "traces" }, + { + "fieldContext": "resource", + "fieldDataType": "string", + "name": "service.name", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "string", "name": "name", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "duration_nano", "signal": "traces" }, + { + "fieldContext": "span", + "fieldDataType": "", + "name": "duration_nano", + "signal": "traces" + }, { "fieldContext": "span", "fieldDataType": "", "name": "http_method", "signal": "traces" }, - { "fieldContext": "span", "fieldDataType": "", "name": "response_status_code", "signal": "traces" } + { + "fieldContext": "span", + "fieldDataType": "", + "name": "response_status_code", + "signal": "traces" + } ], "query": { "builder": { "queryData": [], "queryFormulas": [], "queryTraceOperator": [] }, diff --git a/apps/tradinggoose/lib/workflows/db-helpers.test.ts b/apps/tradinggoose/lib/workflows/db-helpers.test.ts index 57d77cfc6..3766ef463 100644 --- a/apps/tradinggoose/lib/workflows/db-helpers.test.ts +++ b/apps/tradinggoose/lib/workflows/db-helpers.test.ts @@ -111,11 +111,16 @@ vi.doMock('@/lib/logs/console/logger', () => ({ const mockReconcilePublishedChatsForDeploymentTx = vi.fn() const mockReadBootstrappedReviewTargetSnapshot = vi.fn() +const mockRefreshEntityListSession = vi.fn() vi.doMock('@/lib/yjs/server/bootstrap-review-target', () => ({ readBootstrappedReviewTargetSnapshot: mockReadBootstrappedReviewTargetSnapshot, })) +vi.doMock('@/lib/yjs/server/snapshot-bridge', () => ({ + refreshEntityListSession: mockRefreshEntityListSession, +})) + vi.doMock('@/lib/chat/published-deployment', () => ({ reconcilePublishedChatsForDeploymentTx: mockReconcilePublishedChatsForDeploymentTx, })) @@ -328,6 +333,7 @@ describe('Database Helpers', () => { beforeEach(() => { vi.clearAllMocks() mockReconcilePublishedChatsForDeploymentTx.mockResolvedValue(undefined) + mockRefreshEntityListSession.mockResolvedValue(undefined) mockDb.select.mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), @@ -339,6 +345,31 @@ describe('Database Helpers', () => { vi.resetAllMocks() }) + describe('refreshWorkflowListForWorkflow', () => { + it('refreshes the workflow list for the owning workspace', async () => { + mockDb.select.mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ workspaceId: 'workspace-1' }]), + }), + }), + }) + + await dbHelpers.refreshWorkflowListForWorkflow('workflow-1') + + expect(mockRefreshEntityListSession).toHaveBeenCalledWith('workflow', 'workspace-1') + }) + + it('does not reject when the workspace lookup fails', async () => { + mockDb.select.mockImplementationOnce(() => { + throw new Error('database unavailable') + }) + + await expect(dbHelpers.refreshWorkflowListForWorkflow('workflow-1')).resolves.toBeUndefined() + expect(mockRefreshEntityListSession).not.toHaveBeenCalled() + }) + }) + describe('loadWorkflowFromNormalizedTables', () => { it('should successfully load workflow data from normalized tables', async () => { vi.clearAllMocks() diff --git a/apps/tradinggoose/lib/workflows/db-helpers.ts b/apps/tradinggoose/lib/workflows/db-helpers.ts index ccb8769a3..a5ab4dc22 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 { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' import { inferWorkflowDirectionFromState } from '@/lib/workflows/workflow-direction' +import { refreshEntityListSession } from '@/lib/yjs/server/snapshot-bridge' import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' import { extractPersistedStateFromDoc, setWorkflowState } from '@/lib/yjs/workflow-session' import type { @@ -29,9 +30,6 @@ import { SUBFLOW_TYPES } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowDBHelpers') type PersistableWorkflowState = WorkflowState & { - name?: string - description?: string | null - folderId?: string | null variables?: Record<string, any> } @@ -81,9 +79,6 @@ const sanitizeBlockLayout = (layout: unknown): BlockState['layout'] => { } export type PersistedWorkflowState = { - name?: string | null - description?: string | null - folderId?: string | null direction?: WorkflowDirection blocks: Record<string, any> edges: any[] @@ -112,6 +107,30 @@ export const isWorkflowRealtimeRequiredError = ( error: unknown ): error is WorkflowRealtimeRequiredError => error instanceof WorkflowRealtimeRequiredError +// Workflow list sessions are live projections of workflow row metadata. +// Refresh failures must not invalidate the committed workflow row. +export async function refreshWorkflowListForWorkflow(workflowId: string): Promise<void> { + try { + const [row] = await db + .select({ + workspaceId: workflow.workspaceId, + }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + + if (!row?.workspaceId) return + + await refreshWorkflowList(row.workspaceId) + } catch (error) { + logger.warn('Failed to refresh workflow-list projection', { workflowId, error }) + } +} + +export async function refreshWorkflowList(workspaceId: string): Promise<void> { + await refreshEntityListSession('workflow', workspaceId) +} + function decodeWorkflowSnapshot(snapshotBase64: string): PersistedWorkflowState | null { const doc = new Y.Doc() try { @@ -157,9 +176,6 @@ export async function loadWorkflowBootstrapStateFromDb( const [workflowRow, normalizedState] = await Promise.all([ db .select({ - name: workflow.name, - description: workflow.description, - folderId: workflow.folderId, variables: workflow.variables, updatedAt: workflow.updatedAt, }) @@ -174,9 +190,6 @@ export async function loadWorkflowBootstrapStateFromDb( } const savedState = { - name: row.name, - description: row.description, - folderId: row.folderId, blocks: normalizedState.blocks, edges: normalizedState.edges, loops: normalizedState.loops, @@ -411,7 +424,7 @@ export interface NormalizedWorkflowData { } /** - * Regenerates all IDs in a workflow state to avoid conflicts when duplicating or using templates. + * Regenerates all IDs in a workflow state to avoid conflicts when duplicating workflows. * Returns a new state with all IDs regenerated and references updated. */ export function regenerateWorkflowStateIds(state: WorkflowState): WorkflowState { @@ -698,7 +711,7 @@ export async function saveWorkflowToNormalizedTables( state: PersistableWorkflowState ): Promise<{ success: boolean; error?: string; normalizedState?: WorkflowState }> { try { - const { name, description, folderId, variables, ...graphState } = state + const { variables, ...graphState } = state const stateWithUniqueBlockIds = await ensureUniqueBlockIds(workflowId, graphState) const stateWithUniqueEdgeIds = await ensureUniqueEdgeIds(workflowId, stateWithUniqueBlockIds) const { blocks } = sanitizeAgentToolsInBlocks(stateWithUniqueEdgeIds.blocks || {}) @@ -861,9 +874,6 @@ export async function saveWorkflowToNormalizedTables( .set({ lastSynced: savedAt, updatedAt: savedAt, - ...(name !== undefined ? { name } : {}), - ...(description !== undefined ? { description } : {}), - ...(folderId !== undefined ? { folderId } : {}), ...(variables !== undefined ? { variables } : {}), }) .where(eq(workflow.id, workflowId)) @@ -896,9 +906,6 @@ export async function saveWorkflowYjsDocToDb(workflowId: string, doc: Y.Doc): Pr 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(), } diff --git a/apps/tradinggoose/lib/workflows/import-export.ts b/apps/tradinggoose/lib/workflows/import-export.ts index d6013b4f9..649ad40a3 100644 --- a/apps/tradinggoose/lib/workflows/import-export.ts +++ b/apps/tradinggoose/lib/workflows/import-export.ts @@ -9,9 +9,9 @@ import { type SkillTransferRecord, SkillTransferSchema, } from '@/lib/skills/import-export' +import type { SkillDefinition } from '@/lib/skills/types' 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' diff --git a/apps/tradinggoose/lib/workflows/operations/deployment-utils.ts b/apps/tradinggoose/lib/workflows/operations/deployment-utils.ts index 97001d135..fe358d89c 100644 --- a/apps/tradinggoose/lib/workflows/operations/deployment-utils.ts +++ b/apps/tradinggoose/lib/workflows/operations/deployment-utils.ts @@ -1,17 +1,15 @@ import { createLogger } from '@/lib/logs/console/logger' import { getSnapshotForWorkflow } from '@/lib/yjs/workflow-session-registry' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('DeploymentUtils') /** * Build a curl -d example payload based on the API trigger input format. */ -export function getInputFormatExample(workflowId?: string): string { +export function getInputFormatExample(workflowId: string): string { let inputFormatExample = '' try { - const targetWorkflowId = workflowId || useWorkflowRegistry.getState().getActiveWorkflowId() - const snapshot = targetWorkflowId ? getSnapshotForWorkflow(targetWorkflowId) : null + const snapshot = getSnapshotForWorkflow(workflowId) const blocks = Object.values(snapshot?.blocks ?? {}) const apiTriggerBlock = blocks.find((block: any) => block.type === 'api_trigger') diff --git a/apps/tradinggoose/lib/workflows/state-builder.test.ts b/apps/tradinggoose/lib/workflows/state-builder.test.ts deleted file mode 100644 index 104f95558..000000000 --- a/apps/tradinggoose/lib/workflows/state-builder.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { buildWorkflowStateForTemplate } from '@/lib/workflows/state-builder' - -const mockGetRegisteredWorkflowSession = vi.fn() -const mockExtractPersistedStateFromDoc = vi.fn() - -vi.mock('@/lib/yjs/workflow-session-registry', () => ({ - getRegisteredWorkflowSession: (...args: any[]) => mockGetRegisteredWorkflowSession(...args), -})) - -vi.mock('@/lib/yjs/workflow-session', () => ({ - extractPersistedStateFromDoc: (...args: any[]) => mockExtractPersistedStateFromDoc(...args), -})) - -describe('buildWorkflowStateForTemplate', () => { - beforeEach(() => { - mockGetRegisteredWorkflowSession.mockReset() - mockExtractPersistedStateFromDoc.mockReset() - }) - - it('returns null when no live workflow session is registered', () => { - mockGetRegisteredWorkflowSession.mockReturnValue(null) - - expect(buildWorkflowStateForTemplate('wf-1')).toBeNull() - expect(mockExtractPersistedStateFromDoc).not.toHaveBeenCalled() - }) - - it('extracts persisted state from the live workflow doc when ready', () => { - const doc = { id: 'doc-1' } - const persistedState = { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - variables: {}, - lastSaved: 123, - } - - mockGetRegisteredWorkflowSession.mockReturnValue({ workflowId: 'wf-1', doc }) - mockExtractPersistedStateFromDoc.mockReturnValue(persistedState) - - expect(buildWorkflowStateForTemplate('wf-1')).toEqual(persistedState) - expect(mockExtractPersistedStateFromDoc).toHaveBeenCalledWith(doc) - }) -}) diff --git a/apps/tradinggoose/lib/workflows/state-builder.ts b/apps/tradinggoose/lib/workflows/state-builder.ts deleted file mode 100644 index b61d6a9f2..000000000 --- a/apps/tradinggoose/lib/workflows/state-builder.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { getRegisteredWorkflowSession } from '@/lib/yjs/workflow-session-registry' -import { extractPersistedStateFromDoc, type PersistedDocState } from '@/lib/yjs/workflow-session' - -/** - * Build workflow state in the same format as the deployment process. - * Includes variables so templates and exports preserve the full workflow. - * Reads from the live Yjs session. - */ -export function buildWorkflowStateForTemplate(workflowId: string): PersistedDocState | null { - const session = getRegisteredWorkflowSession(workflowId) - if (!session?.doc) { - return null - } - - return extractPersistedStateFromDoc(session.doc) -} diff --git a/apps/tradinggoose/lib/workflows/utils.ts b/apps/tradinggoose/lib/workflows/utils.ts index 493816cae..20b97f01f 100644 --- a/apps/tradinggoose/lib/workflows/utils.ts +++ b/apps/tradinggoose/lib/workflows/utils.ts @@ -35,8 +35,6 @@ const WORKFLOW_BASE_SELECTION = { runCount: workflowTable.runCount, lastRunAt: workflowTable.lastRunAt, variables: workflowTable.variables, - isPublished: workflowTable.isPublished, - marketplaceData: workflowTable.marketplaceData, pinnedApiKeyKey: apiKey.key, pinnedApiKeyName: apiKey.name, pinnedApiKeyType: apiKey.type, diff --git a/apps/tradinggoose/lib/yjs/entity-session.test.ts b/apps/tradinggoose/lib/yjs/entity-session.test.ts new file mode 100644 index 000000000..3f5888ea2 --- /dev/null +++ b/apps/tradinggoose/lib/yjs/entity-session.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import * as Y from 'yjs' +import { getEntityListMembers, replaceEntityListSessionMembers } from './entity-session' + +describe('entity list sessions', () => { + it('orders duplicate names deterministically by id', () => { + const doc = new Y.Doc() + try { + replaceEntityListSessionMembers(doc, [ + { id: 'entity-b', name: 'Same' }, + { id: 'entity-a', name: 'Same' }, + { id: 'entity-c', name: 'Other' }, + ]) + + expect(getEntityListMembers(doc).map((member) => member.entityId)).toEqual([ + 'entity-c', + 'entity-a', + 'entity-b', + ]) + } finally { + doc.destroy() + } + }) +}) diff --git a/apps/tradinggoose/lib/yjs/entity-session.ts b/apps/tradinggoose/lib/yjs/entity-session.ts index 49534a7cc..5eda625ce 100644 --- a/apps/tradinggoose/lib/yjs/entity-session.ts +++ b/apps/tradinggoose/lib/yjs/entity-session.ts @@ -5,15 +5,18 @@ * and provides helpers to seed and read the live entity field state. * * Top-level collections: - * - "fields" (Y.Map) — entity-kind-specific field values + * - "fields" (Y.Map) — entity-kind-specific editable field values * - "metadata" (Y.Map) — session-level metadata: the resolved `workspaceId` * that owns the entity (its canonical persistence * scope), plus bootstrap-touch and identity markers. + * - "members" (Y.Map) — entity-list sessions only. List discovery metadata + * is mutated explicitly by create/update/delete flows, + * never inferred from a saved entity document. * * Entity-kind adapters: * - skill: name, description, content * - custom_tool: title, schemaText (Y.Text), codeText (Y.Text) - * - indicator: name, color, pineCode (Y.Text), inputMeta + * - indicator: name, color, pineCode (Y.Text) * - knowledge_base: name, description, chunkingConfig * - mcp_server: name, description, transport, url, headers, command, * args, env, timeout, retries, enabled @@ -39,71 +42,80 @@ export function getEntityMetadataMap(doc: Y.Doc): Y.Map<any> { export interface EntityListMember { entityId: string entityName: string + entityDescription?: string enabled?: boolean + folderId?: string | null + color?: string + createdAt?: string + updatedAt?: string + connectionStatus?: string } -export type EntityListMemberMutation = - | { op: 'upsert'; entityId: string; name: string; enabled?: boolean } - | { op: 'remove'; entityId: string } - -function getEntityListMembersMap( - doc: Y.Doc -): Y.Map<{ name: string; enabled?: boolean; deleted?: boolean }> { +function getEntityListMembersMap(doc: Y.Doc): Y.Map<{ + name: string + description?: string + enabled?: boolean + folderId?: string | null + color?: string + createdAt?: string + updatedAt?: string + connectionStatus?: string + deleted?: boolean +}> { return doc.getMap('members') } -export function getEntityListMemberFromFields( - entityKind: Exclude<ReviewEntityKind, 'workflow'>, - entityId: string, - fields: Record<string, unknown> -): { 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( +export function replaceEntityListSessionMembers( doc: Y.Doc, - members: Array<{ id: string; name: string; enabled?: boolean }> + members: Array<{ + id: string + name: string + description?: string + enabled?: boolean + folderId?: string | null + color?: string + createdAt?: string + updatedAt?: string + connectionStatus?: string + }> ): void { doc.transact(() => { const listMembers = getEntityListMembersMap(doc) + const memberIds = new Set(members.map((member) => member.id)) + listMembers.forEach((_value, entityId) => { + if (!memberIds.has(entityId)) listMembers.delete(entityId) + }) for (const member of members) { - listMembers.set(member.id, { + const next = { name: member.name, + ...(typeof member.description === 'string' ? { description: member.description } : {}), ...(typeof member.enabled === 'boolean' ? { enabled: member.enabled } : {}), - }) + ...('folderId' in member ? { folderId: member.folderId ?? null } : {}), + ...(typeof member.color === 'string' ? { color: member.color } : {}), + ...(typeof member.createdAt === 'string' ? { createdAt: member.createdAt } : {}), + ...(typeof member.updatedAt === 'string' ? { updatedAt: member.updatedAt } : {}), + ...(typeof member.connectionStatus === 'string' + ? { connectionStatus: member.connectionStatus } + : {}), + } + const current = listMembers.get(member.id) + if ( + current?.deleted || + current?.name !== next.name || + current?.description !== next.description || + current?.enabled !== next.enabled || + current?.folderId !== next.folderId || + current?.color !== next.color || + current?.createdAt !== next.createdAt || + current?.updatedAt !== next.updatedAt || + current?.connectionStatus !== next.connectionStatus + ) { + listMembers.set(member.id, next) + } } }, YJS_ORIGINS.SYSTEM) } -function applyEntityListMutation(doc: Y.Doc, mutation: EntityListMemberMutation): void { - doc.transact(() => { - getEntityListMembersMap(doc).set( - mutation.entityId, - mutation.op === 'upsert' - ? { - name: mutation.name, - deleted: false, - ...(typeof mutation.enabled === 'boolean' ? { enabled: mutation.enabled } : {}), - } - : { name: '', deleted: true } - ) - }, YJS_ORIGINS.SYSTEM) -} - -export function applyEntityListMutations( - doc: Y.Doc, - mutations: EntityListMemberMutation | EntityListMemberMutation[] -): void { - for (const mutation of Array.isArray(mutations) ? mutations : [mutations]) { - applyEntityListMutation(doc, mutation) - } -} - export function getEntityListMembers(doc: Y.Doc): EntityListMember[] { const entries: EntityListMember[] = [] getEntityListMembersMap(doc).forEach((value, entityId) => { @@ -111,10 +123,21 @@ export function getEntityListMembers(doc: Y.Doc): EntityListMember[] { entries.push({ entityId, entityName: typeof value?.name === 'string' ? value.name : '', + ...(typeof value?.description === 'string' ? { entityDescription: value.description } : {}), ...(typeof value?.enabled === 'boolean' ? { enabled: value.enabled } : {}), + ...(value && 'folderId' in value ? { folderId: value.folderId ?? null } : {}), + ...(typeof value?.color === 'string' ? { color: value.color } : {}), + ...(typeof value?.createdAt === 'string' ? { createdAt: value.createdAt } : {}), + ...(typeof value?.updatedAt === 'string' ? { updatedAt: value.updatedAt } : {}), + ...(typeof value?.connectionStatus === 'string' + ? { connectionStatus: value.connectionStatus } + : {}), }) }) - entries.sort((a, b) => a.entityName.localeCompare(b.entityName)) + entries.sort((a, b) => { + const nameOrder = a.entityName.localeCompare(b.entityName) + return nameOrder || a.entityId.localeCompare(b.entityId) + }) return entries } @@ -181,7 +204,6 @@ export function seedEntitySession(doc: Y.Doc, options: EntitySessionSeedOptions) const pineCode = new Y.Text() pineCode.insert(0, payload.pineCode ?? '') fields.set('pineCode', pineCode) - fields.set('inputMeta', payload.inputMeta ?? null) break } @@ -243,7 +265,6 @@ export function getEntityFields(doc: Y.Doc, entityKind: ReviewEntityKind): Recor result.name = fields.get('name') ?? '' result.color = fields.get('color') ?? '' result.pineCode = fields.get('pineCode')?.toString() ?? '' - result.inputMeta = fields.get('inputMeta') break case 'knowledge_base': diff --git a/apps/tradinggoose/lib/yjs/entity-state.ts b/apps/tradinggoose/lib/yjs/entity-state.ts index 08dd1351b..d7396a21d 100644 --- a/apps/tradinggoose/lib/yjs/entity-state.ts +++ b/apps/tradinggoose/lib/yjs/entity-state.ts @@ -46,10 +46,6 @@ export function savedEntityRowToFields( name: row.name ?? '', color: row.color ?? '', pineCode: row.pineCode ?? '', - inputMeta: - row.inputMeta && typeof row.inputMeta === 'object' && !Array.isArray(row.inputMeta) - ? row.inputMeta - : null, } case 'knowledge_base': return { diff --git a/apps/tradinggoose/lib/yjs/provider.test.ts b/apps/tradinggoose/lib/yjs/provider.test.ts index fda25d35d..1388d9109 100644 --- a/apps/tradinggoose/lib/yjs/provider.test.ts +++ b/apps/tradinggoose/lib/yjs/provider.test.ts @@ -2,9 +2,12 @@ * @vitest-environment jsdom */ -import * as Y from 'yjs' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ReviewTargetDescriptor, ReviewTargetRuntimeState } from '@/lib/copilot/review-sessions/types' +import type * as Y from 'yjs' +import type { + ReviewTargetDescriptor, + ReviewTargetRuntimeState, +} from '@/lib/copilot/review-sessions/types' const fetchMock = vi.fn() @@ -255,6 +258,42 @@ describe('bootstrapYjsProvider', () => { consoleErrorSpy.mockRestore() }) + it('ends read sessions on connection loss instead of resyncing a dead projection', async () => { + fetchMock.mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + + if (url === '/api/auth/socket-token') { + return jsonResponse({ token: 'token-1' }) + } + + if (url.startsWith('/api/yjs/sessions/workflow-1/snapshot?')) { + expect(url).toContain('accessMode=read') + return jsonResponse({ + snapshotBase64: '', + descriptor, + runtime, + }) + } + + throw new Error(`Unexpected fetch: ${url}`) + }) + + const { bootstrapYjsProvider } = await import('./provider') + const result = await bootstrapYjsProvider(descriptor, 'ws://localhost:3002', 'read') + const provider = result.provider as unknown as MockWebsocketProvider + + expect(result.accessMode).toBe('read') + const tokenFetches = fetchMock.mock.calls.length + + provider.emit('connection-close', null, provider) + provider.emit('connection-error', new Event('error'), provider) + await Promise.resolve() + + expect(fetchMock).toHaveBeenCalledTimes(tokenFetches) + expect(provider.connect).toHaveBeenCalledTimes(1) + expect(provider.params.token).toBe('token-1') + }) + it('requires write access on the snapshot request and waits for provider sync', async () => { fetchMock.mockImplementation(async (input: RequestInfo | URL) => { const url = typeof input === 'string' ? input : input.toString() @@ -279,5 +318,4 @@ describe('bootstrapYjsProvider', () => { expect(result.provider).toBe(providerInstances[0]) expect(providerInstances[0].params.accessMode).toBe('write') }) - }) diff --git a/apps/tradinggoose/lib/yjs/provider.ts b/apps/tradinggoose/lib/yjs/provider.ts index b337a883c..667f3d0f9 100644 --- a/apps/tradinggoose/lib/yjs/provider.ts +++ b/apps/tradinggoose/lib/yjs/provider.ts @@ -5,6 +5,7 @@ import { serializeYjsTransportEnvelope, } from '@/lib/copilot/review-sessions/identity' import type { + ReviewAccessMode, ReviewTargetDescriptor, ReviewTargetRuntimeState, } from '@/lib/copilot/review-sessions/types' @@ -16,6 +17,7 @@ export interface YjsProviderBootstrapResult { provider: WebsocketProvider descriptor: ReviewTargetDescriptor runtime: ReviewTargetRuntimeState + accessMode: ReviewAccessMode } const SOCKET_TOKEN_RETRY_MS = 1_000 @@ -38,7 +40,8 @@ async function fetchSocketToken(): Promise<string> { async function fetchSnapshot( sessionId: string, - envelopeParams: Record<string, string> + envelopeParams: Record<string, string>, + accessMode: ReviewAccessMode ): Promise<{ snapshotBase64: string descriptor: ReviewTargetDescriptor @@ -46,7 +49,7 @@ async function fetchSnapshot( }> { const params = new URLSearchParams({ ...envelopeParams, - accessMode: 'write', + accessMode, }) const res = await fetch(`/api/yjs/sessions/${encodeURIComponent(sessionId)}/snapshot?${params}`, { cache: 'no-store', @@ -101,13 +104,14 @@ export function waitForYjsSync(provider: WebsocketProvider): Promise<void> { export async function bootstrapYjsProvider( descriptor: ReviewTargetDescriptor, - wsOrigin = getDefaultWsOrigin() + wsOrigin = getDefaultWsOrigin(), + accessMode: ReviewAccessMode = 'write' ): Promise<YjsProviderBootstrapResult> { const doc = new Y.Doc() const initialEnvelope = buildYjsTransportEnvelope(descriptor) const initialEnvelopeParams = serializeYjsTransportEnvelope(initialEnvelope) - const snapshot = await fetchSnapshot(descriptor.yjsSessionId, initialEnvelopeParams) + const snapshot = await fetchSnapshot(descriptor.yjsSessionId, initialEnvelopeParams, accessMode) const resolvedDescriptor = snapshot.descriptor const runtime = snapshot.runtime @@ -123,59 +127,70 @@ export async function bootstrapYjsProvider( const token = await fetchSocketToken() const provider = new WebsocketProvider(serverUrl, resolvedDescriptor.yjsSessionId, doc, { - params: { token, accessMode: 'write', ...envelopeParams }, + params: { token, accessMode, ...envelopeParams }, connect: true, }) - let tokenRefreshInFlight: Promise<void> | null = null - let tokenRefreshRetryTimeout: ReturnType<typeof setTimeout> | null = null + // Reconnection is a write-session concern: unsaved collaborative edits must + // merge back, so the same doc resyncs with a rotated token. A read session + // subscribes to a server-owned projection whose lineage is disposable — + // connection loss ends the session, and its owner rebootstraps a fresh doc + // from a fresh snapshot instead of resyncing one the server regenerated. + // A read result is DB-fresh at resolve time (the snapshot route reseeds + // live list docs before serving) and eventually consistent afterwards: + // websocket updates stream in asynchronously, so the doc must be observed, + // not treated as a point-in-time authority after resolve. + if (accessMode === 'write') { + let tokenRefreshInFlight: Promise<void> | null = null + let tokenRefreshRetryTimeout: ReturnType<typeof setTimeout> | null = null + + const scheduleReconnectWithFreshToken = (currentProvider: WebsocketProvider) => { + if (!currentProvider.shouldConnect || tokenRefreshInFlight || tokenRefreshRetryTimeout) { + return + } - const scheduleReconnectWithFreshToken = (currentProvider: WebsocketProvider) => { - if (!currentProvider.shouldConnect || tokenRefreshInFlight || tokenRefreshRetryTimeout) { - return + // Better Auth one-time tokens are consumed on verify, so every reconnect + // must rotate the token before y-websocket attempts the next connection. + currentProvider.shouldConnect = false + tokenRefreshInFlight = (async () => { + try { + const nextToken = await fetchSocketToken() + currentProvider.params = { + token: nextToken, + accessMode, + ...envelopeParams, + } + currentProvider.connect() + } catch (error) { + console.error('[YjsProvider] Failed to refresh socket token', error) + tokenRefreshRetryTimeout = setTimeout(() => { + tokenRefreshRetryTimeout = null + scheduleReconnectWithFreshToken(currentProvider) + }, SOCKET_TOKEN_RETRY_MS) + } finally { + tokenRefreshInFlight = null + } + })() } - // Better Auth one-time tokens are consumed on verify, so every reconnect - // must rotate the token before y-websocket attempts the next connection. - currentProvider.shouldConnect = false - tokenRefreshInFlight = (async () => { - try { - const nextToken = await fetchSocketToken() - currentProvider.params = { - token: nextToken, - accessMode: 'write', - ...envelopeParams, - } - currentProvider.connect() - } catch (error) { - console.error('[YjsProvider] Failed to refresh socket token', error) - tokenRefreshRetryTimeout = setTimeout(() => { - tokenRefreshRetryTimeout = null - scheduleReconnectWithFreshToken(currentProvider) - }, SOCKET_TOKEN_RETRY_MS) - } finally { - tokenRefreshInFlight = null + provider.on( + 'connection-close', + (_event: CloseEvent | null, currentProvider: WebsocketProvider) => { + scheduleReconnectWithFreshToken(currentProvider) } - })() - } - - provider.on( - 'connection-close', - (_event: CloseEvent | null, currentProvider: WebsocketProvider) => { + ) + provider.on('connection-error', (_event: Event, currentProvider: WebsocketProvider) => { scheduleReconnectWithFreshToken(currentProvider) + }) + + try { + await waitForYjsSync(provider) + } catch (error) { + provider.disconnect() + provider.destroy() + doc.destroy() + throw error } - ) - provider.on('connection-error', (_event: Event, currentProvider: WebsocketProvider) => { - scheduleReconnectWithFreshToken(currentProvider) - }) - - try { - await waitForYjsSync(provider) - } catch (error) { - provider.disconnect() - provider.destroy() - doc.destroy() - throw error } return { @@ -183,6 +198,7 @@ export async function bootstrapYjsProvider( provider, descriptor: resolvedDescriptor, runtime, + accessMode, } } 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 22b0d25ae..9c43442d4 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.test.ts @@ -100,19 +100,14 @@ describe('applySavedEntityState', () => { 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: 'Draft Indicator', color: '#ff0000', pineCode: 'indicator("Draft")', - inputMeta: { stale: true }, }) try { @@ -121,7 +116,6 @@ describe('applySavedEntityState', () => { name: 'Canonical Indicator', color: '#ff0000', pineCode: 'indicator("Draft")', - inputMeta, }) } finally { doc.destroy() @@ -131,7 +125,6 @@ describe('applySavedEntityState', () => { name: 'Canonical Indicator', color: '#ff0000', pineCode: 'indicator("Draft")', - inputMeta, updatedAt: expect.any(Date), }) expect(mockUpdateWhere).toHaveBeenCalledWith({ diff --git a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts index bd1dde1f1..52d88fa14 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-entity-state.ts @@ -6,16 +6,13 @@ import { pineIndicators, skill, } from '@tradinggoose/db/schema' -import { and, eq, inArray } 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, getEntityWorkspaceId, seedEntitySession } from '@/lib/yjs/entity-session' import type { SavedEntityKind } from '@/lib/yjs/entity-state' -import { - applyEntityStateInSocketServer, - notifyEntityListMembersUpserted, -} from '@/lib/yjs/server/snapshot-bridge' +import { applyEntityStateInSocketServer } from '@/lib/yjs/server/snapshot-bridge' export class SavedEntityPersistenceError extends Error { constructor( @@ -72,29 +69,6 @@ function normalizeSavedEntityFields( } } -export async function publishCreatedSavedEntityListMembers( - entityKind: SavedEntityKind, - workspaceId: string, - members: Array<{ id: string; name: string; enabled?: boolean }>, - afterRollback?: () => Promise<unknown> -): Promise<void> { - 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, @@ -146,7 +120,6 @@ async function persistSavedEntityState( name: String(fields.name ?? ''), color: String(fields.color ?? ''), pineCode: String(fields.pineCode ?? ''), - inputMeta: objectField(fields.inputMeta), updatedAt: now, }) .where(and(eq(pineIndicators.id, entityId), eq(pineIndicators.workspaceId, workspaceId))) @@ -164,26 +137,39 @@ async function persistSavedEntityState( .where(and(eq(knowledgeBase.id, entityId), eq(knowledgeBase.workspaceId, workspaceId))) .returning({ id: knowledgeBase.id }) break - case 'mcp_server': + case 'mcp_server': { + const url = String(fields.url ?? '') || null + const enabled = fields.enabled !== false + const disconnectedState = + !enabled || !url + ? { + connectionStatus: 'disconnected' as const, + lastError: null, + lastToolsRefresh: null, + toolCount: 0, + } + : {} 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, + url, 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, + enabled, updatedAt: now, + ...disconnectedState, }) .where(and(eq(mcpServers.id, entityId), eq(mcpServers.workspaceId, workspaceId))) .returning({ id: mcpServers.id }) break + } } if (persisted.length === 0) { 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 0c0a5eaba..9ed51a3ff 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.test.ts @@ -4,41 +4,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockApplyWorkflowPatchInSocketServer, - mockDbUpdate, - mockDbSelect, - mockEnsureUniqueBlockIds, - mockEnsureUniqueEdgeIds, - mockSelectFrom, - mockSelectLimit, - mockSelectWhere, -} = vi.hoisted(() => { - return { - mockApplyWorkflowPatchInSocketServer: vi.fn(), - mockDbUpdate: vi.fn(), - mockDbSelect: vi.fn(), - mockEnsureUniqueBlockIds: vi.fn(), - mockEnsureUniqueEdgeIds: vi.fn(), - mockSelectFrom: vi.fn(), - mockSelectLimit: vi.fn(), - mockSelectWhere: vi.fn(), - } -}) - -vi.mock('@tradinggoose/db', () => ({ - db: { - select: mockDbSelect, - update: mockDbUpdate, - }, - workflow: { - id: 'workflow.id', - }, -})) - -vi.mock('drizzle-orm', () => ({ - eq: vi.fn((field, value) => ({ field, value })), -})) +const { mockApplyWorkflowPatchInSocketServer, mockEnsureUniqueBlockIds, mockEnsureUniqueEdgeIds } = + vi.hoisted(() => { + return { + mockApplyWorkflowPatchInSocketServer: vi.fn(), + mockEnsureUniqueBlockIds: vi.fn(), + mockEnsureUniqueEdgeIds: vi.fn(), + } + }) vi.mock('@/lib/workflows/db-helpers', () => ({ ensureUniqueBlockIds: mockEnsureUniqueBlockIds, @@ -69,33 +42,6 @@ describe('applyWorkflowState', () => { mockApplyWorkflowPatchInSocketServer.mockResolvedValue(undefined) mockEnsureUniqueBlockIds.mockImplementation(async (_workflowId, state) => state) mockEnsureUniqueEdgeIds.mockImplementation(async (_workflowId, state) => state) - mockSelectLimit.mockResolvedValue([{ id: 'workflow-1', name: 'Renamed Workflow' }]) - mockSelectWhere.mockReturnValue({ limit: mockSelectLimit }) - mockSelectFrom.mockReturnValue({ where: mockSelectWhere }) - mockDbSelect.mockReturnValue({ from: mockSelectFrom }) - }) - - it('updates workflow entity metadata through the socket-owned Yjs document', async () => { - const { applyWorkflowMetadata } = await import('./apply-workflow-state') - - const updatedWorkflow = await applyWorkflowMetadata('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(mockDbUpdate).not.toHaveBeenCalled() - expect(mockDbSelect.mock.invocationCallOrder[0]).toBeGreaterThan( - mockApplyWorkflowPatchInSocketServer.mock.invocationCallOrder[0] - ) - expect(updatedWorkflow).toMatchObject({ id: 'workflow-1', name: 'Renamed Workflow' }) }) it('publishes normalized workflow state to the socket-owned Yjs document', async () => { @@ -136,8 +82,7 @@ describe('applyWorkflowState', () => { loops: {}, parallels: {}, }, - {}, - { name: 'Workflow Name' } + {} ) expect(mockApplyWorkflowPatchInSocketServer).toHaveBeenCalledWith( @@ -149,13 +94,11 @@ describe('applyWorkflowState', () => { }, }), variables: {}, - metadata: { name: 'Workflow Name' }, }) ) - expect(mockDbUpdate).not.toHaveBeenCalled() }) - it('does not commit workflow DB changes when the Yjs socket apply fails', async () => { + it('surfaces socket apply failures', async () => { mockApplyWorkflowPatchInSocketServer.mockRejectedValueOnce(new TypeError('fetch failed')) const { applyWorkflowState } = await import('./apply-workflow-state') @@ -163,7 +106,5 @@ describe('applyWorkflowState', () => { await expect(applyWorkflowState('workflow-1', emptyWorkflowState, {})).rejects.toThrow( 'fetch failed' ) - - 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 bcc6a3c87..bf229a0f8 100644 --- a/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts +++ b/apps/tradinggoose/lib/yjs/server/apply-workflow-state.ts @@ -1,22 +1,15 @@ -import { db, workflow } from '@tradinggoose/db' -import { eq } from 'drizzle-orm' import { ensureUniqueBlockIds, ensureUniqueEdgeIds, WorkflowRealtimeRequiredError, } from '@/lib/workflows/db-helpers' import { applyWorkflowPatchInSocketServer } from '@/lib/yjs/server/snapshot-bridge' -import { - createWorkflowSnapshot, - type WorkflowMetadataPatch, - type WorkflowSnapshot, -} from '@/lib/yjs/workflow-session' +import { createWorkflowSnapshot, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' export async function applyWorkflowState( workflowId: string, workflowState: WorkflowSnapshot, - variables?: Record<string, any>, - metadata?: WorkflowMetadataPatch + variables?: Record<string, any> ): Promise<void> { const syncedAt = new Date() const appliedWorkflowState = createWorkflowSnapshot({ @@ -37,31 +30,8 @@ export async function applyWorkflowState( 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<typeof workflow.$inferSelect> { - try { - await applyWorkflowPatchInSocketServer(workflowId, { metadata }) - } catch (error) { - throw new WorkflowRealtimeRequiredError(error) - } - - const [updatedWorkflow] = await db - .select() - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - if (!updatedWorkflow) { - throw new Error('Workflow not found') - } - - return updatedWorkflow -} 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..df3cf9253 --- /dev/null +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' +import { getEntityListMembers } from '@/lib/yjs/entity-session' +import { reseedEntityListSessionFromDb } from './bootstrap-review-target' + +const { readEntityListMembersFromDb } = vi.hoisted(() => ({ + readEntityListMembersFromDb: vi.fn(), +})) + +vi.mock('@/lib/yjs/server/entity-loaders', () => ({ + readEntityListMembersFromDb, + readSavedEntityFieldsFromDb: vi.fn(), + resolveEntityWorkspaceId: vi.fn(), +})) + +function deferred<T>() { + let resolve!: (value: T) => void + const promise = new Promise<T>((done) => { + resolve = done + }) + return { promise, resolve } +} + +async function flushMicrotasks() { + await Promise.resolve() + await Promise.resolve() +} + +describe('reseedEntityListSessionFromDb', () => { + beforeEach(() => { + readEntityListMembersFromDb.mockReset() + }) + + it('serializes destructive full reseeds for the same list document', async () => { + const olderSnapshot = deferred<Array<{ id: string; name: string }>>() + const newerSnapshot = deferred<Array<{ id: string; name: string }>>() + readEntityListMembersFromDb + .mockReturnValueOnce(olderSnapshot.promise) + .mockReturnValueOnce(newerSnapshot.promise) + + const doc = new Y.Doc() + try { + const first = reseedEntityListSessionFromDb(doc, 'workflow', 'workspace-1') + const second = reseedEntityListSessionFromDb(doc, 'workflow', 'workspace-1') + + await flushMicrotasks() + expect(readEntityListMembersFromDb).toHaveBeenCalledTimes(1) + + olderSnapshot.resolve([{ id: 'workflow-1', name: 'Workflow 1' }]) + await first + await flushMicrotasks() + expect(readEntityListMembersFromDb).toHaveBeenCalledTimes(2) + + newerSnapshot.resolve([ + { id: 'workflow-1', name: 'Workflow 1' }, + { id: 'workflow-2', name: 'Workflow 2' }, + ]) + await second + + expect(getEntityListMembers(doc).map((member) => member.entityId)).toEqual([ + 'workflow-1', + 'workflow-2', + ]) + } finally { + doc.destroy() + } + }) + + it('reports the current reseed failure while allowing the next queued reseed to run', async () => { + readEntityListMembersFromDb + .mockRejectedValueOnce(new Error('database unavailable')) + .mockResolvedValueOnce([{ id: 'workflow-2', name: 'Workflow 2' }]) + + const doc = new Y.Doc() + try { + const first = reseedEntityListSessionFromDb(doc, 'workflow', 'workspace-1') + const second = reseedEntityListSessionFromDb(doc, 'workflow', 'workspace-1') + + await expect(first).rejects.toThrow('database unavailable') + await second + + expect(readEntityListMembersFromDb).toHaveBeenCalledTimes(2) + expect(getEntityListMembers(doc).map((member) => member.entityId)).toEqual(['workflow-2']) + } finally { + doc.destroy() + } + }) +}) diff --git a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts index 412db1f37..1f597030c 100644 --- a/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts +++ b/apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts @@ -8,6 +8,7 @@ import { import { getReviewTargetRuntimeState } from '@/lib/copilot/review-sessions/runtime' import type { ResolvedReviewTarget, + ReviewEntityKind, ReviewTargetDescriptor, ReviewTargetRuntimeState, } from '@/lib/copilot/review-sessions/types' @@ -16,7 +17,7 @@ import { type EntityListMember, getEntityFields, getEntityListMembers, - seedEntityListSession, + replaceEntityListSessionMembers, seedEntitySession, } from '@/lib/yjs/entity-session' import { type SavedEntityKind, SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' @@ -44,6 +45,8 @@ export class ReviewTargetBootstrapError extends Error { } } +const entityListReseedQueues = new WeakMap<Y.Doc, Promise<void>>() + export function getRuntimeStateFromDoc(doc: Y.Doc): ReviewTargetRuntimeState { return getReviewTargetRuntimeState(doc) } @@ -65,15 +68,21 @@ function mapSavedEntitySnapshotError(error: unknown): never { throw new SavedEntityRealtimeRequiredError() } +function isNotFoundError(error: unknown): boolean { + return ( + typeof error === 'object' && error !== null && (error as { status?: number }).status === 404 + ) +} + export async function readBootstrappedReviewTargetSnapshot(descriptor: ReviewTargetDescriptor) { const bridgeParams = serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor)) return getYjsSnapshot(descriptor.yjsSessionId, bridgeParams) } -export async function requireSavedEntityRealtimeListMembers( +async function readLiveSavedEntityListFieldsForExecution( entityKind: SavedEntityKind, workspaceId: string -): Promise<EntityListMember[]> { +): Promise<Array<EntityListMember & { fields: Record<string, unknown> }>> { const snapshot = await readBootstrappedReviewTargetSnapshot( buildEntityListDescriptor(entityKind, workspaceId) ).catch(mapSavedEntitySnapshotError) @@ -84,31 +93,66 @@ export async function requireSavedEntityRealtimeListMembers( const doc = new Y.Doc() try { Y.applyUpdate(doc, Buffer.from(snapshot.snapshotBase64, 'base64')) - const activeEntityIds = new Set( - (await readEntityListMembersFromDb(entityKind, workspaceId)).map((member) => member.id) + const entries = await Promise.all( + getEntityListMembers(doc).map(async (member) => { + try { + return { + ...member, + fields: await readBootstrappedSavedEntityFields( + entityKind, + member.entityId, + workspaceId + ), + } + } catch (error) { + if (isNotFoundError(error)) return null + throw error + } + }) + ) + + return entries.filter( + (entry): entry is EntityListMember & { fields: Record<string, unknown> } => entry !== null ) - return getEntityListMembers(doc).filter((member) => activeEntityIds.has(member.entityId)) } finally { doc.destroy() } } -export async function requireSavedEntityRealtimeListFields( +export async function readSavedEntityFieldsForExecution( entityKind: SavedEntityKind, - workspaceId: string + entityId: string, + workspaceId: string, + isDeployedContext: boolean +): Promise<Record<string, unknown>> { + return isDeployedContext + ? readSavedEntityFieldsFromDb(entityKind, entityId, workspaceId) + : readBootstrappedSavedEntityFields(entityKind, entityId, workspaceId) +} + +export async function readSavedEntityListFieldsForExecution( + entityKind: SavedEntityKind, + workspaceId: string, + isDeployedContext: boolean ): Promise<Array<EntityListMember & { fields: Record<string, unknown> }>> { - const members = await requireSavedEntityRealtimeListMembers(entityKind, workspaceId) + if (!isDeployedContext) { + return readLiveSavedEntityListFieldsForExecution(entityKind, workspaceId) + } + + const members = await readEntityListMembersFromDb(entityKind, workspaceId) const entries = await Promise.all( members.map(async (member) => { try { return { - ...member, - fields: await readBootstrappedSavedEntityFields(entityKind, member.entityId, workspaceId), + entityId: member.id, + entityName: member.name, + ...(typeof member.enabled === 'boolean' ? { enabled: member.enabled } : {}), + ...('folderId' in member ? { folderId: member.folderId ?? null } : {}), + ...(typeof member.color === 'string' ? { color: member.color } : {}), + fields: await readSavedEntityFieldsFromDb(entityKind, member.id, workspaceId), } } catch (error) { - if (error instanceof ReviewTargetBootstrapError && error.status === 404) { - return null - } + if (isNotFoundError(error)) return null throw error } }) @@ -149,18 +193,12 @@ 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 let resolvedWorkspaceId: string | null = descriptor.workspaceId if (descriptor.entityKind === 'workflow') { const workflowState = await loadWorkflowBootstrapStateFromDb(descriptor.entityId) if (!workflowState) { throw new ReviewTargetBootstrapError(404, 'Workflow not found') } - workflowName = workflowState.name - workflowDescription = workflowState.description - workflowFolderId = workflowState.folderId setWorkflowState( doc, @@ -198,15 +236,6 @@ export async function createSavedReviewTargetBootstrapUpdate( metadata.set('draftSessionId', descriptor.draftSessionId) metadata.set('reviewSessionId', descriptor.reviewSessionId) metadata.set('reseededFromCanonical', true) - 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 { @@ -220,13 +249,13 @@ export async function createSavedReviewTargetBootstrapUpdate( } export async function createEntityListBootstrapUpdate( - entityKind: SavedEntityKind, + entityKind: ReviewEntityKind, workspaceId: string ): Promise<ResolvedReviewTarget & { state: Uint8Array }> { const descriptor = buildEntityListDescriptor(entityKind, workspaceId) const doc = new Y.Doc() try { - seedEntityListSession(doc, await readEntityListMembersFromDb(entityKind, workspaceId)) + replaceEntityListSessionMembers(doc, await readEntityListMembersFromDb(entityKind, workspaceId)) const metadata = getMetadataMap(doc) metadata.set('reseededFromCanonical', true) @@ -241,3 +270,23 @@ export async function createEntityListBootstrapUpdate( doc.destroy() } } + +export async function reseedEntityListSessionFromDb( + doc: Y.Doc, + entityKind: ReviewEntityKind, + workspaceId: string +): Promise<void> { + const previous = entityListReseedQueues.get(doc) ?? Promise.resolve() + const reseed = previous.then(async () => { + replaceEntityListSessionMembers(doc, await readEntityListMembersFromDb(entityKind, workspaceId)) + }) + const tail = reseed.catch(() => undefined) + entityListReseedQueues.set(doc, tail) + try { + await reseed + } finally { + if (entityListReseedQueues.get(doc) === tail) { + entityListReseedQueues.delete(doc) + } + } +} diff --git a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts index 6af4af6a3..db3e40f04 100644 --- a/apps/tradinggoose/lib/yjs/server/entity-loaders.ts +++ b/apps/tradinggoose/lib/yjs/server/entity-loaders.ts @@ -5,8 +5,10 @@ import { mcpServers, pineIndicators, skill, + workflow, } from '@tradinggoose/db/schema' -import { and, eq, isNull, type SQL } from 'drizzle-orm' +import { and, asc, eq, isNull, type SQL } from 'drizzle-orm' +import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' import { type SavedEntityKind, type SavedEntityRow, @@ -54,19 +56,112 @@ export async function resolveEntityWorkspaceId( } export async function readEntityListMembersFromDb( - entityKind: SavedEntityKind, + entityKind: ReviewEntityKind, workspaceId: string -): Promise<Array<{ id: string; name: string; enabled?: boolean }>> { +): Promise< + Array<{ + id: string + name: string + description?: string + enabled?: boolean + folderId?: string | null + color?: string + createdAt?: string + updatedAt?: string + connectionStatus?: string + }> +> { + if (entityKind === 'workflow') { + const rows = await db + .select({ + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderId: workflow.folderId, + color: workflow.color, + createdAt: workflow.createdAt, + }) + .from(workflow) + .where(eq(workflow.workspaceId, workspaceId)) + .orderBy(asc(workflow.name), asc(workflow.id)) + + return rows.map((row) => ({ + id: row.id, + name: row.name, + description: row.description ?? undefined, + folderId: row.folderId, + color: row.color, + createdAt: row.createdAt?.toISOString(), + })) + } + if (entityKind === 'mcp_server') { const rows = await db - .select({ id: mcpServers.id, name: mcpServers.name, enabled: mcpServers.enabled }) + .select({ + id: mcpServers.id, + name: mcpServers.name, + enabled: mcpServers.enabled, + updatedAt: mcpServers.updatedAt, + connectionStatus: mcpServers.connectionStatus, + }) .from(mcpServers) .where(entityCondition(entityKind, [eq(mcpServers.workspaceId, workspaceId)])) + .orderBy(asc(mcpServers.name), asc(mcpServers.id)) return rows.map((row) => ({ id: row.id, name: row.name ?? '', enabled: row.enabled !== false, + updatedAt: row.updatedAt?.toISOString(), + connectionStatus: row.connectionStatus ?? 'disconnected', + })) + } + + if (entityKind === 'skill') { + const rows = await db + .select({ id: skill.id, name: skill.name, description: skill.description }) + .from(skill) + .where(entityCondition(entityKind, [eq(skill.workspaceId, workspaceId)])) + .orderBy(asc(skill.name), asc(skill.id)) + + return rows.map((row) => ({ + id: row.id, + name: row.name ?? '', + description: row.description ?? undefined, + })) + } + + if (entityKind === 'custom_tool') { + const rows = await db + .select({ id: customTools.id, name: customTools.title, schema: customTools.schema }) + .from(customTools) + .where(entityCondition(entityKind, [eq(customTools.workspaceId, workspaceId)])) + .orderBy(asc(customTools.title), asc(customTools.id)) + + return rows.map((row) => { + const schema = row.schema as { function?: { description?: unknown } } | null + return { + id: row.id, + name: row.name ?? '', + description: + typeof schema?.function?.description === 'string' + ? schema.function.description + : undefined, + } + }) + } + + if (entityKind === 'indicator') { + const rows = await db + .select({ id: pineIndicators.id, name: pineIndicators.name, color: pineIndicators.color }) + .from(pineIndicators) + .where(entityCondition(entityKind, [eq(pineIndicators.workspaceId, workspaceId)])) + .orderBy(asc(pineIndicators.name), asc(pineIndicators.id)) + + return rows.map((row) => ({ + id: row.id, + name: row.name ?? '', + ...(typeof row.color === 'string' && row.color.trim() ? { color: row.color } : {}), })) } @@ -75,6 +170,7 @@ export async function readEntityListMembersFromDb( .select({ id: table.id, name }) .from(table) .where(entityCondition(entityKind, [eq(table.workspaceId, workspaceId)])) + .orderBy(asc(name), asc(table.id)) return rows.map((row) => ({ id: row.id, name: row.name ?? '' })) } diff --git a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.test.ts b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.test.ts new file mode 100644 index 000000000..a31b3674a --- /dev/null +++ b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.test.ts @@ -0,0 +1,68 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockFetch, mockLogger } = vi.hoisted(() => ({ + mockFetch: vi.fn(), + mockLogger: { + warn: vi.fn(), + error: vi.fn(), + }, +})) + +vi.mock('@/lib/env', () => ({ + env: { INTERNAL_API_SECRET: 'internal-secret' }, + getInternalRealtimeUrl: () => 'http://socket.test', +})) + +vi.mock('@/lib/logs/console/logger', () => ({ + createLogger: vi.fn(() => mockLogger), +})) + +beforeEach(() => { + vi.resetModules() + mockFetch.mockReset() + mockLogger.warn.mockReset() + mockLogger.error.mockReset() + vi.stubGlobal('fetch', mockFetch) +}) + +describe('refreshEntityListSession', () => { + it('discards the projection so subscribers rebootstrap from DB when refresh fails', async () => { + mockFetch + .mockRejectedValueOnce(new TypeError('fetch failed')) + .mockResolvedValueOnce(new Response('{}', { status: 200 })) + + const { refreshEntityListSession } = await import('./snapshot-bridge') + + await expect(refreshEntityListSession('skill', 'workspace-1')).resolves.toBeUndefined() + + expect(mockFetch).toHaveBeenCalledTimes(2) + const [refreshUrl] = mockFetch.mock.calls[0] + expect(refreshUrl).toContain('/internal/yjs/sessions/list%3Askill%3Aworkspace-1/members') + const [discardUrl, discardInit] = mockFetch.mock.calls[1] + expect(discardUrl).toBe('http://socket.test/internal/yjs/sessions/list%3Askill%3Aworkspace-1') + expect(discardInit.method).toBe('DELETE') + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to refresh entity-list projection', + expect.objectContaining({ entityKind: 'skill', workspaceId: 'workspace-1' }) + ) + expect(mockLogger.error).not.toHaveBeenCalled() + }) + + it('never fails the committed mutation even when the discard also fails', async () => { + mockFetch.mockRejectedValue(new TypeError('fetch failed')) + + const { refreshEntityListSession } = await import('./snapshot-bridge') + + await expect(refreshEntityListSession('skill', 'workspace-1')).resolves.toBeUndefined() + + expect(mockFetch).toHaveBeenCalledTimes(2) + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to discard stale entity-list projection', + expect.objectContaining({ entityKind: 'skill', workspaceId: 'workspace-1' }) + ) + }) +}) diff --git a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts index d0f032c25..6395ef912 100644 --- a/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts +++ b/apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts @@ -1,11 +1,18 @@ -import { buildEntityListDescriptor } from '@/lib/copilot/review-sessions/identity' +import { + buildEntityListDescriptor, + buildYjsTransportEnvelope, + serializeYjsTransportEnvelope, +} from '@/lib/copilot/review-sessions/identity' import type { + ReviewEntityKind, ReviewTargetDescriptor, ReviewTargetRuntimeState, } from '@/lib/copilot/review-sessions/types' import { env, getInternalRealtimeUrl } from '@/lib/env' -import { type SavedEntityKind, SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' -import type { WorkflowMetadataPatch, WorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { createLogger } from '@/lib/logs/console/logger' +import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' + +const logger = createLogger('YjsSnapshotBridge') export interface YjsSnapshotResponse { snapshotBase64: string @@ -17,7 +24,6 @@ export interface YjsSnapshotResponse { type WorkflowPatch = { workflowState?: WorkflowSnapshot variables?: Record<string, any> - metadata?: WorkflowMetadataPatch } export class SocketServerBridgeError extends Error { @@ -151,41 +157,39 @@ export async function applyYjsUpdateInSocketServer( ) } -async function postEntityListMembersToSocketServer( - entityKind: SavedEntityKind, - workspaceId: string, - body: unknown +/** + * Converge the live entity-list projection after a committed membership + * mutation. The DB rows are canonical and the list doc is a disposable + * projection, so this never rejects: a mutation's success must not depend on + * projection fan-out. On refresh failure the projection is discarded instead, + * which closes subscriber connections so every viewer rebootstraps a fresh + * doc from canonical DB state. + */ +export async function refreshEntityListSession( + entityKind: ReviewEntityKind, + workspaceId: string ): Promise<void> { const descriptor = buildEntityListDescriptor(entityKind, workspaceId) + const params = new URLSearchParams( + serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor)) + ) try { await postJsonToSocketServer( - `/internal/yjs/sessions/${encodeURIComponent(descriptor.yjsSessionId)}/members`, - body + `/internal/yjs/sessions/${encodeURIComponent(descriptor.yjsSessionId)}/members?${params}`, + {} ) } catch (error) { - if (error instanceof SocketServerBridgeError && error.status < 500) { - throw error - } - throw new SavedEntityRealtimeRequiredError() + logger.warn('Failed to refresh entity-list projection', { entityKind, workspaceId, error }) + await deleteYjsSessionInSocketServer(descriptor.yjsSessionId).catch((discardError) => { + logger.error('Failed to discard stale entity-list projection', { + entityKind, + workspaceId, + error: discardError, + }) + }) } } -export async function notifyEntityListMembersUpserted( - entityKind: SavedEntityKind, - workspaceId: string, - members: Array<{ id: string; name: string; enabled?: boolean }> -): Promise<void> { - await postEntityListMembersToSocketServer(entityKind, workspaceId, { members }) -} - -export async function notifyEntityListMemberRemoved( - entityKind: SavedEntityKind, - workspaceId: string, - entityId: string -): Promise<void> { - await postEntityListMembersToSocketServer(entityKind, workspaceId, { remove: entityId }) -} - export async function deleteYjsSessionInSocketServer(sessionId: string): Promise<void> { await fetchFromSocketServer( new URL(`/internal/yjs/sessions/${encodeURIComponent(sessionId)}`, getSocketServerUrl()), diff --git a/apps/tradinggoose/lib/yjs/use-entity-fields.test.tsx b/apps/tradinggoose/lib/yjs/use-entity-fields.test.tsx new file mode 100644 index 000000000..4707ca3a0 --- /dev/null +++ b/apps/tradinggoose/lib/yjs/use-entity-fields.test.tsx @@ -0,0 +1,213 @@ +/** @vitest-environment jsdom */ + +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' + +const mockBootstrapYjsProvider = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/yjs/provider', () => ({ + bootstrapYjsProvider: mockBootstrapYjsProvider, +})) + +vi.mock('@/app/query-provider', () => ({ + getQueryClient: () => ({ invalidateQueries: vi.fn() }), +})) + +vi.mock('@/lib/mcp/utils', () => ({ + MCP_TOOLS_CHANGED_EVENT: 'mcp-tools-changed', +})) + +vi.mock('@/hooks/queries/custom-tools', () => ({ customToolsKeys: {} })) +vi.mock('@/hooks/queries/indicators', () => ({ indicatorKeys: {} })) +vi.mock('@/hooks/queries/knowledge', () => ({ knowledgeKeys: {} })) +vi.mock('@/hooks/queries/skills', () => ({ skillsKeys: {} })) + +import type { EntityListMember } from './entity-session' +import { useEntityList } from './use-entity-fields' + +type CapturedList = { + members: EntityListMember[] + isLoading: boolean + error: string | null +} + +function createMockSession(members: Record<string, { name: string }>) { + const doc = new Y.Doc() + doc.transact(() => { + const map = doc.getMap('members') + for (const [entityId, value] of Object.entries(members)) { + map.set(entityId, value) + } + }) + + const listeners = new Map<string, Set<(...args: any[]) => void>>() + const provider = { + on: (event: string, handler: (...args: any[]) => void) => { + const handlers = listeners.get(event) ?? new Set() + handlers.add(handler) + listeners.set(event, handlers) + }, + off: (event: string, handler: (...args: any[]) => void) => { + listeners.get(event)?.delete(handler) + }, + disconnect: vi.fn(), + destroy: vi.fn(), + } + + return { + doc, + provider, + descriptor: {}, + runtime: {}, + accessMode: 'read' as const, + emit: (event: string) => { + for (const handler of Array.from(listeners.get(event) ?? [])) { + handler() + } + }, + } +} + +let container: HTMLDivElement | null = null +let root: ReturnType<typeof createRoot> | null = null +const previousActEnvironment = (globalThis as any).IS_REACT_ACT_ENVIRONMENT + +function Harness({ + workspaceId, + capture, +}: { + workspaceId: string + capture: (value: CapturedList) => void +}) { + const { members, isLoading, error } = useEntityList('skill', workspaceId) + capture({ members, isLoading, error }) + return null +} + +async function renderList(workspaceId: string) { + const captured: { current: CapturedList | null } = { current: null } + await act(async () => { + root!.render( + <Harness workspaceId={workspaceId} capture={(value) => (captured.current = value)} /> + ) + }) + return captured +} + +beforeAll(() => { + ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true +}) + +afterAll(() => { + ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment +}) + +beforeEach(() => { + vi.useFakeTimers() + mockBootstrapYjsProvider.mockReset() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => { + root?.unmount() + }) + container?.remove() + root = null + container = null + vi.useRealTimers() +}) + +describe('useEntityList read-session lifecycle', () => { + it('replaces the session from a fresh snapshot after connection loss', async () => { + const stale = createMockSession({ 'id-1': { name: 'Alpha' }, 'id-2': { name: 'Gone' } }) + const fresh = createMockSession({ 'id-1': { name: 'Alpha' } }) + mockBootstrapYjsProvider.mockResolvedValueOnce(stale).mockResolvedValueOnce(fresh) + + const captured = await renderList('workspace-reopen') + expect(captured.current?.members.map((m) => m.entityName)).toEqual(['Alpha', 'Gone']) + + await act(async () => { + stale.emit('connection-close') + }) + expect(stale.provider.destroy).not.toHaveBeenCalled() + expect(captured.current?.isLoading).toBe(false) + expect(captured.current?.members.map((m) => m.entityName)).toEqual(['Alpha', 'Gone']) + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + }) + expect(mockBootstrapYjsProvider).toHaveBeenCalledTimes(2) + expect(stale.provider.destroy).toHaveBeenCalledTimes(1) + expect(captured.current?.members.map((m) => m.entityName)).toEqual(['Alpha']) + expect(captured.current?.error).toBeNull() + }) + + it('retries a failed initial open until the session is live', async () => { + const fresh = createMockSession({ 'id-1': { name: 'Alpha' } }) + mockBootstrapYjsProvider + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(fresh) + + const captured = await renderList('workspace-first-open') + expect(captured.current?.error).toBe('offline') + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + }) + expect(mockBootstrapYjsProvider).toHaveBeenCalledTimes(2) + expect(captured.current?.members.map((m) => m.entityName)).toEqual(['Alpha']) + expect(captured.current?.error).toBeNull() + }) + + it('keeps retrying failed reopens until the session is live again', async () => { + const stale = createMockSession({ 'id-1': { name: 'Alpha' } }) + const fresh = createMockSession({ 'id-3': { name: 'Beta' } }) + mockBootstrapYjsProvider + .mockResolvedValueOnce(stale) + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(fresh) + + const captured = await renderList('workspace-retry') + expect(captured.current?.members.map((m) => m.entityName)).toEqual(['Alpha']) + + await act(async () => { + stale.emit('connection-error') + stale.emit('connection-close') + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + }) + expect(mockBootstrapYjsProvider).toHaveBeenCalledTimes(2) + expect(captured.current?.members.map((m) => m.entityName)).toEqual(['Alpha']) + expect(captured.current?.error).toBe('offline') + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + }) + expect(mockBootstrapYjsProvider).toHaveBeenCalledTimes(3) + expect(captured.current?.members.map((m) => m.entityName)).toEqual(['Beta']) + expect(captured.current?.error).toBeNull() + }) + + it('stops reopening once the last consumer unmounts', async () => { + const stale = createMockSession({ 'id-1': { name: 'Alpha' } }) + mockBootstrapYjsProvider.mockResolvedValue(stale) + + await renderList('workspace-release') + await act(async () => { + stale.emit('connection-close') + }) + await act(async () => { + root!.unmount() + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000) + }) + expect(mockBootstrapYjsProvider).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/tradinggoose/lib/yjs/use-entity-fields.ts b/apps/tradinggoose/lib/yjs/use-entity-fields.ts index 35681a36b..50eade76b 100644 --- a/apps/tradinggoose/lib/yjs/use-entity-fields.ts +++ b/apps/tradinggoose/lib/yjs/use-entity-fields.ts @@ -9,19 +9,29 @@ */ import { useCallback, useEffect, useMemo, useState } from 'react' -import { useQueryClient } from '@tanstack/react-query' import * as Y from 'yjs' import { + buildEntityListDescriptor, buildSavedEntityDescriptor, buildYjsTransportEnvelope, serializeYjsTransportEnvelope, } from '@/lib/copilot/review-sessions/identity' -import { getFieldsMap, replaceEntityTextField, setEntityField } from '@/lib/yjs/entity-session' +import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' +import { MCP_TOOLS_CHANGED_EVENT } from '@/lib/mcp/utils' +import { + type EntityListMember, + getEntityListMembers, + 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 { 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' type SavedEntityYjsSessionState = { @@ -30,13 +40,204 @@ type SavedEntityYjsSessionState = { error: string | null } -export function useSavedEntityYjsSession( +type SharedYjsSessionEntry = { + key: string + result: YjsProviderBootstrapResult | null + error: string | null + refCount: number + initPromise: Promise<void> | null + listeners: Set<() => void> +} + +const sharedYjsSessionEntries = new Map<string, SharedYjsSessionEntry>() + +function closeYjsSession(result: YjsProviderBootstrapResult): void { + result.provider.disconnect() + result.provider.destroy() + result.doc.destroy() +} + +async function saveYjsSessionSnapshot(result: YjsProviderBootstrapResult): Promise<void> { + const { descriptor } = result + const params = new URLSearchParams({ + ...serializeYjsTransportEnvelope(buildYjsTransportEnvelope(descriptor)), + accessMode: 'write', + }) + const update = Y.encodeStateAsUpdate(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) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error || 'Failed to save Yjs session') + } +} + +function readSharedYjsSessionEntry(entry: SharedYjsSessionEntry): SavedEntityYjsSessionState { + return { + key: entry.key, + result: entry.result, + error: entry.error, + } +} + +function emitSharedYjsSessionEntry(entry: SharedYjsSessionEntry): void { + for (const listener of entry.listeners) { + listener() + } +} + +function getSharedYjsSessionEntry(sessionKey: string): SharedYjsSessionEntry { + const current = sharedYjsSessionEntries.get(sessionKey) + if (current) return current + + const entry: SharedYjsSessionEntry = { + key: sessionKey, + result: null, + error: null, + refCount: 0, + initPromise: null, + listeners: new Set(), + } + sharedYjsSessionEntries.set(sessionKey, entry) + return entry +} + +function initializeSharedYjsSessionEntry( + entry: SharedYjsSessionEntry, + openSession: () => Promise<YjsProviderBootstrapResult>, + errorMessage: string, + staleResult?: YjsProviderBootstrapResult +): void { + if (entry.initPromise || (!staleResult && entry.result)) return + + if (!staleResult) { + entry.error = null + emitSharedYjsSessionEntry(entry) + } + entry.initPromise = openSession() + .then((next) => { + if ( + sharedYjsSessionEntries.get(entry.key) !== entry || + entry.refCount === 0 || + (staleResult && entry.result !== staleResult) + ) { + closeYjsSession(next) + return + } + + entry.result = next + entry.error = null + if (next.accessMode === 'read') { + attachReadSessionReopen(entry, next, openSession, errorMessage) + } + if (staleResult) { + emitSharedYjsSessionEntry(entry) + closeYjsSession(staleResult) + } + }) + .catch((nextError) => { + if (sharedYjsSessionEntries.get(entry.key) !== entry || entry.refCount === 0) return + entry.error = nextError instanceof Error ? nextError.message : errorMessage + }) + .finally(() => { + if (sharedYjsSessionEntries.get(entry.key) !== entry) return + entry.initPromise = null + emitSharedYjsSessionEntry(entry) + if (staleResult ? entry.result === staleResult : !entry.result) { + scheduleSharedYjsSessionReopen(entry, openSession, errorMessage, staleResult) + } + }) +} + +const SESSION_REOPEN_RETRY_MS = 1_000 + +function attachReadSessionReopen( + entry: SharedYjsSessionEntry, + result: YjsProviderBootstrapResult, + openSession: () => Promise<YjsProviderBootstrapResult>, + errorMessage: string +): void { + let handled = false + const handleConnectionLoss = () => { + if (handled) return + handled = true + result.provider.off('connection-close', handleConnectionLoss) + result.provider.off('connection-error', handleConnectionLoss) + if (sharedYjsSessionEntries.get(entry.key) !== entry || entry.result !== result) return + scheduleSharedYjsSessionReopen(entry, openSession, errorMessage, result) + } + result.provider.on('connection-close', handleConnectionLoss) + result.provider.on('connection-error', handleConnectionLoss) +} + +// A subscribed session converges to live: every failed open — initial or +// after connection loss — retries at the same 1s cadence write sessions use +// for token rotation, until the session is live or the entry is released. +function scheduleSharedYjsSessionReopen( + entry: SharedYjsSessionEntry, + openSession: () => Promise<YjsProviderBootstrapResult>, + errorMessage: string, + staleResult?: YjsProviderBootstrapResult +): void { + setTimeout(() => { + if (sharedYjsSessionEntries.get(entry.key) !== entry || entry.refCount === 0) return + initializeSharedYjsSessionEntry(entry, openSession, errorMessage, staleResult) + }, SESSION_REOPEN_RETRY_MS) +} + +function releaseSharedYjsSessionEntry(entry: SharedYjsSessionEntry): void { + entry.refCount = Math.max(0, entry.refCount - 1) + if (entry.refCount > 0) return + + sharedYjsSessionEntries.delete(entry.key) + if (entry.result) { + closeYjsSession(entry.result) + entry.result = null + } + entry.listeners.clear() +} + +function invalidateSavedEntityQueries( entityKind: SavedEntityKind, - entityId: string | null | undefined, - workspaceId: string | null | undefined + entityId: string, + workspaceId: string +): void { + const queryClient = getQueryClient() + + switch (entityKind) { + case 'skill': + void queryClient.invalidateQueries({ queryKey: skillsKeys.list(workspaceId) }) + return + case 'custom_tool': + void queryClient.invalidateQueries({ queryKey: customToolsKeys.list(workspaceId) }) + void queryClient.invalidateQueries({ queryKey: customToolsKeys.detail(entityId) }) + return + case 'indicator': + void queryClient.invalidateQueries({ queryKey: indicatorKeys.list(workspaceId) }) + void queryClient.invalidateQueries({ queryKey: indicatorKeys.detail(entityId) }) + return + case 'knowledge_base': + void queryClient.invalidateQueries({ queryKey: knowledgeKeys.list(workspaceId) }) + void queryClient.invalidateQueries({ queryKey: knowledgeKeys.detail(entityId) }) + return + case 'mcp_server': + window.dispatchEvent(new CustomEvent(MCP_TOOLS_CHANGED_EVENT, { detail: { workspaceId } })) + return + } +} + +function useYjsSession( + sessionKey: string | null, + openSession: (() => Promise<YjsProviderBootstrapResult>) | null, + errorMessage: string ) { - const queryClient = useQueryClient() - const sessionKey = entityId && workspaceId ? `${entityKind}:${workspaceId}:${entityId}` : null const [state, setState] = useState<SavedEntityYjsSessionState>({ key: null, result: null, @@ -44,74 +245,51 @@ export function useSavedEntityYjsSession( }) useEffect(() => { - setState({ key: sessionKey, result: null, error: null }) - if (!entityId || !workspaceId || !sessionKey) return - - let active = true - let current: YjsProviderBootstrapResult | null = null - - bootstrapYjsProvider(buildSavedEntityDescriptor(entityKind, entityId, workspaceId)) - .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', - }) - }) + if (!sessionKey || !openSession) { + setState({ key: sessionKey, result: null, error: null }) + return + } + + const entry = getSharedYjsSessionEntry(sessionKey) + entry.refCount += 1 + + const syncState = () => setState(readSharedYjsSessionEntry(entry)) + entry.listeners.add(syncState) + syncState() + initializeSharedYjsSessionEntry(entry, openSession, errorMessage) return () => { - active = false - current?.provider.disconnect() - current?.provider.destroy() - current?.doc.destroy() + entry.listeners.delete(syncState) + releaseSharedYjsSessionEntry(entry) } - }, [entityId, entityKind, sessionKey, workspaceId]) + }, [errorMessage, openSession, sessionKey]) + + return state.key === sessionKey ? state : null +} - const activeState = state.key === sessionKey ? state : null +export function useSavedEntityYjsSession( + entityKind: SavedEntityKind, + entityId: string | null | undefined, + workspaceId: string | null | undefined +) { + const sessionKey = entityId && workspaceId ? `${entityKind}:${workspaceId}:${entityId}` : null + const openSession = useCallback( + () => bootstrapYjsProvider(buildSavedEntityDescriptor(entityKind, entityId!, workspaceId!)), + [entityId, entityKind, workspaceId] + ) + const activeState = useYjsSession( + sessionKey, + sessionKey ? openSession : null, + 'Failed to open entity session' + ) const save = useCallback(async () => { - if (!activeState?.result || !workspaceId) { + if (!activeState?.result || !entityId || !workspaceId) { throw new Error('Yjs session is not ready') } - const { descriptor } = activeState.result - const params = new URLSearchParams({ - ...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) { - 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]) + await saveYjsSessionSnapshot(activeState.result) + invalidateSavedEntityQueries(entityKind, entityId, workspaceId) + }, [activeState?.result, entityId, entityKind, workspaceId]) return { doc: activeState?.result?.doc ?? null, @@ -121,6 +299,61 @@ export function useSavedEntityYjsSession( } } +export async function saveSavedEntityField( + entityKind: SavedEntityKind, + entityId: string, + workspaceId: string, + key: string, + value: unknown +): Promise<void> { + const result = await bootstrapYjsProvider( + buildSavedEntityDescriptor(entityKind, entityId, workspaceId) + ) + try { + setEntityField(result.doc, key, value) + await saveYjsSessionSnapshot(result) + invalidateSavedEntityQueries(entityKind, entityId, workspaceId) + } finally { + closeYjsSession(result) + } +} + +export function useEntityList( + entityKind: ReviewEntityKind, + workspaceId: string | null | undefined +) { + const sessionKey = workspaceId ? `list:${entityKind}:${workspaceId}` : null + const openSession = useCallback( + () => + bootstrapYjsProvider(buildEntityListDescriptor(entityKind, workspaceId!), undefined, 'read'), + [entityKind, workspaceId] + ) + const activeState = useYjsSession( + sessionKey, + sessionKey ? openSession : null, + 'Failed to open entity list' + ) + const doc = activeState?.result?.doc ?? null + + const subscribe = useMemo(() => { + if (!doc) return (cb: () => void) => () => {} + const members = doc.getMap('members') + return (cb: () => void) => { + members.observe(cb) + return () => members.unobserve(cb) + } + }, [doc]) + + const extract = useCallback(() => (doc ? getEntityListMembers(doc) : []), [doc]) + const members = useYjsSubscription<EntityListMember[]>(subscribe, extract, []) + + return { + members, + 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. diff --git a/apps/tradinggoose/lib/yjs/use-workflow-doc.ts b/apps/tradinggoose/lib/yjs/use-workflow-doc.ts index 166da2666..f21e2fd41 100644 --- a/apps/tradinggoose/lib/yjs/use-workflow-doc.ts +++ b/apps/tradinggoose/lib/yjs/use-workflow-doc.ts @@ -1440,13 +1440,9 @@ export function useWorkflowMutations() { } // --------------------------------------------------------------------------- -// Convenience: combined read+write hook (replaces useWorkflowStore()) +// Convenience: combined read+write hook // --------------------------------------------------------------------------- -/** - * Drop-in replacement for components that destructure both state and actions - * from useWorkflowStore(). Returns the same shape. - */ export function useWorkflowDoc() { const blocks = useWorkflowBlocks() const edges = useWorkflowEdges() diff --git a/apps/tradinggoose/lib/yjs/workflow-session-registry.ts b/apps/tradinggoose/lib/yjs/workflow-session-registry.ts index ef8e60be8..89585c615 100644 --- a/apps/tradinggoose/lib/yjs/workflow-session-registry.ts +++ b/apps/tradinggoose/lib/yjs/workflow-session-registry.ts @@ -1,19 +1,17 @@ 'use client' -import * as Y from 'yjs' +import type * as Y from 'yjs' import { - YJS_KEYS, - readWorkflowMap, getVariablesSnapshot, + readWorkflowMap, readWorkflowSnapshot, readWorkflowTextFieldValue, type WorkflowSnapshot, + YJS_KEYS, } from '@/lib/yjs/workflow-session' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' export interface RegisteredWorkflowSession { workflowId: string - entityName?: string workspaceId?: string | null doc: Y.Doc } @@ -96,16 +94,6 @@ export function readSubBlockValue( return blocks?.[blockId]?.subBlocks?.[subBlockId]?.value ?? null } -/** - * Reads a subblock value using the currently active workflow from the registry. - * Convenience wrapper used by trigger pollers and other consumers that operate - * in an "active workflow" context. - */ -export function readActiveSubBlockValue(blockId: string, subBlockId: string): any { - const activeWorkflowId = useWorkflowRegistry.getState().getActiveWorkflowId() - return readSubBlockValue(activeWorkflowId, blockId, subBlockId) -} - /** * Returns the variables snapshot for a registered session, or null if no * active session exists for the given workflow. diff --git a/apps/tradinggoose/lib/yjs/workflow-session.test.ts b/apps/tradinggoose/lib/yjs/workflow-session.test.ts index 0789b0397..f6418bdff 100644 --- a/apps/tradinggoose/lib/yjs/workflow-session.test.ts +++ b/apps/tradinggoose/lib/yjs/workflow-session.test.ts @@ -4,7 +4,6 @@ import { createWorkflowTextFieldKey, readWorkflowSnapshot, readWorkflowTextFieldsMap, - setWorkflowEntityMetadata, setWorkflowState, } from './workflow-session' @@ -81,12 +80,4 @@ describe('workflow session text fields', () => { ).toBe('fresh') 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 7f75b3406..35c944a97 100644 --- a/apps/tradinggoose/lib/yjs/workflow-session.ts +++ b/apps/tradinggoose/lib/yjs/workflow-session.ts @@ -240,18 +240,6 @@ export interface WorkflowSnapshot { lastSaved?: string | number } -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 @@ -375,8 +363,7 @@ export function setWorkflowState(doc: Y.Doc, state: WorkflowSnapshot, origin?: s export function replaceWorkflowDocumentState( doc: Y.Doc, workflowState: WorkflowSnapshot, - variables?: Record<string, any>, - metadataPatch?: WorkflowMetadataPatch + variables?: Record<string, any> ): void { setWorkflowState(doc, workflowState, YJS_ORIGINS.SYSTEM) @@ -387,35 +374,6 @@ export function replaceWorkflowDocumentState( doc.transact(() => { 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 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 (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) } // --------------------------------------------------------------------------- @@ -524,12 +482,9 @@ export function setVariables(doc: Y.Doc, variables: Record<string, any>, origin? /** * Reads the full persisted workflow state (blocks, edges, loops, parallels, * variables) from a Y.Doc in one call. This is the canonical extraction used - * by both the server-side Yjs loader and the template builder. + * by the server-side Yjs loader. */ export interface PersistedDocState { - name?: string - description?: string | null - folderId?: string | null direction?: WorkflowDirection blocks: Record<string, BlockState> edges: Edge[] @@ -541,12 +496,10 @@ 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() return { - ...metadata, ...(snapshot.direction !== undefined ? { direction: snapshot.direction } : {}), blocks: snapshot.blocks || {}, edges: snapshot.edges || [], diff --git a/apps/tradinggoose/lib/yjs/workflow-shared-session.test.ts b/apps/tradinggoose/lib/yjs/workflow-shared-session.test.ts index ffca6863b..4b8c44c3e 100644 --- a/apps/tradinggoose/lib/yjs/workflow-shared-session.test.ts +++ b/apps/tradinggoose/lib/yjs/workflow-shared-session.test.ts @@ -138,7 +138,6 @@ describe('workflow shared session lifecycle', () => { it('reuses one bootstrapped workflow session across multiple acquisitions', async () => { const doc = new Y.Doc() - doc.getMap('metadata').set('entityName', 'Workflow 1') const destroyDoc = vi.spyOn(doc, 'destroy') const provider = createMockProvider() @@ -177,13 +176,11 @@ describe('workflow shared session lifecycle', () => { workspaceId: 'workspace-1', }) expect(mockWaitForYjsSync).toHaveBeenCalledWith(provider) - expect(writeLease.session.entityName).toBe('Workflow 1') expect(writeLease.session.workspaceId).toBe('workspace-1') writeLease.release() expect(mockRegisterWorkflowSession).toHaveBeenCalledWith({ workflowId: 'workflow-1', - entityName: 'Workflow 1', workspaceId: 'workspace-1', doc, }) diff --git a/apps/tradinggoose/lib/yjs/workflow-shared-session.ts b/apps/tradinggoose/lib/yjs/workflow-shared-session.ts index 3ee97fbf3..5bfeb0781 100644 --- a/apps/tradinggoose/lib/yjs/workflow-shared-session.ts +++ b/apps/tradinggoose/lib/yjs/workflow-shared-session.ts @@ -11,7 +11,6 @@ import { } from '@/lib/yjs/provider' import { createYjsUndoTrackedOrigins } from '@/lib/yjs/transaction-origins' import { - getMetadataMap, getVariablesMap, readWorkflowMap, readWorkflowTextFieldsMap, @@ -41,7 +40,6 @@ export interface SharedWorkflowSessionUser { interface SharedWorkflowSessionEntry { workflowId: string - entityName?: string workspaceId: string | null refCount: number destroyTimeout: ReturnType<typeof setTimeout> | null @@ -206,9 +204,6 @@ async function initializeSharedSession(entry: SharedWorkflowSessionEntry): Promi entry.result = result entry.workspaceId = result.descriptor.workspaceId ?? entry.workspaceId - const entityName = getMetadataMap(result.doc).get('entityName') - entry.entityName = - typeof entityName === 'string' && entityName.trim() ? entityName.trim() : undefined entry.undoManager = undoManager entry.syncUndoState = syncUndoState entry.cleanup = () => { @@ -220,7 +215,6 @@ async function initializeSharedSession(entry: SharedWorkflowSessionEntry): Promi registerWorkflowSession({ workflowId: entry.workflowId, - ...(entry.entityName ? { entityName: entry.entityName } : {}), workspaceId: entry.workspaceId, doc: result.doc, }) @@ -352,7 +346,6 @@ export async function acquireWritableWorkflowSessionLease(args: { return { session: { workflowId: entry.workflowId, - ...(entry.entityName ? { entityName: entry.entityName } : {}), workspaceId: entry.workspaceId, doc: entry.result.doc, }, diff --git a/apps/tradinggoose/providers/ai/utils.test.ts b/apps/tradinggoose/providers/ai/utils.test.ts index 187154a53..2e6c546cb 100644 --- a/apps/tradinggoose/providers/ai/utils.test.ts +++ b/apps/tradinggoose/providers/ai/utils.test.ts @@ -773,6 +773,7 @@ describe('Tool Management', () => { workspaceId: 'workspace-1', chatId: 'chat-1', userId: 'user-1', + isDeployedContext: true, }, }) expect(executionParams._context).not.toHaveProperty('workflowId') @@ -793,6 +794,7 @@ describe('Tool Management', () => { workflowVariables: { symbol: 'AAPL' }, blockData: { blockA: { output: 1 } }, blockNameMapping: { source: 'blockA' }, + isDeployedContext: false, } ) @@ -804,6 +806,7 @@ describe('Tool Management', () => { submissionSource: 'workflow', chatId: 'chat-1', userId: 'user-1', + isDeployedContext: false, }, envVars: { API_KEY: 'secret' }, workflowVariables: { symbol: 'AAPL' }, diff --git a/apps/tradinggoose/providers/ai/utils.ts b/apps/tradinggoose/providers/ai/utils.ts index aa5268b3e..eab77e295 100644 --- a/apps/tradinggoose/providers/ai/utils.ts +++ b/apps/tradinggoose/providers/ai/utils.ts @@ -939,6 +939,7 @@ export function prepareToolExecution( workflowVariables?: Record<string, any> blockData?: Record<string, any> blockNameMapping?: Record<string, string> + isDeployedContext?: boolean } ): { toolParams: Record<string, any> @@ -958,6 +959,7 @@ export function prepareToolExecution( ...(request.submissionSource ? { submissionSource: request.submissionSource } : {}), ...(request.chatId ? { chatId: request.chatId } : {}), ...(request.userId ? { userId: request.userId } : {}), + isDeployedContext: request.isDeployedContext ?? true, } // Add system parameters for execution diff --git a/apps/tradinggoose/socket-server/index.test.ts b/apps/tradinggoose/socket-server/index.test.ts index 6cc9d790e..e3065b533 100644 --- a/apps/tradinggoose/socket-server/index.test.ts +++ b/apps/tradinggoose/socket-server/index.test.ts @@ -5,6 +5,8 @@ */ import { EventEmitter } from 'node:events' import { createServer, request as httpRequest } from 'http' +import * as syncProtocol from '@y/protocols/sync' +import * as encoding from 'lib0/encoding' import { io as createClient } from 'socket.io-client' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import * as Y from 'yjs' @@ -12,7 +14,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { getEntityFields, getEntityListMembers, - seedEntityListSession, + replaceEntityListSessionMembers, seedEntitySession, } from '@/lib/yjs/entity-session' import { @@ -30,11 +32,13 @@ import { } from '@/socket-server/yjs/upstream-utils' const { + mockReseedEntityListSessionFromDb, mockSaveSavedEntityYjsDocToDb, mockSaveWorkflowYjsDocToDb, savedEntityStates, savedWorkflowStates, } = vi.hoisted(() => ({ + mockReseedEntityListSessionFromDb: vi.fn(), mockSaveSavedEntityYjsDocToDb: vi.fn(), mockSaveWorkflowYjsDocToDb: vi.fn(), savedEntityStates: [] as Array<{ @@ -96,6 +100,14 @@ vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ state, } }), + reseedEntityListSessionFromDb: mockReseedEntityListSessionFromDb.mockImplementation( + async (doc) => { + const latest = savedEntityStates.at(-1) + if (!latest) return + const name = latest.entityKind === 'custom_tool' ? latest.fields.title : latest.fields.name + replaceEntityListSessionMembers(doc, [{ id: latest.entityId, name: String(name ?? '') }]) + } + ), getRuntimeStateFromDoc: vi.fn(() => ({ docState: 'active', replaySafe: false, @@ -231,6 +243,13 @@ function createSkillUpdateBase64(payload: Record<string, unknown>): string { return updateBase64 } +function createSyncUpdateMessage(update: Uint8Array): Uint8Array { + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, 0) + syncProtocol.writeUpdate(encoder, update) + return encoding.toUint8Array(encoder) +} + function applySkillSessionUpdate(port: number, sessionId: string, updateBase64: string) { return sendHttpRequestWithOptions( port, @@ -252,7 +271,7 @@ async function connectTestDocument(docId: string) { conn.send = vi.fn((_message, _options, callback) => callback?.()) conn.ping = vi.fn() conn.close = vi.fn() - setupWSConnection(conn, {} as any, { docId }) + setupWSConnection(conn, {} as any, { docId, accessMode: 'write' }) return { conn, doc: (await getExistingDocument(docId))! } } @@ -274,10 +293,11 @@ describe('Socket Server Index Integration', () => { savedWorkflowStates.push(extractPersistedStateFromDoc(doc)) }) mockSaveSavedEntityYjsDocToDb.mockImplementation(async (entityKind, entityId, doc) => { + const fields = getEntityFields(doc, entityKind) savedEntityStates.push({ entityKind, entityId, - fields: getEntityFields(doc, entityKind), + fields, }) }) @@ -428,12 +448,6 @@ describe('Socket Server Index Integration', () => { }) ) - const renameResponse = await applyWorkflowPatch({ metadata: { name: 'Renamed Workflow' } }) - - expect(renameResponse.statusCode).toBe(200) - expect(mockSaveWorkflowYjsDocToDb).toHaveBeenCalledTimes(2) - expect(await getExistingDocument('workflow-1')).toBeNull() - const liveDoc = getDocument('workflow-1', true) as Y.Doc setWorkflowState( liveDoc, @@ -453,9 +467,9 @@ describe('Socket Server Index Integration', () => { 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( + expect(mockSaveWorkflowYjsDocToDb).toHaveBeenCalledTimes(2) + expect(savedWorkflowStates[1]?.variables).toEqual(variables) + expect(savedWorkflowStates[1]?.blocks['block-1'].subBlocks.prompt.value).toBe( 'Use <variable.risklimit> in this prompt' ) expect(await getExistingDocument('workflow-1')).toBeNull() @@ -463,7 +477,7 @@ 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' }]) + replaceEntityListSessionMembers(listDoc, [{ id: 'skill-1', name: 'Old Skill' }]) mockSaveSavedEntityYjsDocToDb.mockImplementationOnce(async (entityKind, entityId, doc) => { doc.getMap('fields').set('name', 'Canonical Risk Skill') savedEntityStates.push({ @@ -720,6 +734,86 @@ describe('Socket Server Index Integration', () => { expect(getRuntimeStateFromDoc).toHaveBeenCalled() }) + it('reseeds an existing entity-list snapshot from DB', async () => { + const sessionId = 'list:skill:workspace-1' + getDocument(sessionId) + const liveDoc = await getExistingDocument(sessionId) + replaceEntityListSessionMembers(liveDoc!, [{ id: 'skill-live', name: 'Live Skill' }]) + savedEntityStates.push({ + entityKind: 'skill', + entityId: 'skill-db', + fields: { name: 'DB Skill' }, + }) + const encodedSessionId = encodeURIComponent(sessionId) + + const response = await sendHttpRequestWithOptions( + PORT, + `/internal/yjs/sessions/${encodedSessionId}/snapshot?targetKind=entity_list&sessionId=${encodedSessionId}&workspaceId=workspace-1&entityKind=skill`, + { + method: 'GET', + headers: { + 'x-internal-secret': INTERNAL_SECRET, + }, + } + ) + + expect(response.statusCode).toBe(200) + expect(mockReseedEntityListSessionFromDb).toHaveBeenCalledWith( + liveDoc, + 'skill', + 'workspace-1' + ) + + const data = JSON.parse(response.body) + const snapshotDoc = new Y.Doc() + try { + Y.applyUpdate(snapshotDoc, Buffer.from(data.snapshotBase64, 'base64')) + expect(getEntityListMembers(snapshotDoc).map((member) => member.entityId)).toEqual([ + 'skill-db', + ]) + } finally { + snapshotDoc.destroy() + } + }) + + it('serves an existing entity-list snapshot when DB reseed fails', async () => { + const sessionId = 'list:skill:workspace-1' + getDocument(sessionId) + const liveDoc = await getExistingDocument(sessionId) + replaceEntityListSessionMembers(liveDoc!, [{ id: 'skill-live', name: 'Live Skill' }]) + mockReseedEntityListSessionFromDb.mockRejectedValueOnce(new Error('database unavailable')) + const encodedSessionId = encodeURIComponent(sessionId) + + const response = await sendHttpRequestWithOptions( + PORT, + `/internal/yjs/sessions/${encodedSessionId}/snapshot?targetKind=entity_list&sessionId=${encodedSessionId}&workspaceId=workspace-1&entityKind=skill`, + { + method: 'GET', + headers: { + 'x-internal-secret': INTERNAL_SECRET, + }, + } + ) + + expect(response.statusCode).toBe(200) + expect(mockReseedEntityListSessionFromDb).toHaveBeenCalledWith( + liveDoc, + 'skill', + 'workspace-1' + ) + + const data = JSON.parse(response.body) + const snapshotDoc = new Y.Doc() + try { + Y.applyUpdate(snapshotDoc, Buffer.from(data.snapshotBase64, 'base64')) + expect(getEntityListMembers(snapshotDoc).map((member) => member.entityId)).toEqual([ + 'skill-live', + ]) + } finally { + snapshotDoc.destroy() + } + }) + it('should bootstrap a saved workflow snapshot into a live Yjs document', async () => { const response = await sendHttpRequestWithOptions( PORT, @@ -754,6 +848,36 @@ describe('Socket Server Index Integration', () => { }) describe('Yjs document cleanup', () => { + it('does not apply client updates from read-only Yjs connections', async () => { + const bootstrapDoc = new Y.Doc() + replaceEntityListSessionMembers(bootstrapDoc, [{ id: 'skill-1', name: 'Skill 1' }]) + const bootstrapState = Y.encodeStateAsUpdate(bootstrapDoc) + bootstrapDoc.destroy() + + 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: 'list:skill:workspace-1', + accessMode: 'read', + bootstrapState, + }) + + const doc = (await getExistingDocument('list:skill:workspace-1'))! + const updateDoc = new Y.Doc() + replaceEntityListSessionMembers(updateDoc, [{ id: 'spoofed-skill', name: 'Spoofed Skill' }]) + conn.emit('message', createSyncUpdateMessage(Y.encodeStateAsUpdate(updateDoc))) + updateDoc.destroy() + + await new Promise((resolve) => setImmediate(resolve)) + + expect(getEntityListMembers(doc).map((member) => member.entityId)).toEqual(['skill-1']) + expect(conn.close).toHaveBeenCalled() + }) + it('should discard a clean idle document without final persistence', async () => { const conn = new (await import('node:events')).EventEmitter() as any conn.readyState = 1 @@ -764,6 +888,7 @@ describe('Socket Server Index Integration', () => { setupWSConnection(conn, {} as any, { docId: 'idle-clean', + accessMode: 'write', onDocumentIdle, }) expect(await getExistingDocument('idle-clean')).not.toBeNull() @@ -785,6 +910,7 @@ describe('Socket Server Index Integration', () => { setupWSConnection(conn, {} as any, { docId: 'idle-save-failed', + accessMode: 'write', onDocumentIdle, }) expect(await getExistingDocument('idle-save-failed')).not.toBeNull() diff --git a/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.test.ts b/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.test.ts index 7fd796b69..11fcdd97b 100644 --- a/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.test.ts +++ b/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.test.ts @@ -31,7 +31,6 @@ vi.mock('@tradinggoose/db/schema', () => ({ workspaceId: 'pineIndicators.workspaceId', name: 'pineIndicators.name', pineCode: 'pineIndicators.pineCode', - inputMeta: 'pineIndicators.inputMeta', }, webhook: { id: 'webhook.id', @@ -80,7 +79,7 @@ vi.mock('@/lib/indicators/dispatch', () => ({ vi.mock('@/lib/indicators/input-meta', () => ({ buildInputsMapFromMeta: vi.fn(() => ({})), - normalizeInputMetaMap: vi.fn(() => ({})), + inferInputMetaFromPineCode: vi.fn(() => ({})), })) vi.mock('@/lib/indicators/series-data', () => ({ diff --git a/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts b/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts index 2d4f70876..dc0cf9ecf 100644 --- a/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts +++ b/apps/tradinggoose/socket-server/market/indicator-monitor-runtime.ts @@ -11,13 +11,13 @@ import { } from '@/lib/execution/pending-execution' import { DEFAULT_INDICATOR_RUNTIME_MAP } from '@/lib/indicators/default/runtime' import { resolveDispatchIntervalMs } from '@/lib/indicators/dispatch' -import { buildInputsMapFromMeta, normalizeInputMetaMap } from '@/lib/indicators/input-meta' +import { buildInputsMapFromMeta, inferInputMetaFromPineCode } from '@/lib/indicators/input-meta' import { mapMarketBarToBarMs, mapMarketSeriesToBarsMs, normalizeBarsMs, } from '@/lib/indicators/series-data' -import type { BarMs } from '@/lib/indicators/types' +import type { BarMs, InputMetaMap } from '@/lib/indicators/types' import { type ListingIdentity, toListingValueObject } from '@/lib/listing/identity' import { createLogger } from '@/lib/logs/console/logger' import { @@ -63,7 +63,7 @@ type IndicatorDefinition = { id: string name: string pineCode: string - inputMeta?: Record<string, unknown> + inputMeta?: InputMetaMap } type MonitorRuntimeConfig = { @@ -251,7 +251,7 @@ async function resolveIndicatorDefinitions( id: monitor.indicatorId, name: defaultIndicator.name, pineCode: defaultIndicator.pineCode, - inputMeta: defaultIndicator.inputMeta as Record<string, unknown> | undefined, + inputMeta: defaultIndicator.inputMeta, }) }) @@ -269,7 +269,6 @@ async function resolveIndicatorDefinitions( workspaceId: pineIndicators.workspaceId, name: pineIndicators.name, pineCode: pineIndicators.pineCode, - inputMeta: pineIndicators.inputMeta, }) .from(pineIndicators) .where( @@ -284,7 +283,7 @@ async function resolveIndicatorDefinitions( id: row.id, name: row.name, pineCode: row.pineCode, - inputMeta: (row.inputMeta as Record<string, unknown> | undefined) ?? undefined, + inputMeta: inferInputMetaFromPineCode(row.pineCode), }) }) @@ -638,8 +637,7 @@ export class IndicatorMonitorRuntime { throw new Error('Unable to resolve provider symbol') } - const inputMeta = normalizeInputMetaMap(indicator.inputMeta) - const inputsMap = buildInputsMapFromMeta(inputMeta, monitor.indicatorInputs) + const inputsMap = buildInputsMapFromMeta(indicator.inputMeta, monitor.indicatorInputs) const initialBars = await this.fetchMonitorBars(monitor, auth) const cappedBars = initialBars.slice(-MONITOR_WINDOW_BARS) diff --git a/apps/tradinggoose/socket-server/routes/http.ts b/apps/tradinggoose/socket-server/routes/http.ts index d0586e590..c14d4dda9 100644 --- a/apps/tradinggoose/socket-server/routes/http.ts +++ b/apps/tradinggoose/socket-server/routes/http.ts @@ -10,14 +10,7 @@ import { import type { ReviewEntityKind } from '@/lib/copilot/review-sessions/types' import { env } from '@/lib/env' import { saveWorkflowYjsDocToDb } from '@/lib/workflows/db-helpers' -import { - applyEntityListMutations, - type EntityListMemberMutation, - getEntityFields, - getEntityListMemberFromFields, - getEntityWorkspaceId, - seedEntitySession, -} from '@/lib/yjs/entity-session' +import { seedEntitySession } from '@/lib/yjs/entity-session' import { SavedEntityPersistenceError, saveSavedEntityYjsDocToDb, @@ -26,14 +19,10 @@ import { createEntityListBootstrapUpdate, createSavedReviewTargetBootstrapUpdate, getRuntimeStateFromDoc, + reseedEntityListSessionFromDb, } from '@/lib/yjs/server/bootstrap-review-target' import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' -import { - replaceWorkflowDocumentState, - setWorkflowEntityMetadata, - type WorkflowMetadataPatch, - type WorkflowSnapshot, -} from '@/lib/yjs/workflow-session' +import { replaceWorkflowDocumentState, type WorkflowSnapshot } from '@/lib/yjs/workflow-session' import { replaceWorkflowVariables } from '@/lib/yjs/workflow-variables' import { getMonitorRuntimeLockHealth } from '@/socket-server/monitor-runtime-lock' import { @@ -70,7 +59,6 @@ const INTERNAL_YJS_ENTITY_LIST_MEMBERS_PATH = /^\/internal\/yjs\/sessions\/([^/] type ApplyWorkflowStateRequest = { workflowState?: WorkflowSnapshot variables?: Record<string, any> - metadata?: WorkflowMetadataPatch } type SavedEntityKind = Exclude<ReviewEntityKind, 'workflow'> @@ -182,19 +170,9 @@ function parseApplyWorkflowStateRequest(body: unknown): ApplyWorkflowStateReques const candidate = body as Record<string, unknown> const workflowState = candidate.workflowState - 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 && metadata === undefined && candidate.variables === undefined) { - throw new InvalidInternalYjsRequestError('workflowState, variables, or metadata is required') + if (workflowState === undefined && candidate.variables === undefined) { + throw new InvalidInternalYjsRequestError('workflowState or variables is required') } if ( @@ -216,7 +194,6 @@ function parseApplyWorkflowStateRequest(body: unknown): ApplyWorkflowStateReques return { workflowState: workflowState as WorkflowSnapshot | undefined, variables: candidate.variables as Record<string, any> | undefined, - metadata, } } @@ -313,47 +290,35 @@ async function applyThroughStaging( function applyWorkflowApplyRequest(doc: Y.Doc, body: ApplyWorkflowStateRequest): void { if (body.workflowState) { - replaceWorkflowDocumentState(doc, body.workflowState, body.variables, body.metadata) + replaceWorkflowDocumentState(doc, body.workflowState, body.variables) return } if (body.variables !== undefined) replaceWorkflowVariables(doc, body.variables, YJS_ORIGINS.SYSTEM) - if (body.metadata) setWorkflowEntityMetadata(doc, body.metadata) } -async function syncEntityListMemberFromDoc( +async function refreshSavedEntityListDoc( entityKind: SavedEntityKind, - entityId: string, entityDoc: Y.Doc ): Promise<void> { - const workspaceId = getEntityWorkspaceId(entityDoc) - if (!workspaceId) { - return - } + const workspaceId = entityDoc.getMap('metadata').get('workspaceId') + if (typeof workspaceId !== 'string' || !workspaceId) return const descriptor = buildEntityListDescriptor(entityKind, workspaceId) const listDoc = await getExistingDocument(descriptor.yjsSessionId) - if (!listDoc) { - return - } + 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) + try { + await reseedEntityListSessionFromDb(listDoc, entityKind, workspaceId) + markDocumentPersisted(listDoc) + discardDocumentIfIdle(descriptor.yjsSessionId) + } catch { + discardDocument(descriptor.yjsSessionId) + } } async function handleInternalYjsEntityListMembersRequest( - req: IncomingMessage, + parsedUrl: URL, res: ServerResponse, logger: Logger, sessionId: string @@ -369,27 +334,18 @@ async function handleInternalYjsEntityListMembersRequest( return } - const body = (await readJsonBody(req)) as { - members?: Array<{ id?: unknown; name?: unknown; enabled?: unknown }> - remove?: unknown + const envelope = parseYjsTransportEnvelope(Object.fromEntries(parsedUrl.searchParams)) + if (envelope.sessionId !== sessionId) { + throw new InvalidInternalYjsRequestError('Session ID mismatch') } - 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) + // buildReviewTargetDescriptorFromEnvelope rejects entity_list envelopes + // without a workspaceId, so the cast below cannot see null. + const descriptor = buildReviewTargetDescriptorFromEnvelope(envelope) + await reseedEntityListSessionFromDb( + liveDoc, + descriptor.entityKind, + descriptor.workspaceId as string + ) markDocumentPersisted(liveDoc) discardDocumentIfIdle(sessionId) sendJson(res, 200, { success: true, applied: true }) @@ -451,9 +407,11 @@ async function handleInternalYjsEntityApplyRequest( seedEntitySession(target, { entityKind: body.entityKind, payload: body.fields }) clearSessionReseededFromCanonical(target) }, - (staged) => saveSavedEntityYjsDocToDb(body.entityKind, entityId, staged) + async (staged) => { + await saveSavedEntityYjsDocToDb(body.entityKind, entityId, staged) + } ) - await syncEntityListMemberFromDoc(body.entityKind, entityId, doc) + await refreshSavedEntityListDoc(body.entityKind, doc) sendJson(res, 200, { success: true }) } catch (error) { @@ -502,7 +460,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) + await refreshSavedEntityListDoc(descriptor.entityKind, doc) markDocumentPersisted(doc) discardDocumentIfIdle(sessionId) } @@ -555,7 +513,7 @@ async function handleInternalYjsSnapshotRequest( if (!liveDoc) { const bootstrapped = isEntityListSessionId(descriptor.yjsSessionId) ? await createEntityListBootstrapUpdate( - descriptor.entityKind as SavedEntityKind, + descriptor.entityKind, descriptor.workspaceId as string ) : descriptor.entityId @@ -576,6 +534,22 @@ async function handleInternalYjsSnapshotRequest( return } + if (isEntityListSessionId(descriptor.yjsSessionId) && !bootstrappedForRequest) { + try { + await reseedEntityListSessionFromDb( + liveDoc, + descriptor.entityKind, + descriptor.workspaceId as string + ) + markDocumentPersisted(liveDoc) + } catch (error) { + logger.warn('Failed to reseed existing entity-list snapshot; serving live projection', { + error, + sessionId, + }) + } + } + const state = Y.encodeStateAsUpdate(liveDoc) sendJson(res, 200, { @@ -669,7 +643,7 @@ async function handleInternalYjsRequest( req.method ) if (memberListId) { - await handleInternalYjsEntityListMembersRequest(req, res, logger, memberListId) + await handleInternalYjsEntityListMembersRequest(parsedUrl, res, logger, memberListId) return true } diff --git a/apps/tradinggoose/socket-server/yjs/upstream-utils.ts b/apps/tradinggoose/socket-server/yjs/upstream-utils.ts index fe632c510..19f945d86 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 type { ReviewAccessMode } from '@/lib/copilot/review-sessions/types' import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' const messageSync = 0 @@ -211,7 +212,12 @@ function closeConn(doc: WSSharedDoc, conn: WebSocket): void { } } -function handleMessage(conn: WebSocket, doc: WSSharedDoc, message: Uint8Array): void { +function handleMessage( + conn: WebSocket, + doc: WSSharedDoc, + message: Uint8Array, + accessMode: ReviewAccessMode +): void { try { const encoder = encoding.createEncoder() const decoder = decoding.createDecoder(message) @@ -219,6 +225,13 @@ function handleMessage(conn: WebSocket, doc: WSSharedDoc, message: Uint8Array): switch (messageType) { case messageSync: + if ( + accessMode === 'read' && + decoding.peekVarUint(decoder) !== syncProtocol.messageYjsSyncStep1 + ) { + closeConn(doc, conn) + break + } encoding.writeVarUint(encoder, messageSync) syncProtocol.readSyncMessage(decoder, encoder, doc, conn) if (encoding.length(encoder) > 1) { @@ -276,6 +289,7 @@ export function setupWSConnection( _req: IncomingMessage, opts: { docId: string + accessMode: ReviewAccessMode gc?: boolean bootstrapState?: Uint8Array onDocumentIdle?: DocumentPersistenceHandler @@ -285,6 +299,7 @@ export function setupWSConnection( ): void { const { docId, + accessMode, gc = true, bootstrapState, onDocumentIdle, @@ -304,7 +319,7 @@ export function setupWSConnection( conn.on('message', (data: ArrayBuffer) => { void doc.whenInitialized.then(() => { - handleMessage(conn, doc, new Uint8Array(data)) + handleMessage(conn, doc, new Uint8Array(data), accessMode) }) }) @@ -341,7 +356,11 @@ export function setupWSConnection( void doc.whenInitialized.then(() => { const encoder = encoding.createEncoder() encoding.writeVarUint(encoder, messageSync) - syncProtocol.writeSyncStep1(encoder, doc) + if (accessMode === 'read') { + syncProtocol.writeSyncStep2(encoder, doc) + } else { + syncProtocol.writeSyncStep1(encoder, doc) + } send(doc, conn, encoding.toUint8Array(encoder)) const awarenessStates = doc.awareness.getStates() diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts index 2ccd92e17..644b7d09e 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.test.ts @@ -16,6 +16,7 @@ const mockLogger = { } const mockAuthenticateYjsConnection = vi.fn() +const mockCreateEntityListBootstrapUpdate = vi.fn() const mockCreateSavedReviewTargetBootstrapUpdate = vi.fn() const mockVerifyReviewTargetAccess = vi.fn() const mockGetExistingDocument = vi.fn() @@ -72,6 +73,7 @@ beforeEach(() => { vi.resetModules() mockAuthenticateYjsConnection.mockReset() + mockCreateEntityListBootstrapUpdate.mockReset() mockCreateSavedReviewTargetBootstrapUpdate.mockReset() mockVerifyReviewTargetAccess.mockReset() mockGetExistingDocument.mockReset() @@ -91,6 +93,7 @@ beforeEach(() => { })) vi.doMock('@/lib/yjs/server/bootstrap-review-target', () => ({ + createEntityListBootstrapUpdate: mockCreateEntityListBootstrapUpdate, createSavedReviewTargetBootstrapUpdate: mockCreateSavedReviewTargetBootstrapUpdate, getRuntimeStateFromDoc: vi.fn((doc) => ({ docState: doc.getMap('metadata').get('docState') === 'expired' ? 'expired' : 'active', @@ -237,6 +240,7 @@ describe('handleYjsUpgrade', () => { expect.objectContaining({ bootstrapState, docId: sessionId, + accessMode: 'write', gc: true, onDocumentUpdate: expect.any(Function), }) diff --git a/apps/tradinggoose/socket-server/yjs/ws-handler.ts b/apps/tradinggoose/socket-server/yjs/ws-handler.ts index 52f27d0dc..5fc8088cf 100644 --- a/apps/tradinggoose/socket-server/yjs/ws-handler.ts +++ b/apps/tradinggoose/socket-server/yjs/ws-handler.ts @@ -10,7 +10,6 @@ import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissi 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, @@ -27,6 +26,7 @@ interface YjsIncomingMessage extends IncomingMessage { yjsUserId?: string yjsBootstrapState?: Uint8Array yjsPersistLiveUpdates?: boolean + yjsAccessMode?: ReviewAccessMode } async function persistWorkflowDocument(docId: string, doc: Y.Doc): Promise<void> { @@ -66,12 +66,13 @@ export function handleYjsUpgrade( const yjsSessionId = decodeURIComponent(match[1]) void authenticateAndPrepareUpgrade(yjsSessionId, url) - .then(({ bootstrapState, userId, resolvedSessionId, persistLiveUpdates }) => { + .then(({ accessMode, bootstrapState, userId, resolvedSessionId, persistLiveUpdates }) => { const yjsReq = request as YjsIncomingMessage yjsReq.yjsSessionId = resolvedSessionId yjsReq.yjsUserId = userId yjsReq.yjsBootstrapState = bootstrapState yjsReq.yjsPersistLiveUpdates = persistLiveUpdates + yjsReq.yjsAccessMode = accessMode ensureConnectionHandler(wss) wss.handleUpgrade(request, socket, head, (ws: WebSocket) => { @@ -97,6 +98,7 @@ async function authenticateAndPrepareUpgrade( userId: string resolvedSessionId: string persistLiveUpdates: boolean + accessMode: ReviewAccessMode }> { const accessMode = parseAccessMode(url, pathSessionId) const { userId, envelope } = await authenticateYjsConnection(url) @@ -125,12 +127,15 @@ async function authenticateAndPrepareUpgrade( throw new YjsAuthError(403, 'Forbidden') } + // Every list connect follows a fresh snapshot fetch, which reseeds live + // list docs from DB (routes/http.ts), so no upgrade-time reseed is needed. const liveDoc = await getExistingDocument(pathSessionId) + const bootstrapped = liveDoc ? null : isListTarget ? await createEntityListBootstrapUpdate( - descriptor.entityKind as SavedEntityKind, + descriptor.entityKind, descriptor.workspaceId as string ) : descriptor.entityId @@ -150,6 +155,7 @@ async function authenticateAndPrepareUpgrade( bootstrapState: bootstrapped?.state, userId, resolvedSessionId: pathSessionId, + accessMode, persistLiveUpdates: descriptor.entityKind === 'workflow' && descriptor.entityId === pathSessionId, } @@ -183,8 +189,8 @@ function ensureConnectionHandler(wss: WebSocketServer): void { const yjsReq = req as YjsIncomingMessage const docId = yjsReq.yjsSessionId - if (!docId) { - ws.close(4409, 'Missing session ID') + if (!docId || !yjsReq.yjsAccessMode) { + ws.close(4409, 'Missing session access') return } @@ -192,6 +198,7 @@ function ensureConnectionHandler(wss: WebSocketServer): void { logger.info('Yjs connection established', { docId, userId: yjsReq.yjsUserId }) setupWSConnection(ws, req, { docId, + accessMode: yjsReq.yjsAccessMode, gc: true, bootstrapState: yjsReq.yjsBootstrapState, onDocumentIdle: persistWorkflowDocument, diff --git a/apps/tradinggoose/stores/copilot/tool-registry.test.ts b/apps/tradinggoose/stores/copilot/tool-registry.test.ts index 88ca647bd..f220316f9 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.test.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.test.ts @@ -1,5 +1,6 @@ import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MCP_TOOLS_CHANGED_EVENT } from '@/lib/mcp/utils' 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' @@ -13,7 +14,6 @@ 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', () => { @@ -314,7 +314,7 @@ describe('tool-registry', () => { } }) - it('refreshes MCP servers and notifies MCP tool discovery after server-managed MCP mutations', async () => { + it('notifies MCP tool discovery after server-managed MCP mutations', async () => { class TestCustomEvent<T> { type: string detail: T | undefined @@ -325,9 +325,6 @@ describe('tool-registry', () => { } } const dispatchEvent = vi.fn() - const fetchServers = vi - .spyOn(useMcpServersStore.getState(), 'fetchServers') - .mockResolvedValue(undefined) vi.stubGlobal('CustomEvent', TestCustomEvent) vi.stubGlobal('window', { dispatchEvent }) @@ -335,12 +332,10 @@ describe('tool-registry', () => { 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() } }) diff --git a/apps/tradinggoose/stores/copilot/tool-registry.ts b/apps/tradinggoose/stores/copilot/tool-registry.ts index cff05a73a..9d4ab7a16 100644 --- a/apps/tradinggoose/stores/copilot/tool-registry.ts +++ b/apps/tradinggoose/stores/copilot/tool-registry.ts @@ -17,6 +17,7 @@ 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 { MCP_TOOLS_CHANGED_EVENT } from '@/lib/mcp/utils' 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' @@ -26,7 +27,6 @@ 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 { MCP_TOOLS_CHANGED_EVENT, useMcpServersStore } from '@/stores/mcp-servers/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('CopilotToolRegistry') @@ -358,7 +358,6 @@ 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 }, diff --git a/apps/tradinggoose/stores/copilot/types.ts b/apps/tradinggoose/stores/copilot/types.ts index 1aaef2406..06a1ee467 100644 --- a/apps/tradinggoose/stores/copilot/types.ts +++ b/apps/tradinggoose/stores/copilot/types.ts @@ -89,7 +89,6 @@ export type ChatContext = | { kind: 'logs'; executionId?: string; label: string } | { kind: 'workflow_block'; workflowId: string; blockId: string; label: string } | { kind: 'knowledge'; knowledgeId?: string; workspaceId?: string; label: string } - | { kind: 'templates'; templateId?: string; label: string } | { kind: 'docs'; label: string } export interface CopilotChat { diff --git a/apps/tradinggoose/stores/folders/store.ts b/apps/tradinggoose/stores/folders/store.ts index 61cb624af..36eba6583 100644 --- a/apps/tradinggoose/stores/folders/store.ts +++ b/apps/tradinggoose/stores/folders/store.ts @@ -1,20 +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 { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('FoldersStore') -export interface Workflow { - id: string - folderId?: string | null - name?: string - description?: string - userId?: string - workspaceId?: string - [key: string]: any // For additional properties -} - export interface WorkflowFolder { id: string name: string @@ -73,10 +62,6 @@ interface FolderState { }) => Promise<WorkflowFolder> updateFolderAPI: (id: string, updates: Partial<WorkflowFolder>) => Promise<WorkflowFolder> deleteFolder: (id: string, workspaceId: string) => Promise<void> - - // Helper functions - isWorkflowInDeletedSubfolder: (workflow: Workflow, deletedFolderId: string) => boolean - removeSubfoldersRecursively: (parentFolderId: string) => void } export const useFolderStore = create<FolderState>()( @@ -317,7 +302,7 @@ export const useFolderStore = create<FolderState>()( return processedFolder }, - deleteFolder: async (id: string, workspaceId: string) => { + deleteFolder: async (id: string, _workspaceId: string) => { const response = await fetch(`/api/folders/${id}`, { method: 'DELETE' }) if (!response.ok) { @@ -326,76 +311,22 @@ export const useFolderStore = create<FolderState>()( } const responseData = await response.json() - const workflowRegistry = useWorkflowRegistry.getState() + const parentId = typeof responseData.parentId === 'string' ? responseData.parentId : null - const collectDescendantFolderIds = (folderId: string): string[] => { - const childFolders = get().getChildFolders(folderId) - return childFolders.flatMap((child) => [child.id, ...collectDescendantFolderIds(child.id)]) + for (const childFolder of get().getChildFolders(id)) { + get().updateFolder(childFolder.id, { parentId }) } - - const descendantFolderIds = collectDescendantFolderIds(id) - const allFolderIds = [id, ...descendantFolderIds] - const workflowIdsToRemove = Object.values(workflowRegistry.workflows) - .filter( - (workflow) => - workflow.workspaceId === workspaceId && - workflow.folderId && - allFolderIds.includes(workflow.folderId) - ) - .map((workflow) => workflow.id) - - // Remove the folder from local state get().removeFolder(id) - // Remove from expanded state set((state) => { const newExpanded = new Set(state.expandedFolders) - allFolderIds.forEach((folderId) => newExpanded.delete(folderId)) + newExpanded.delete(id) return { expandedFolders: newExpanded } }) - // Remove subfolders from local state - get().removeSubfoldersRecursively(id) - - if (workflowIdsToRemove.length > 0) { - await Promise.all( - workflowIdsToRemove.map((workflowId) => - workflowRegistry.removeWorkflow(workflowId, { skipApi: true }) - ) - ) - } - logger.info( - `Deleted ${responseData.deletedItems.workflows} workflow(s) and ${responseData.deletedItems.folders} folder(s)` - ) - }, - - isWorkflowInDeletedSubfolder: (workflow: Workflow, deletedFolderId: string) => { - if (!workflow.folderId) return false - - const folders = get().folders - let currentFolderId: string | null = workflow.folderId - - while (currentFolderId && folders[currentFolderId]) { - if (currentFolderId === deletedFolderId) { - return true - } - currentFolderId = folders[currentFolderId].parentId - } - - return false - }, - - removeSubfoldersRecursively: (parentFolderId: string) => { - const folders = get().folders - const childFolderIds = Object.keys(folders).filter( - (id) => folders[id].parentId === parentFolderId + `Deleted folder ${id}; moved ${responseData.movedFolders ?? 0} folder(s) and ${responseData.movedWorkflows ?? 0} workflow(s) up one level` ) - - childFolderIds.forEach((childId) => { - get().removeSubfoldersRecursively(childId) - get().removeFolder(childId) - }) }, }), { name: 'folder-store' } diff --git a/apps/tradinggoose/stores/index.ts b/apps/tradinggoose/stores/index.ts index 32bfe2f2b..99080c0f3 100644 --- a/apps/tradinggoose/stores/index.ts +++ b/apps/tradinggoose/stores/index.ts @@ -9,7 +9,6 @@ import { useCustomToolsStore } from '@/stores/custom-tools/store' import { useExecutionStore } from '@/stores/execution/store' import { useIndicatorsStore } from '@/stores/indicators/store' import { useEnvironmentStore } from '@/stores/settings/environment/store' -import { useSkillsStore } from '@/stores/skills/store' import { useSubscriptionStore } from '@/stores/subscription/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -73,7 +72,6 @@ export const resetAllStores = () => { useConsoleStore.setState({ entries: [] }) getCopilotStore().setState({ messages: [], isSendingMessage: false }) useCustomToolsStore.getState().resetAll() - useSkillsStore.getState().resetAll() useIndicatorsStore.getState().resetAll() resetWorkspacePermissionsStore() // Variables store has no tracking to reset; registry hydrates @@ -89,7 +87,6 @@ export const logAllStores = () => { console: useConsoleStore.getState(), copilot: getCopilotStore().getState(), customTools: useCustomToolsStore.getState(), - skills: useSkillsStore.getState(), indicators: useIndicatorsStore.getState(), subscription: useSubscriptionStore.getState(), } diff --git a/apps/tradinggoose/stores/mcp-servers/store.ts b/apps/tradinggoose/stores/mcp-servers/store.ts deleted file mode 100644 index deb403b79..000000000 --- a/apps/tradinggoose/stores/mcp-servers/store.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { devtools } from 'zustand/middleware' -import { createWithEqualityFn as create } from 'zustand/traditional' -import { createLogger } from '@/lib/logs/console/logger' -import { initialState, type McpServersActions, type McpServersState } from './types' - -const logger = createLogger('McpServersStore') - -export const MCP_TOOLS_CHANGED_EVENT = 'tradinggoose:mcp-tools-changed' - -export const useMcpServersStore = create<McpServersState & McpServersActions>()( - devtools( - (set) => ({ - ...initialState, - - fetchServers: async (workspaceId: string) => { - set({ isLoading: true, error: null }) - - try { - const response = await fetch(`/api/mcp/servers?workspaceId=${workspaceId}`) - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to fetch servers') - } - - const listedServers: McpServersState['servers'] = Array.isArray(data.data?.servers) - ? data.data.servers - : [] - set({ servers: listedServers, isLoading: false }) - logger.info( - `Fetched ${data.data?.servers?.length || 0} MCP servers for workspace ${workspaceId}` - ) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to fetch servers' - logger.error('Failed to fetch MCP servers:', error) - set({ error: errorMessage, isLoading: false }) - } - }, - - createServer: async (workspaceId: string, config) => { - set({ isLoading: true, error: null }) - - try { - const requestBody = { - ...config, - workspaceId, - } - - const response = await fetch('/api/mcp/servers', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody), - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to create server') - } - - const serverId = - data?.data && typeof data.data.serverId === 'string' ? data.data.serverId : null - - if (!serverId) { - throw new Error('Failed to create server: missing server id') - } - - const newServer = { - ...requestBody, - id: serverId, - description: requestBody.description ?? undefined, - url: requestBody.url ?? undefined, - command: requestBody.command ?? undefined, - args: requestBody.args ?? [], - env: requestBody.env ?? {}, - timeout: requestBody.timeout ?? 30000, - retries: requestBody.retries ?? 3, - enabled: requestBody.enabled ?? true, - } - set((state) => ({ - servers: [...state.servers, newServer], - isLoading: false, - })) - - logger.info(`Created MCP server: ${config.name} in workspace: ${workspaceId}`) - return newServer - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to create server' - logger.error('Failed to create MCP server:', error) - set({ error: errorMessage, isLoading: false }) - throw error - } - }, - - 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({ name }), - }) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to rename server') - } - - const updatedServer = data.data?.server || null - const nextName = typeof updatedServer?.name === 'string' ? updatedServer.name : name - - set((state) => ({ - servers: state.servers.map((server) => - server.id === id && server.workspaceId === workspaceId && nextName - ? { ...server, name: nextName } - : server - ), - isLoading: false, - })) - - logger.info(`Renamed MCP server: ${id} in workspace: ${workspaceId}`) - return updatedServer - } catch (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 - } - }, - - deleteServer: async (workspaceId: string, id: string) => { - set({ isLoading: true, error: null }) - - try { - const response = await fetch( - `/api/mcp/servers?serverId=${id}&workspaceId=${workspaceId}`, - { - method: 'DELETE', - } - ) - - const data = await response.json() - - if (!response.ok) { - throw new Error(data.error || 'Failed to delete server') - } - - set((state) => ({ - servers: state.servers.filter((server) => server.id !== id), - isLoading: false, - })) - - logger.info(`Deleted MCP server: ${id} from workspace: ${workspaceId}`) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to delete server' - logger.error('Failed to delete MCP server:', error) - set({ error: errorMessage, isLoading: false }) - throw error - } - }, - - refreshServer: async (workspaceId: string, id: string, result) => { - const refreshedAt = result?.lastToolsRefresh ?? new Date().toISOString() - - set((state) => ({ - servers: state.servers.map((server) => - server.id === id && server.workspaceId === workspaceId - ? { - ...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 - ), - })) - - logger.info(`Refreshed MCP server: ${id} in workspace: ${workspaceId}`) - }, - }), - { - name: 'mcp-servers-store', - } - ) -) - -export const useEnabledServers = () => { - return useMcpServersStore((state) => - state.servers.filter((server) => !server.deletedAt && server.enabled !== false) - ) -} diff --git a/apps/tradinggoose/stores/mcp-servers/types.ts b/apps/tradinggoose/stores/mcp-servers/types.ts deleted file mode 100644 index 4b1674355..000000000 --- a/apps/tradinggoose/stores/mcp-servers/types.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { McpTransport } from '@/lib/mcp/types' - -export interface McpServerWithStatus { - id: string - name: string - description?: string | null - transport?: McpTransport - url?: string | null - headers?: Record<string, string> - command?: string | null - args?: string[] - env?: Record<string, string> - timeout?: number - retries?: number - enabled?: boolean - createdAt?: string - updatedAt?: string - connectionStatus?: 'connected' | 'disconnected' | 'error' - lastError?: string - toolCount?: number - lastConnected?: string - totalRequests?: number - lastUsed?: string - lastToolsRefresh?: string - deletedAt?: string - workspaceId: string -} - -export interface McpServersState { - servers: McpServerWithStatus[] - isLoading: boolean - error: string | null -} - -export interface McpServersActions { - fetchServers: (workspaceId: string) => Promise<void> - createServer: ( - workspaceId: string, - config: Omit< - McpServerWithStatus, - | 'id' - | 'createdAt' - | 'updatedAt' - | 'connectionStatus' - | 'lastError' - | 'toolCount' - | 'lastConnected' - | 'totalRequests' - | 'lastUsed' - | 'lastToolsRefresh' - | 'deletedAt' - | 'workspaceId' - > - ) => Promise<McpServerWithStatus> - renameServer: ( - workspaceId: string, - id: string, - name: string - ) => Promise<McpServerWithStatus | null> - deleteServer: (workspaceId: string, id: string) => Promise<void> - refreshServer: ( - workspaceId: string, - id: string, - result?: { - status?: McpServerWithStatus['connectionStatus'] - toolCount?: number - lastConnected?: string | null - lastToolsRefresh?: string | null - error?: string | null - } - ) => Promise<void> -} - -export const initialState: McpServersState = { - servers: [], - isLoading: false, - error: null, -} diff --git a/apps/tradinggoose/stores/skills/store.ts b/apps/tradinggoose/stores/skills/store.ts deleted file mode 100644 index d98f240a4..000000000 --- a/apps/tradinggoose/stores/skills/store.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { createWithEqualityFn as create } from 'zustand/traditional' -import { devtools } from 'zustand/middleware' -import { createLogger } from '@/lib/logs/console/logger' -import type { SkillsStore } from '@/stores/skills/types' - -const logger = createLogger('SkillsStore') - -const initialState = { - skillsByWorkspace: {}, - activeWorkspaceId: null, -} satisfies Pick<SkillsStore, 'skillsByWorkspace' | 'activeWorkspaceId'> - -export const useSkillsStore = create<SkillsStore>()( - devtools( - (set, get) => ({ - ...initialState, - - setSkills: (workspaceId, skills) => { - logger.info(`Synced ${skills.length} skills for workspace ${workspaceId}`) - set((state) => ({ - skillsByWorkspace: { - ...state.skillsByWorkspace, - [workspaceId]: skills, - }, - activeWorkspaceId: workspaceId, - })) - }, - - readSkill: (id, workspaceId) => { - const targetWorkspace = workspaceId ?? get().activeWorkspaceId - if (!targetWorkspace) return undefined - return get().skillsByWorkspace[targetWorkspace]?.find( - (currentSkill) => currentSkill.id === id - ) - }, - - getAllSkills: (workspaceId) => { - const targetWorkspace = workspaceId ?? get().activeWorkspaceId - if (!targetWorkspace) return [] - return get().skillsByWorkspace[targetWorkspace] ?? [] - }, - - resetWorkspace: (workspaceId) => { - set((state) => { - const next = { ...state.skillsByWorkspace } - delete next[workspaceId] - return { - skillsByWorkspace: next, - activeWorkspaceId: - state.activeWorkspaceId === workspaceId ? null : state.activeWorkspaceId, - } - }) - }, - - resetAll: () => set(initialState), - }), - { - name: 'skills-store', - } - ) -) diff --git a/apps/tradinggoose/stores/skills/types.ts b/apps/tradinggoose/stores/skills/types.ts deleted file mode 100644 index d80375056..000000000 --- a/apps/tradinggoose/stores/skills/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -export interface SkillDefinition { - id: string - workspaceId: string - userId: string | null - name: string - description: string - content: string - createdAt?: string - updatedAt?: string -} - -export interface SkillsStore { - skillsByWorkspace: Record<string, SkillDefinition[]> - activeWorkspaceId: string | null - - setSkills: (workspaceId: string, skills: SkillDefinition[]) => void - readSkill: (id: string, workspaceId?: string) => SkillDefinition | undefined - getAllSkills: (workspaceId?: string) => SkillDefinition[] - resetWorkspace: (workspaceId: string) => void - resetAll: () => void -} diff --git a/apps/tradinggoose/stores/workflows/index.ts b/apps/tradinggoose/stores/workflows/index.ts index bb410565b..0f1550220 100644 --- a/apps/tradinggoose/stores/workflows/index.ts +++ b/apps/tradinggoose/stores/workflows/index.ts @@ -1,7 +1,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { getSnapshotForWorkflow } from '@/lib/yjs/workflow-session-registry' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('Workflows') @@ -21,26 +21,11 @@ function getYjsWorkflowState(workflowId: string): WorkflowState | null { * Get a workflow with its state merged in by ID * Reads state from the Yjs session for the given workflow. * @param workflowId ID of the workflow to retrieve - * @returns The workflow with state values or null if not found/not active + * @returns The workflow with state values or null if no live session exists */ -export function readWorkflowWithValues(workflowId: string, channelId?: string) { +export function readWorkflowWithValues(workflowId: string) { const registryState = useWorkflowRegistry.getState() const { workflows } = registryState - const activeWorkflowId = - typeof registryState.getActiveWorkflowId === 'function' - ? registryState.getActiveWorkflowId(channelId) - : null - - if (!workflows[workflowId]) { - logger.warn(`Workflow ${workflowId} not found`) - return null - } - - // Only return data for active workflow with a live Yjs session - if (workflowId !== activeWorkflowId) { - logger.warn(`Cannot get state for non-active workflow ${workflowId}`) - return null - } const workflowState = getYjsWorkflowState(workflowId) if (!workflowState) { @@ -50,17 +35,15 @@ export function readWorkflowWithValues(workflowId: string, channelId?: string) { const metadata = workflows[workflowId] - // Get deployment status from registry const deploymentStatus = useWorkflowRegistry.getState().readWorkflowDeploymentStatus(workflowId) return { id: workflowId, - name: metadata.name, - description: metadata.description, - color: metadata.color || '#3972F6', - marketplaceData: metadata.marketplaceData || null, - workspaceId: metadata.workspaceId, - folderId: metadata.folderId, + name: metadata?.name ?? '', + description: metadata?.description, + color: metadata?.color || '#3972F6', + workspaceId: metadata?.workspaceId, + folderId: metadata?.folderId, state: { blocks: workflowState.blocks, edges: workflowState.edges, @@ -73,81 +56,6 @@ export function readWorkflowWithValues(workflowId: string, channelId?: string) { } } -/** - * Get a specific block with its subblock values merged in - * @param blockId ID of the block to retrieve - * @returns The block with subblock values or null if not found - */ -export function getBlockWithValues(blockId: string, channelId?: string): BlockState | null { - const registryState = useWorkflowRegistry.getState() - const activeWorkflowId = - typeof registryState.getActiveWorkflowId === 'function' - ? registryState.getActiveWorkflowId(channelId) - : null - - if (!activeWorkflowId) return null - - const workflowState = getYjsWorkflowState(activeWorkflowId) - if (!workflowState || !workflowState.blocks[blockId]) return null - - return workflowState.blocks[blockId] || null -} - -/** - * Get all workflows with their values - * Only includes the active workflow state (read from Yjs). - * @returns An object containing workflows, with state only for the active workflow - */ -export function getAllWorkflowsWithValues(channelId?: string) { - const { workflows } = useWorkflowRegistry.getState() - const result: Record<string, any> = {} - const activeWorkflowId = useWorkflowRegistry.getState().getActiveWorkflowId(channelId) - - // Only sync the active workflow to ensure we always send valid state data - if (activeWorkflowId && workflows[activeWorkflowId]) { - const metadata = workflows[activeWorkflowId] - - const workflowState = getYjsWorkflowState(activeWorkflowId) - if (!workflowState) return result - - // Get deployment status from registry - const deploymentStatus = useWorkflowRegistry - .getState() - .readWorkflowDeploymentStatus(activeWorkflowId) - - // Include the API key in the state if it exists in the deployment status - const apiKey = deploymentStatus?.apiKey - - result[activeWorkflowId] = { - id: activeWorkflowId, - name: metadata.name, - description: metadata.description, - color: metadata.color || '#3972F6', - marketplaceData: metadata.marketplaceData || null, - folderId: metadata.folderId, - state: { - blocks: workflowState.blocks, - edges: workflowState.edges, - loops: workflowState.loops, - parallels: workflowState.parallels, - lastSaved: workflowState.lastSaved, - isDeployed: deploymentStatus?.isDeployed || false, - deployedAt: deploymentStatus?.deployedAt, - marketplaceData: metadata.marketplaceData || null, - }, - // Include API key if available - apiKey, - } - - // Only include workspaceId if it's not null/undefined - if (metadata.workspaceId) { - result[activeWorkflowId].workspaceId = metadata.workspaceId - } - } - - return result -} - export { useWorkflowRegistry } from '@/stores/workflows/registry/store' export type { WorkflowMetadata } from '@/stores/workflows/registry/types' export { mergeSubblockState } from '@/stores/workflows/utils' diff --git a/apps/tradinggoose/stores/workflows/json/store.test.ts b/apps/tradinggoose/stores/workflows/json/store.test.ts index 037eefa3d..e503de664 100644 --- a/apps/tradinggoose/stores/workflows/json/store.test.ts +++ b/apps/tradinggoose/stores/workflows/json/store.test.ts @@ -1,28 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useSkillsStore } from '@/stores/skills/store' const mockGetSnapshotForWorkflow = vi.hoisted(() => vi.fn()) -const mockWorkflowRegistryState = vi.hoisted(() => ({ - workflows: { - 'workflow-1': { - id: 'workflow-1', - name: 'Primary Workflow', - description: 'Workflow imported from the unified schema', - color: '#3972F6', - workspaceId: 'workspace-1', - }, - }, - getActiveWorkflowId: vi.fn(() => 'workflow-1'), -})) +const mockFetchSkills = vi.hoisted(() => vi.fn()) vi.mock('@/lib/yjs/workflow-session-registry', () => ({ getSnapshotForWorkflow: mockGetSnapshotForWorkflow, })) - -vi.mock('@/stores/workflows/registry/store', () => ({ - useWorkflowRegistry: { - getState: () => mockWorkflowRegistryState, - }, +vi.mock('@/hooks/queries/skills', () => ({ + fetchSkills: mockFetchSkills, })) import { useWorkflowJsonStore } from './store' @@ -30,10 +15,9 @@ import { useWorkflowJsonStore } from './store' describe('workflow json store', () => { beforeEach(() => { mockGetSnapshotForWorkflow.mockReset() - useSkillsStore.getState().resetAll() + mockFetchSkills.mockReset() useWorkflowJsonStore.setState({ json: '', - lastGenerated: undefined, }) mockGetSnapshotForWorkflow.mockReturnValue({ @@ -66,7 +50,7 @@ describe('workflow json store', () => { isDeployed: false, deployedAt: undefined, }) - useSkillsStore.getState().setSkills('workspace-1', [ + mockFetchSkills.mockResolvedValue([ { id: 'skill-1', workspaceId: 'workspace-1', @@ -91,9 +75,13 @@ describe('workflow json store', () => { it('threads workspace skills into the workflow export payload', async () => { await useWorkflowJsonStore.getState().getJson({ workflowId: 'workflow-1', - channelId: 'channel-1', + name: 'Primary Workflow', + description: 'Workflow imported from the unified schema', + workspaceId: 'workspace-1', }) + expect(mockFetchSkills).toHaveBeenCalledWith('workspace-1') + const payload = JSON.parse(useWorkflowJsonStore.getState().json) as { resourceTypes: string[] skills: Array<{ diff --git a/apps/tradinggoose/stores/workflows/json/store.ts b/apps/tradinggoose/stores/workflows/json/store.ts index e7b197b26..581daf274 100644 --- a/apps/tradinggoose/stores/workflows/json/store.ts +++ b/apps/tradinggoose/stores/workflows/json/store.ts @@ -3,78 +3,56 @@ import { createWithEqualityFn as create } from 'zustand/traditional' import { createLogger } from '@/lib/logs/console/logger' 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' +import { fetchSkills } from '@/hooks/queries/skills' const logger = createLogger('WorkflowJsonStore') export interface WorkflowJsonScope { - workflowId?: string | null - channelId?: string + workflowId: string + name: string + description?: string + workspaceId?: string | null } interface WorkflowJsonStore { json: string - lastGenerated?: number - generateJson: (scope?: WorkflowJsonScope) => Promise<void> - getJson: (scope?: WorkflowJsonScope) => Promise<string> - refreshJson: (scope?: WorkflowJsonScope) => Promise<void> + generateJson: (scope: WorkflowJsonScope) => Promise<void> + getJson: (scope: WorkflowJsonScope) => Promise<string> + refreshJson: (scope: WorkflowJsonScope) => Promise<void> } export const useWorkflowJsonStore = create<WorkflowJsonStore>()( devtools( (set, get) => ({ json: '', - lastGenerated: undefined, generateJson: async (scope) => { - const clearJson = () => - set({ - json: '', - lastGenerated: Date.now(), - }) - - const scopedWorkflowId = - typeof scope?.workflowId === 'string' && scope.workflowId.trim().length > 0 - ? scope.workflowId - : null - const registryState = useWorkflowRegistry.getState() - const activeWorkflowId = - scopedWorkflowId ?? registryState.getActiveWorkflowId(scope?.channelId) + const clearJson = () => set({ json: '' }) - if (!activeWorkflowId) { - logger.warn('No active workflow to generate JSON for') + const workflowId = scope.workflowId.trim() + if (!workflowId) { + logger.warn('No workflow to generate JSON for') clearJson() return } try { - const currentWorkflow = registryState.workflows[activeWorkflowId] - - if (!currentWorkflow) { - logger.warn('No workflow metadata found for ID:', activeWorkflowId) - clearJson() - return - } - - const workflowSnapshot = getSnapshotForWorkflow(activeWorkflowId) + const workflowSnapshot = getSnapshotForWorkflow(workflowId) if (!workflowSnapshot) { - logger.warn('No workflow state found for ID:', activeWorkflowId) + logger.warn('No workflow state found for ID:', workflowId) clearJson() return } const exportFile = createWorkflowExportFile({ workflow: { - name: currentWorkflow.name, - description: currentWorkflow.description ?? '', + name: scope.name, + description: scope.description ?? '', state: workflowSnapshot, }, - skills: currentWorkflow.workspaceId - ? useSkillsStore.getState().getAllSkills(currentWorkflow.workspaceId) - : [], + skills: scope.workspaceId ? await fetchSkills(scope.workspaceId) : [], }) // Convert to formatted JSON @@ -82,7 +60,6 @@ export const useWorkflowJsonStore = create<WorkflowJsonStore>()( set({ json: jsonString, - lastGenerated: Date.now(), }) logger.info('Workflow JSON generated successfully', { @@ -100,20 +77,8 @@ export const useWorkflowJsonStore = create<WorkflowJsonStore>()( }, getJson: async (scope) => { - const currentTime = Date.now() - const { json, lastGenerated } = get() - const hasScope = - typeof scope?.workflowId === 'string' || - (typeof scope?.channelId === 'string' && scope.channelId.length > 0) - - // 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) { - await get().generateJson(scope) - return get().json - } - - return json + await get().generateJson(scope) + return get().json }, refreshJson: async (scope) => { diff --git a/apps/tradinggoose/stores/workflows/registry/store.test.ts b/apps/tradinggoose/stores/workflows/registry/store.test.ts index 1dc3c148c..02f92ba13 100644 --- a/apps/tradinggoose/stores/workflows/registry/store.test.ts +++ b/apps/tradinggoose/stores/workflows/registry/store.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { WORKSPACE_BOOTSTRAP_CHANNEL, type WorkflowMetadata } from '@/stores/workflows/registry/types' +import { + WORKSPACE_BOOTSTRAP_CHANNEL, + type WorkflowMetadata, +} from '@/stores/workflows/registry/types' type Deferred<T> = { promise: Promise<T> @@ -8,7 +11,7 @@ type Deferred<T> = { reject: (error?: unknown) => void } -const createDeferred = <T,>(): Deferred<T> => { +const createDeferred = <T>(): Deferred<T> => { let resolve!: (value: T) => void let reject!: (error?: unknown) => void @@ -77,7 +80,6 @@ const createWorkflowMetadata = (id: string, workspaceId = 'ws-test'): WorkflowMe lastModified: new Date('2026-03-02T00:00:00.000Z'), workspaceId, folderId: null, - marketplaceData: null, }) const resetRegistryState = () => { @@ -156,7 +158,8 @@ describe('workflow registry stale metadata handling', () => { vi.stubGlobal( 'fetch', vi.fn((input: RequestInfo | URL) => { - const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + const rawUrl = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url const url = new URL(rawUrl, 'http://localhost:3000') const workspaceId = url.searchParams.get('workspaceId') if (!workspaceId) { @@ -238,7 +241,8 @@ describe('workflow registry stale metadata handling', () => { vi.stubGlobal( 'fetch', vi.fn((input: RequestInfo | URL) => { - const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + const rawUrl = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url const url = new URL(rawUrl, 'http://localhost:3000') const workspaceId = url.searchParams.get('workspaceId') if (!workspaceId) { diff --git a/apps/tradinggoose/stores/workflows/registry/store.ts b/apps/tradinggoose/stores/workflows/registry/store.ts index 7849cca05..d63eb1198 100644 --- a/apps/tradinggoose/stores/workflows/registry/store.ts +++ b/apps/tradinggoose/stores/workflows/registry/store.ts @@ -3,13 +3,13 @@ import { createWithEqualityFn as create } from 'zustand/traditional' import { getStableVibrantColor } from '@/lib/colors' import { createLogger } from '@/lib/logs/console/logger' import { generateCreativeWorkflowName } from '@/lib/naming' -import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { API_ENDPOINTS } from '@/stores/constants' import { usePairColorStore } from '@/stores/dashboard/pair-store' import type { ChannelHydrationState, DeploymentStatus, WorkflowMetadata, + WorkflowMetadataSeed, WorkflowRegistry, } from '@/stores/workflows/registry/types' import { WORKSPACE_BOOTSTRAP_CHANNEL } from '@/stores/workflows/registry/types' @@ -227,9 +227,7 @@ const mapRegistryMetadata = (rows: any[]) => { name, description, color, - variables, createdAt, - marketplaceData, workspaceId, folderId, isDeployed, @@ -244,7 +242,6 @@ const mapRegistryMetadata = (rows: any[]) => { color: color || getStableVibrantColor(id), lastModified: createdAt ? new Date(createdAt) : new Date(), createdAt: createdAt ? new Date(createdAt) : new Date(), - marketplaceData: marketplaceData || null, workspaceId, folderId: folderId || null, } @@ -303,21 +300,10 @@ export function hasWorkflowsInitiallyLoaded(): boolean { // Track if initial load has happened to prevent premature navigation let hasInitiallyLoaded = false -// Cache for workflow data to prevent redundant fetches -const workflowCache = new Map<string, { data: any; timestamp: number }>() -const CACHE_TTL = 5 * 60 * 1000 // 5 minutes - // Map to track in-flight requests for deduplication const pendingRequests = new Map<string, Promise<any>>() async function fetchWorkflowData(id: string): Promise<any> { - // Check cache first - const cached = workflowCache.get(id) - if (cached && Date.now() - cached.timestamp < CACHE_TTL) { - logger.info(`Using cached data for workflow ${id}`) - return cached.data - } - // Check for pending request if (pendingRequests.has(id)) { logger.info(`Reusing in-flight request for workflow ${id}`) @@ -332,9 +318,6 @@ async function fetchWorkflowData(id: string): Promise<any> { throw new Error(`Failed to fetch workflow: ${response.statusText}`) } const { data } = await response.json() - - // Update cache - workflowCache.set(id, { data, timestamp: Date.now() }) return data } finally { // Remove from pending requests @@ -555,8 +538,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()( hasInitiallyLoaded = false // Clear current workspace state - workflowCache.clear() - set((state) => { const existingHydration = state.hydrationByChannel const realChannels = getRealHydrationChannels(existingHydration) @@ -688,13 +669,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()( const channelKey = resolveChannelKey(channelId) const state = get() const workflowMetadata = state.workflows[workflowId] - - if (!workflowMetadata) { - logger.error(`Workflow ${workflowId} not found in registry`) - set({ error: `Workflow not found: ${workflowId}` }) - throw new Error(`Workflow not found: ${workflowId}`) - } - const activeWorkflowIdForChannel = getActiveWorkflowIdFromState(state, channelKey) const hydration = getHydrationFromState(state, channelKey) @@ -711,7 +685,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()( } const requestId = crypto.randomUUID() - const workspaceId = workflowMetadata.workspaceId ?? hydration.workspaceId ?? null + const workspaceId = workflowMetadata?.workspaceId ?? hydration.workspaceId ?? null set((current) => { const nextHydrationByChannel: Record<string, ChannelHydrationState> = { @@ -783,7 +757,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()( deployedAt: workflowData.deployedAt ? new Date(workflowData.deployedAt) : undefined, apiKey: workflowData.apiKey, lastSaved: Date.now(), - marketplaceData: workflowData.marketplaceData || null, deploymentStatuses: {}, } } else { @@ -813,9 +786,13 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()( return {} } + const { workflows: fetchedWorkflows, deploymentStatuses: fetchedDeploymentStatuses } = + mapRegistryMetadata([workflowData]) + const fetchedWorkflow = fetchedWorkflows[workflowData.id ?? workflowId] + const resolvedWorkspaceId = fetchedWorkflow?.workspaceId ?? workspaceId const nextHydrationByChannel: Record<string, ChannelHydrationState> = { ...current.hydrationByChannel, - [channelKey]: createReadyHydrationState(workspaceId, workflowId), + [channelKey]: createReadyHydrationState(resolvedWorkspaceId, workflowId), } if ( @@ -825,17 +802,11 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()( delete nextHydrationByChannel[WORKSPACE_BOOTSTRAP_CHANNEL] } - const nextDeploymentStatuses = { ...current.deploymentStatuses } - if (workflowData?.isDeployed || workflowData?.deployedAt) { - nextDeploymentStatuses[workflowId] = { - isDeployed: workflowData.isDeployed || false, - deployedAt: workflowData.deployedAt ? new Date(workflowData.deployedAt) : undefined, - apiKey: workflowData.apiKey || undefined, - needsRedeployment: false, - } - } - return { + workflows: { + ...current.workflows, + ...(fetchedWorkflow ? { [workflowId]: fetchedWorkflow } : {}), + }, activeWorkflowIds: { ...current.activeWorkflowIds, [channelKey]: workflowId, @@ -844,7 +815,10 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()( ...current.loadedWorkflowIds, [channelKey]: true, }, - deploymentStatuses: nextDeploymentStatuses, + deploymentStatuses: { + ...current.deploymentStatuses, + ...fetchedDeploymentStatuses, + }, hydrationByChannel: nextHydrationByChannel, isLoading: deriveIsMetadataLoading(nextHydrationByChannel), error: null, @@ -915,17 +889,10 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()( createdAt: new Date(), description: createdWorkflow.description, color: createdWorkflow.color, - marketplaceData: options.marketplaceId - ? { id: options.marketplaceId, status: 'temp' as const } - : undefined, workspaceId, folderId: createdWorkflow.folderId, } - if (options.marketplaceId && options.marketplaceState) { - logger.info(`Created workflow from marketplace: ${options.marketplaceId}`) - } - // Add workflow to registry with server-generated ID set((state) => ({ workflows: { @@ -954,9 +921,9 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()( /** * Duplicates an existing workflow */ - duplicateWorkflow: async (sourceId: string) => { + duplicateWorkflow: async (sourceId: string, source?: WorkflowMetadataSeed) => { const { workflows } = get() - const sourceWorkflow = workflows[sourceId] + const sourceWorkflow = source ?? workflows[sourceId] if (!sourceWorkflow) { set({ error: `Workflow ${sourceId} not found` }) @@ -1005,57 +972,13 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()( // Generate new workflow metadata using the server-generated ID const newWorkflow: WorkflowMetadata = { id, - name: `${sourceWorkflow.name} (Copy)`, + name: duplicatedWorkflow.name ?? `${sourceWorkflow.name} (Copy)`, lastModified: new Date(), createdAt: new Date(), - description: sourceWorkflow.description, + description: duplicatedWorkflow.description ?? sourceWorkflow.description, color: duplicatedWorkflow.color || getStableVibrantColor(id), workspaceId, // Include the workspaceId in the new workflow - folderId: sourceWorkflow.folderId, // Include the folderId from source workflow - // Do not copy marketplace data - } - - // Get the current workflow state from the Yjs session - const { getRegisteredWorkflowSession: getYjsSession } = - require('@/lib/yjs/workflow-session-registry') as typeof import('@/lib/yjs/workflow-session-registry') - const { readWorkflowSnapshot: getYjsSnapshot } = - require('@/lib/yjs/workflow-session') as typeof import('@/lib/yjs/workflow-session') - const yjsSession = getYjsSession(sourceId) - const currentWorkflowState = yjsSession?.doc ? getYjsSnapshot(yjsSession.doc) : null - - // If we're duplicating the active workflow, use current state - // Otherwise, we need to fetch it from DB or use empty state - let sourceState: any - - if (sourceId === getActiveWorkflowIdFromState(get()) && currentWorkflowState) { - // Source is the active workflow, copy current state from Yjs - sourceState = { - blocks: currentWorkflowState.blocks || {}, - edges: currentWorkflowState.edges || [], - loops: currentWorkflowState.loops || {}, - parallels: currentWorkflowState.parallels || {}, - } - } else { - const defaultArtifacts = buildDefaultWorkflowArtifacts() - sourceState = { - blocks: defaultArtifacts.workflowState.blocks, - edges: defaultArtifacts.workflowState.edges, - loops: defaultArtifacts.workflowState.loops, - parallels: defaultArtifacts.workflowState.parallels, - } - } - - // Create the new workflow state with copied content - const newState = { - blocks: sourceState.blocks, - edges: sourceState.edges, - loops: sourceState.loops, - parallels: sourceState.parallels, - isDeployed: false, - deployedAt: undefined, - workspaceId, - deploymentStatuses: {}, - lastSaved: Date.now(), + folderId: duplicatedWorkflow.folderId ?? sourceWorkflow.folderId, // Include the folderId from source workflow } // Add workflow to registry @@ -1072,25 +995,13 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()( }, // Delete workflow and clean up associated storage - removeWorkflow: async ( - id: string, - options?: { skipApi?: boolean; templateAction?: 'keep' | 'delete' } - ) => { + removeWorkflow: async (id: string, options?: { skipApi?: boolean }) => { const skipApi = options?.skipApi ?? false - const templateAction = options?.templateAction - const { workflows } = get() - const workflowToDelete = workflows[id] - - if (!workflowToDelete) { - logger.warn(`Attempted to delete non-existent workflow: ${id}`) - return - } set({ error: null }) if (!skipApi) { try { - const query = templateAction ? `?deleteTemplates=${templateAction}` : '' - const response = await fetch(`/api/workflows/${id}${query}`, { + const response = await fetch(`/api/workflows/${id}`, { method: 'DELETE', }) @@ -1178,28 +1089,36 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()( // Update workflow metadata updateWorkflow: async ( id: string, - metadata: Partial<Pick<WorkflowMetadata, 'name' | 'description' | 'folderId'>> + metadata: Partial<Pick<WorkflowMetadata, 'name' | 'description' | 'folderId'>>, + source?: WorkflowMetadataSeed ) => { const { workflows } = get() - const workflow = workflows[id] - if (!workflow) { - logger.warn(`Cannot update workflow ${id}: not found in registry`) - return - } + const workflow = workflows[id] ?? source + const workflowForState: WorkflowMetadata | null = workflow + ? { + ...workflow, + lastModified: workflow.lastModified ?? new Date(), + createdAt: workflow.createdAt ?? new Date(), + } + : null // Optimistically update local state first - set((state) => ({ - workflows: { - ...state.workflows, - [id]: { - ...workflow, - ...metadata, - lastModified: new Date(), - createdAt: workflow.createdAt, // Preserve creation date + if (workflowForState) { + set((state) => ({ + workflows: { + ...state.workflows, + [id]: { + ...workflowForState, + ...metadata, + lastModified: new Date(), + createdAt: workflowForState.createdAt, + }, }, - }, - error: null, - })) + error: null, + })) + } else { + set({ error: null }) + } // Persist to database via API try { @@ -1222,15 +1141,17 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()( workflows: { ...state.workflows, [id]: { - ...state.workflows[id], + ...(state.workflows[id] ?? workflowForState), + id: updatedWorkflow.id ?? id, name: updatedWorkflow.name, description: updatedWorkflow.description, color: updatedWorkflow.color, + workspaceId: updatedWorkflow.workspaceId, folderId: updatedWorkflow.folderId, lastModified: new Date(updatedWorkflow.updatedAt), createdAt: updatedWorkflow.createdAt ? new Date(updatedWorkflow.createdAt) - : state.workflows[id].createdAt, + : ((state.workflows[id] ?? workflowForState)?.createdAt ?? new Date()), }, }, })) @@ -1241,7 +1162,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()( set((state) => ({ workflows: { ...state.workflows, - [id]: workflow, // Revert to original state + ...(workflowForState ? { [id]: workflowForState } : {}), }, error: `Failed to update workflow: ${error instanceof Error ? error.message : 'Unknown error'}`, })) diff --git a/apps/tradinggoose/stores/workflows/registry/types.ts b/apps/tradinggoose/stores/workflows/registry/types.ts index 855c3f6b3..968856b25 100644 --- a/apps/tradinggoose/stores/workflows/registry/types.ts +++ b/apps/tradinggoose/stores/workflows/registry/types.ts @@ -1,8 +1,3 @@ -export interface MarketplaceData { - id: string // Marketplace entry ID to track original marketplace source - status: 'owner' | 'temp' -} - export interface DeploymentStatus { isDeployed: boolean deployedAt?: Date @@ -17,11 +12,16 @@ export interface WorkflowMetadata { createdAt: Date description?: string color: string - marketplaceData?: MarketplaceData | null workspaceId?: string folderId?: string | null } +export type WorkflowMetadataSeed = Pick< + WorkflowMetadata, + 'id' | 'name' | 'description' | 'color' | 'workspaceId' | 'folderId' +> & + Partial<Pick<WorkflowMetadata, 'lastModified' | 'createdAt'>> + export type HydrationPhase = | 'idle' | 'metadata-loading' @@ -59,25 +59,21 @@ export interface WorkflowRegistryActions { setActiveWorkflow: (params: { workflowId: string; channelId?: string }) => Promise<void> switchToWorkspace: (id: string) => Promise<void> loadWorkflows: (params: { workspaceId: string; channelId?: string }) => Promise<void> - removeWorkflow: ( - id: string, - options?: { skipApi?: boolean; templateAction?: 'keep' | 'delete' } - ) => Promise<void> + removeWorkflow: (id: string, options?: { skipApi?: boolean }) => Promise<void> updateWorkflow: ( id: string, - metadata: Partial<Pick<WorkflowMetadata, 'name' | 'description' | 'folderId'>> + metadata: Partial<Pick<WorkflowMetadata, 'name' | 'description' | 'folderId'>>, + source?: WorkflowMetadataSeed ) => Promise<void> createWorkflow: (options?: { isInitial?: boolean - marketplaceId?: string - marketplaceState?: any name?: string description?: string workspaceId?: string folderId?: string | null initialWorkflowState?: any }) => Promise<string> - duplicateWorkflow: (sourceId: string) => Promise<string | null> + duplicateWorkflow: (sourceId: string, source?: WorkflowMetadataSeed) => Promise<string | null> readWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null setDeploymentStatus: ( workflowId: string | null, diff --git a/apps/tradinggoose/stores/workflows/subblock/store.ts b/apps/tradinggoose/stores/workflows/subblock/store.ts deleted file mode 100644 index 5432126a1..000000000 --- a/apps/tradinggoose/stores/workflows/subblock/store.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { createWithEqualityFn as create } from 'zustand/traditional' -import { devtools } from 'zustand/middleware' -import { getBlock } from '@/blocks' -import type { SubBlockConfig } from '@/blocks/types' -import { populateTriggerFieldsFromConfig } from '@/hooks/use-trigger-config-aggregation' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import type { SubBlockStore } from '@/stores/workflows/subblock/types' -import { DEFAULT_WORKFLOW_CHANNEL_ID } from '@/stores/workflows/workflow/store' -import { isTriggerValid } from '@/triggers' -import { resolveTriggerIdForBlock } from '@/triggers/resolution' - -/** - * SubBlockState stores values for all subblocks in workflows - * - * Important implementation notes: - * 1. Values are stored per workflow, per block, per subblock - * 2. When workflows are synced to the database, the mergeSubblockState function - * in utils.ts combines the block structure with these values - * 3. If a subblock value exists here but not in the block structure - * (e.g., inputFormat in the input trigger block), the merge function will include it - * in the synchronized state to ensure persistence - */ - -export const useSubBlockStore = create<SubBlockStore>()( - devtools((set, get) => ({ - workflowValues: {}, - loadingWebhooks: new Set<string>(), - checkedWebhooks: new Set<string>(), - - setValue: (blockId: string, subBlockId: string, value: any, workflowId?: string) => { - const resolvedWorkflowId = - workflowId ?? - useWorkflowRegistry.getState().getActiveWorkflowId(DEFAULT_WORKFLOW_CHANNEL_ID) - if (!resolvedWorkflowId) return - - // Validate and fix table data if needed - let validatedValue = value - if (Array.isArray(value)) { - // Check if this looks like table data (array of objects with cells) - const isTableData = - value.length > 0 && - value.some((item) => item && typeof item === 'object' && 'cells' in item) - - if (isTableData) { - console.log('Validating table data for subblock:', { blockId, subBlockId }) - validatedValue = value.map((row: any) => { - // Ensure each row has proper structure - if (!row || typeof row !== 'object') { - console.warn('Fixing malformed table row:', row) - return { - id: crypto.randomUUID(), - cells: { Key: '', Value: '' }, - } - } - - // Ensure row has an id - if (!row.id) { - row.id = crypto.randomUUID() - } - - // Ensure row has cells object - if (!row.cells || typeof row.cells !== 'object') { - console.warn('Fixing malformed table row cells:', row) - row.cells = { Key: '', Value: '' } - } - - return row - }) - } - } - - set((state) => ({ - workflowValues: { - ...state.workflowValues, - [resolvedWorkflowId]: { - ...state.workflowValues[resolvedWorkflowId], - [blockId]: { - ...state.workflowValues[resolvedWorkflowId]?.[blockId], - [subBlockId]: validatedValue, - }, - }, - }, - })) - - // Trigger debounced sync to DB - get().syncWithDB() - }, - - getValue: (blockId: string, subBlockId: string, workflowId?: string) => { - const resolvedWorkflowId = - workflowId ?? - useWorkflowRegistry.getState().getActiveWorkflowId(DEFAULT_WORKFLOW_CHANNEL_ID) - if (!resolvedWorkflowId) return null - - return get().workflowValues[resolvedWorkflowId]?.[blockId]?.[subBlockId] ?? null - }, - - clear: () => { - const activeWorkflowId = useWorkflowRegistry - .getState() - .getActiveWorkflowId(DEFAULT_WORKFLOW_CHANNEL_ID) - if (!activeWorkflowId) return - - set((state) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId]: {}, - }, - })) - - // Note: Socket.IO handles real-time sync automatically - }, - setWorkflowValues: (workflowId: string, values: Record<string, Record<string, any>>) => { - set((state) => ({ - workflowValues: { - ...state.workflowValues, - [workflowId]: values, - }, - })) - }, - - initializeFromWorkflow: (workflowId: string, blocks: Record<string, any>) => { - // Initialize from blocks - const values: Record<string, Record<string, any>> = {} - Object.entries(blocks).forEach(([blockId, block]) => { - values[blockId] = {} - Object.entries(block.subBlocks || {}).forEach(([subBlockId, subBlock]) => { - values[blockId][subBlockId] = (subBlock as SubBlockConfig).value - }) - }) - - set((state) => ({ - workflowValues: { - ...state.workflowValues, - [workflowId]: values, - }, - })) - - Object.entries(blocks).forEach(([blockId, block]) => { - const blockConfig = getBlock(block.type) - if (!blockConfig) return - - const isTriggerBlock = blockConfig.category === 'triggers' || block.triggerMode === true - if (!isTriggerBlock) return - - const triggerId = resolveTriggerIdForBlock(block) ?? undefined - - if (!triggerId || !isTriggerValid(triggerId)) { - return - } - - const triggerConfigSubBlock = block.subBlocks?.triggerConfig - if (triggerConfigSubBlock?.value && typeof triggerConfigSubBlock.value === 'object') { - populateTriggerFieldsFromConfig( - blockId, - triggerConfigSubBlock.value, - triggerId, - workflowId - ) - - const currentChecked = get().checkedWebhooks - if (currentChecked.has(blockId)) { - set((state) => { - const newSet = new Set(state.checkedWebhooks) - newSet.delete(blockId) - return { checkedWebhooks: newSet } - }) - } - } - }) - }, - - // Removed syncWithDB - Socket.IO handles real-time sync automatically - syncWithDB: () => { - // No-op: Socket.IO handles real-time sync - }, - })) -) diff --git a/apps/tradinggoose/stores/workflows/subblock/types.ts b/apps/tradinggoose/stores/workflows/subblock/types.ts deleted file mode 100644 index 9aab53870..000000000 --- a/apps/tradinggoose/stores/workflows/subblock/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface SubBlockState { - workflowValues: Record<string, Record<string, Record<string, any>>> // Store values per workflow ID - loadingWebhooks: Set<string> - checkedWebhooks: Set<string> -} - -export interface SubBlockStore extends SubBlockState { - setValue: (blockId: string, subBlockId: string, value: any, workflowId?: string) => void - getValue: (blockId: string, subBlockId: string, workflowId?: string) => any - clear: () => void - initializeFromWorkflow: (workflowId: string, blocks: Record<string, any>) => void - setWorkflowValues: (workflowId: string, values: Record<string, Record<string, any>>) => void - // Add debounced sync function - syncWithDB: () => void -} diff --git a/apps/tradinggoose/stores/workflows/subblock/utils.ts b/apps/tradinggoose/stores/workflows/subblock/utils.ts deleted file mode 100644 index 578ebe141..000000000 --- a/apps/tradinggoose/stores/workflows/subblock/utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -// DEPRECATED: useEnvironmentStore import removed as autofill functions were removed - -/** - * Checks if a value is an environment variable reference in the format {{ENV_VAR}} - */ -export const isEnvVarReference = (value: string): boolean => { - // Check if the value looks like {{ENV_VAR}} - return /^\{\{[a-zA-Z0-9_-]+\}\}$/.test(value) -} - -/** - * Extracts the environment variable name from a reference like {{ENV_VAR}} - */ -export const extractEnvVarName = (value: string): string | null => { - if (!isEnvVarReference(value)) return null - return value.slice(2, -2) -} diff --git a/apps/tradinggoose/stores/workflows/workflow/store-client.tsx b/apps/tradinggoose/stores/workflows/workflow/store-client.tsx deleted file mode 100644 index 55112ffcb..000000000 --- a/apps/tradinggoose/stores/workflows/workflow/store-client.tsx +++ /dev/null @@ -1,82 +0,0 @@ -'use client' - -import { createContext, type ReactNode, useContext, useMemo } from 'react' -import { useStoreWithEqualityFn } from 'zustand/traditional' -import type { StoreApi } from 'zustand/vanilla' -import { - DEFAULT_WORKFLOW_CHANNEL_ID, - readWorkflowStoreForChannel, - readWorkflowStoreState, - setWorkflowStoreState, - subscribeToWorkflowStore, -} from '@/stores/workflows/workflow/store' -import type { WorkflowStore } from '@/stores/workflows/workflow/types' - -const WorkflowStoreContext = createContext<StoreApi<WorkflowStore>>(readWorkflowStoreForChannel()) - -export function WorkflowStoreProvider({ - channelId = DEFAULT_WORKFLOW_CHANNEL_ID, - workflowId, - children, -}: { - channelId?: string - workflowId?: string - children: ReactNode -}) { - const store = useMemo( - () => readWorkflowStoreForChannel(channelId, workflowId), - [channelId, workflowId] - ) - return <WorkflowStoreContext.Provider value={store}>{children}</WorkflowStoreContext.Provider> -} - -type Selector<T> = (state: WorkflowStore) => T -type EqualityFn<T> = (a: T, b: T) => boolean - -function useWorkflowStoreBase(): WorkflowStore -function useWorkflowStoreBase<T>(selector: Selector<T>, equalityFn?: EqualityFn<T>): T -function useWorkflowStoreBase<T>(selector?: Selector<T>, equalityFn?: EqualityFn<T>) { - const store = useContext(WorkflowStoreContext) - if (!store) { - throw new Error('useWorkflowStore must be used within a WorkflowStoreProvider') - } - - if (!selector) { - return useStoreWithEqualityFn(store) - } - - return equalityFn - ? useStoreWithEqualityFn(store, selector, equalityFn) - : useStoreWithEqualityFn(store, selector) -} - -type UseWorkflowStoreHook = typeof useWorkflowStoreBase & { - getState: (channelId?: string) => WorkflowStore - setState: ( - partial: Parameters<StoreApi<WorkflowStore>['setState']>[0], - replace?: Parameters<StoreApi<WorkflowStore>['setState']>[1] - ) => void - setStateForChannel: ( - partial: Parameters<StoreApi<WorkflowStore>['setState']>[0], - channelId: string, - replace?: Parameters<StoreApi<WorkflowStore>['setState']>[1], - workflowId?: string - ) => void - subscribe: ( - listener: Parameters<StoreApi<WorkflowStore>['subscribe']>[0], - channelId?: string - ) => () => void -} - -export const useWorkflowStore = useWorkflowStoreBase as UseWorkflowStoreHook - -useWorkflowStore.getState = (channelId?: string) => readWorkflowStoreState(channelId) - -useWorkflowStore.setState = (partial, replace) => setWorkflowStoreState(partial, undefined, replace) - -useWorkflowStore.setStateForChannel = (partial, channelId, replace, workflowId) => - setWorkflowStoreState(partial, channelId, replace, workflowId) - -useWorkflowStore.subscribe = (listener, channelId) => subscribeToWorkflowStore(listener, channelId) - -export { DEFAULT_WORKFLOW_CHANNEL_ID } from '@/stores/workflows/workflow/store' diff --git a/apps/tradinggoose/stores/workflows/workflow/store.ts b/apps/tradinggoose/stores/workflows/workflow/store.ts deleted file mode 100644 index b7489586c..000000000 --- a/apps/tradinggoose/stores/workflows/workflow/store.ts +++ /dev/null @@ -1,1309 +0,0 @@ -import type { Edge } from '@xyflow/react' -import type { StateCreator } from 'zustand' -import { devtools } from 'zustand/middleware' -import { createStore, type StoreApi } from 'zustand/vanilla' -import { createLogger } from '@/lib/logs/console/logger' -import { resolveBlockRuntimeState } from '@/lib/workflows/block-outputs' -import { buildInitialSubBlockStates } from '@/lib/workflows/subblock-values' -import { getBlock } from '@/blocks' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { - getUniqueBlockName, - mergeSubblockState, - normalizeBlockName, -} from '@/stores/workflows/utils' -import type { - Position, - SubBlockState, - WorkflowState, - WorkflowStore, -} from '@/stores/workflows/workflow/types' -import { - findAllDescendantNodes, - generateLoopBlocks, - generateParallelBlocks, -} from '@/stores/workflows/workflow/utils' - -const logger = createLogger('WorkflowStore') - -const initialState = { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - lastSaved: undefined, - // Legacy deployment fields (keeping for compatibility but they will be deprecated) - isDeployed: false, - deployedAt: undefined, - // New field for per-workflow deployment tracking - deploymentStatuses: {}, - needsRedeployment: false, -} - -export const DEFAULT_WORKFLOW_CHANNEL_ID = 'default' - -type WorkflowStoreStateCreator = StateCreator<WorkflowStore, [['zustand/devtools', never]], []> - -const createWorkflowStoreState = - (channelId: string): WorkflowStoreStateCreator => - (set, get) => ({ - ...initialState, - - setNeedsRedeploymentFlag: (needsRedeployment: boolean) => { - set({ needsRedeployment }) - }, - - addBlock: ( - id: string, - type: string, - name: string, - position: Position, - data?: Record<string, any>, - parentId?: string, - extent?: 'parent', - blockProperties?: { - enabled?: boolean - locked?: boolean - horizontalHandles?: boolean - isWide?: boolean - advancedMode?: boolean - triggerMode?: boolean - height?: number - } - ) => { - const blockConfig = getBlock(type) - // For custom nodes like loop and parallel that don't use BlockConfig - if (!blockConfig && (type === 'loop' || type === 'parallel')) { - // Merge parentId and extent into data if provided - const nodeData = { - ...data, - ...(parentId && { parentId, extent: extent || 'parent' }), - } - - const newState = { - blocks: { - ...get().blocks, - [id]: { - id, - type, - name, - position, - subBlocks: {}, - outputs: {}, - enabled: blockProperties?.enabled ?? true, - locked: blockProperties?.locked ?? false, - horizontalHandles: blockProperties?.horizontalHandles ?? true, - isWide: blockProperties?.isWide ?? false, - advancedMode: blockProperties?.advancedMode ?? false, - triggerMode: blockProperties?.triggerMode ?? false, - height: blockProperties?.height ?? 0, - data: nodeData, - }, - }, - edges: [...get().edges], - loops: get().generateLoopBlocks(), - parallels: get().generateParallelBlocks(), - } - - set(newState) - get().updateLastSaved() - return - } - - if (!blockConfig) return - - // Merge parentId and extent into data for regular blocks - const nodeData = { - ...data, - ...(parentId && { parentId, extent: extent || 'parent' }), - } - - let subBlocks = buildInitialSubBlockStates(blockConfig.subBlocks) as Record< - string, - SubBlockState - > - - const triggerMode = blockProperties?.triggerMode ?? false - const runtimeState = resolveBlockRuntimeState({ - blockType: type, - blockConfig, - subBlocks, - triggerMode, - }) - subBlocks = runtimeState.subBlocks - const outputs = runtimeState.outputs - - const newState = { - blocks: { - ...get().blocks, - [id]: { - id, - type, - name, - position, - subBlocks, - outputs, - enabled: blockProperties?.enabled ?? true, - locked: blockProperties?.locked ?? false, - horizontalHandles: blockProperties?.horizontalHandles ?? true, - isWide: blockProperties?.isWide ?? false, - advancedMode: blockProperties?.advancedMode ?? false, - triggerMode: triggerMode, - height: blockProperties?.height ?? 0, - layout: {}, - data: nodeData, - }, - }, - edges: [...get().edges], - loops: get().generateLoopBlocks(), - parallels: get().generateParallelBlocks(), - } - - set(newState) - get().updateLastSaved() - }, - - updateBlockPosition: (id: string, position: Position) => { - set((state) => ({ - blocks: { - ...state.blocks, - [id]: { - ...state.blocks[id], - position, - }, - }, - edges: [...state.edges], - })) - get().updateLastSaved() - // No sync for position updates to avoid excessive syncing during drag - }, - - updateBlockPositions: (updates: Array<{ id: string; position: Position }>) => { - updates.forEach(({ id, position }) => { - get().updateBlockPosition(id, position) - }) - }, - - updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => { - set((state) => { - // Check if the block exists before trying to update it - const block = state.blocks[id] - if (!block) { - logger.warn(`Cannot update dimensions: Block ${id} not found in workflow store`) - return state // Return unchanged state - } - - return { - blocks: { - ...state.blocks, - [id]: { - ...block, - data: { - ...block.data, - width: dimensions.width, - height: dimensions.height, - }, - layout: { - ...block.layout, - measuredWidth: dimensions.width, - measuredHeight: dimensions.height, - }, - }, - }, - edges: [...state.edges], - } - }) - get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically - }, - - updateParentId: (id: string, parentId: string, extent: 'parent') => { - const block = get().blocks[id] - if (!block) { - logger.warn(`Cannot set parent: Block ${id} not found`) - return - } - - logger.info('UpdateParentId called:', { - blockId: id, - blockName: block.name, - blockType: block.type, - newParentId: parentId, - extent, - currentParentId: block.data?.parentId, - }) - - // Skip if the parent ID hasn't changed - if (block.data?.parentId === parentId) { - logger.info('Parent ID unchanged, skipping update') - return - } - - // Store current absolute position - const absolutePosition = { ...block.position } - - // Handle empty or null parentId (removing from parent) - // On removal, clear the data JSON entirely per normalized DB contract - const newData = !parentId - ? {} - : { - ...block.data, - parentId, - extent, - } - - // For removal we already set data to {}; for setting a parent keep as-is - - const newState = { - blocks: { - ...get().blocks, - [id]: { - ...block, - position: absolutePosition, - data: newData, - }, - }, - edges: [...get().edges], - loops: { ...get().loops }, - parallels: { ...get().parallels }, - } - - logger.info('[WorkflowStore/updateParentId] Updated parentId relationship:', { - blockId: id, - newParentId: parentId || 'None (removed parent)', - keepingPosition: absolutePosition, - }) - - set(newState) - get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically - }, - - updateParentIds: (updates: Array<{ id: string; parentId: string; extent: 'parent' }>) => { - updates.forEach(({ id, parentId, extent }) => { - get().updateParentId(id, parentId, extent) - }) - }, - - removeBlock: (id: string) => { - // First, clean up any subblock values for this block - const subBlockStore = useSubBlockStore.getState() - const activeWorkflowId = useWorkflowRegistry.getState().getActiveWorkflowId(channelId) - - const newState = { - blocks: { ...get().blocks }, - edges: [...get().edges].filter((edge) => edge.source !== id && edge.target !== id), - loops: { ...get().loops }, - parallels: { ...get().parallels }, - } - - // Find and remove all child blocks if this is a parent node - const blocksToRemove = new Set([id]) - - // Recursively find all descendant blocks (children, grandchildren, etc.) - const findAllDescendants = (parentId: string) => { - Object.entries(newState.blocks).forEach(([blockId, block]) => { - if (block.data?.parentId === parentId) { - blocksToRemove.add(blockId) - // Recursively find this block's children - findAllDescendants(blockId) - } - }) - } - - // Start recursive search from the target block - findAllDescendants(id) - - logger.info('Found blocks to remove:', { - targetId: id, - totalBlocksToRemove: Array.from(blocksToRemove), - includesHierarchy: blocksToRemove.size > 1, - }) - - // Clean up subblock values before removing the block - if (activeWorkflowId && subBlockStore.workflowValues) { - const updatedWorkflowValues = { - ...(subBlockStore.workflowValues[activeWorkflowId] || {}), - } - - // Remove values for all blocks being deleted - blocksToRemove.forEach((blockId) => { - delete updatedWorkflowValues[blockId] - }) - - // Update subblock store - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId]: updatedWorkflowValues, - }, - })) - } - - // Remove all edges connected to any of the blocks being removed - newState.edges = newState.edges.filter( - (edge) => !blocksToRemove.has(edge.source) && !blocksToRemove.has(edge.target) - ) - - // Delete all blocks marked for removal - blocksToRemove.forEach((blockId) => { - delete newState.blocks[blockId] - }) - - set(newState) - get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically - }, - - addEdge: (edge: Edge) => { - // Check for duplicate connections - const isDuplicate = get().edges.some( - (existingEdge) => - existingEdge.source === edge.source && - existingEdge.target === edge.target && - existingEdge.sourceHandle === edge.sourceHandle && - existingEdge.targetHandle === edge.targetHandle - ) - - // If it's a duplicate connection, return early without adding the edge - if (isDuplicate) { - return - } - - const newEdge: Edge = { - id: edge.id || crypto.randomUUID(), - source: edge.source, - target: edge.target, - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, - type: edge.type || 'default', - data: edge.data || {}, - } - - const newEdges = [...get().edges, newEdge] - - const newState = { - blocks: { ...get().blocks }, - edges: newEdges, - loops: generateLoopBlocks(get().blocks), - parallels: get().generateParallelBlocks(), - } - - set(newState) - get().updateLastSaved() - }, - - removeEdge: (edgeId: string) => { - // Validate the edge exists - const edgeToRemove = get().edges.find((edge) => edge.id === edgeId) - if (!edgeToRemove) { - logger.warn(`Attempted to remove non-existent edge: ${edgeId}`) - return - } - - const newEdges = get().edges.filter((edge) => edge.id !== edgeId) - - const newState = { - blocks: { ...get().blocks }, - edges: newEdges, - loops: generateLoopBlocks(get().blocks), - parallels: get().generateParallelBlocks(), - } - - set(newState) - get().updateLastSaved() - }, - - clear: () => { - const newState = { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - lastSaved: Date.now(), - } - set(newState) - // Note: Socket.IO handles real-time sync automatically - return newState - }, - - updateLastSaved: () => { - set({ lastSaved: Date.now() }) - // Note: Socket.IO handles real-time sync automatically - }, - - // Add method to get current workflow state (eliminates duplication in diff store) - readWorkflowState: (): WorkflowState => { - const state = get() - return { - blocks: state.blocks, - edges: state.edges, - loops: state.loops, - parallels: state.parallels, - lastSaved: state.lastSaved, - isDeployed: state.isDeployed, - deployedAt: state.deployedAt, - deploymentStatuses: state.deploymentStatuses, - needsRedeployment: state.needsRedeployment, - } - }, - replaceWorkflowState: ( - workflowState: WorkflowState, - options?: { updateLastSaved?: boolean } - ) => { - set((state) => { - const nextBlocks = workflowState.blocks || {} - const nextEdges = workflowState.edges || [] - const nextLoops = - Object.keys(workflowState.loops || {}).length > 0 - ? workflowState.loops - : generateLoopBlocks(nextBlocks) - const nextParallels = - Object.keys(workflowState.parallels || {}).length > 0 - ? workflowState.parallels - : generateParallelBlocks(nextBlocks) - - return { - ...state, - blocks: nextBlocks, - edges: nextEdges, - loops: nextLoops, - parallels: nextParallels, - isDeployed: - workflowState.isDeployed !== undefined ? workflowState.isDeployed : state.isDeployed, - deployedAt: workflowState.deployedAt ?? state.deployedAt, - deploymentStatuses: workflowState.deploymentStatuses || state.deploymentStatuses, - needsRedeployment: - workflowState.needsRedeployment !== undefined - ? workflowState.needsRedeployment - : state.needsRedeployment, - lastSaved: - options?.updateLastSaved === true - ? Date.now() - : (workflowState.lastSaved ?? state.lastSaved), - } - }) - }, - - toggleBlockEnabled: (id: string) => { - const newState = { - blocks: { - ...get().blocks, - [id]: { - ...get().blocks[id], - enabled: !get().blocks[id].enabled, - }, - }, - edges: [...get().edges], - loops: { ...get().loops }, - parallels: { ...get().parallels }, - } - - set(newState) - get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically - }, - - toggleBlockLocked: (id: string) => { - const currentBlocks = get().blocks - const targetBlock = currentBlocks[id] - if (!targetBlock) return - - const targetLocked = !targetBlock.locked - const newBlocks = { ...currentBlocks } - const blockIdsToToggle = new Set<string>([id]) - - if (targetBlock.type === 'loop' || targetBlock.type === 'parallel') { - findAllDescendantNodes(id, currentBlocks).forEach((descId) => { - blockIdsToToggle.add(descId) - }) - } - - for (const blockId of blockIdsToToggle) { - const block = newBlocks[blockId] - if (!block) continue - newBlocks[blockId] = { - ...block, - locked: targetLocked, - } - } - - set({ - blocks: newBlocks, - edges: [...get().edges], - loops: { ...get().loops }, - parallels: { ...get().parallels }, - }) - get().updateLastSaved() - }, - - duplicateBlock: (id: string) => { - const block = get().blocks[id] - if (!block) return - - const newId = crypto.randomUUID() - const offsetPosition = { - x: block.position.x + 250, - y: block.position.y + 20, - } - - const newName = getUniqueBlockName(block.name, get().blocks) - - // Get merged state to capture current subblock values - const activeWorkflowId = useWorkflowRegistry.getState().getActiveWorkflowId(channelId) - const mergedBlock = activeWorkflowId - ? mergeSubblockState(get().blocks, activeWorkflowId, id)[id] - : get().blocks[id] - - // Create new subblocks with merged values - const newSubBlocks = Object.entries(mergedBlock.subBlocks).reduce( - (acc, [subId, subBlock]) => ({ - ...acc, - [subId]: { - ...subBlock, - value: JSON.parse(JSON.stringify(subBlock.value)), - }, - }), - {} - ) - - const newState = { - blocks: { - ...get().blocks, - [newId]: { - ...block, - id: newId, - name: newName, - position: offsetPosition, - subBlocks: newSubBlocks, - locked: false, - }, - }, - edges: [...get().edges], - loops: get().generateLoopBlocks(), - parallels: get().generateParallelBlocks(), - } - - // Update the subblock store with the duplicated values - if (activeWorkflowId) { - const subBlockValues = - useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {} - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId]: { - ...state.workflowValues[activeWorkflowId], - [newId]: JSON.parse(JSON.stringify(subBlockValues)), - }, - }, - })) - } - - set(newState) - get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically - }, - - toggleBlockHandles: (id: string) => { - const newState = { - blocks: { - ...get().blocks, - [id]: { - ...get().blocks[id], - horizontalHandles: !get().blocks[id].horizontalHandles, - }, - }, - edges: [...get().edges], - loops: { ...get().loops }, - } - - set(newState) - get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically - }, - - updateBlockName: (id: string, name: string) => { - const oldBlock = get().blocks[id] - if (!oldBlock) return false - - // Check for normalized name collisions - const normalizedNewName = normalizeBlockName(name) - const currentBlocks = get().blocks - - // Find any other block with the same normalized name - const conflictingBlock = Object.entries(currentBlocks).find(([blockId, block]) => { - return ( - blockId !== id && // Different block - block.name && // Has a name - normalizeBlockName(block.name) === normalizedNewName // Same normalized name - ) - }) - - if (conflictingBlock) { - // Don't allow the rename - another block already uses this normalized name - logger.error( - `Cannot rename block to "${name}" - another block "${conflictingBlock[1].name}" already uses the normalized name "${normalizedNewName}"` - ) - return false - } - - // Create a new state with the updated block name - const newState = { - blocks: { - ...get().blocks, - [id]: { - ...oldBlock, - name, - }, - }, - edges: [...get().edges], - loops: { ...get().loops }, - parallels: { ...get().parallels }, - } - - // Update references in subblock store - const subBlockStore = useSubBlockStore.getState() - const activeWorkflowId = useWorkflowRegistry.getState().getActiveWorkflowId(channelId) - if (activeWorkflowId) { - // Get the workflow values for the active workflow - // workflowValues: {[block_id]:{[subblock_id]:[subblock_value]}} - const workflowValues = subBlockStore.workflowValues[activeWorkflowId] || {} - const updatedWorkflowValues = { ...workflowValues } - const changedSubblocks: Array<{ blockId: string; subBlockId: string; newValue: any }> = [] - - // Loop through blocks - Object.entries(workflowValues).forEach(([blockId, blockValues]) => { - if (blockId === id) return // Skip the block being renamed - - // Loop through subblocks and update references - Object.entries(blockValues).forEach(([subBlockId, value]) => { - const oldBlockName = oldBlock.name.replace(/\s+/g, '').toLowerCase() - const newBlockName = name.replace(/\s+/g, '').toLowerCase() - const regex = new RegExp(`<${oldBlockName}\\.`, 'g') - - // Use a recursive function to handle all object types - const updatedValue = updateReferences(value, regex, `<${newBlockName}.`) - - // Check if the value actually changed - if (JSON.stringify(updatedValue) !== JSON.stringify(value)) { - updatedWorkflowValues[blockId][subBlockId] = updatedValue - changedSubblocks.push({ - blockId, - subBlockId, - newValue: updatedValue, - }) - } - - // Helper function to recursively update references in any data structure - function updateReferences(value: any, regex: RegExp, replacement: string): any { - // Handle string values - if (typeof value === 'string') { - return regex.test(value) ? value.replace(regex, replacement) : value - } - - // Handle arrays - if (Array.isArray(value)) { - return value.map((item) => updateReferences(item, regex, replacement)) - } - - // Handle objects - if (value !== null && typeof value === 'object') { - const result = { ...value } - for (const key in result) { - result[key] = updateReferences(result[key], regex, replacement) - } - return result - } - - // Return unchanged for other types - return value - } - }) - }) - - // Update the subblock store with the new values - useSubBlockStore.setState({ - workflowValues: { - ...subBlockStore.workflowValues, - [activeWorkflowId]: updatedWorkflowValues, - }, - }) - - // Store changed subblocks for collaborative sync - if (changedSubblocks.length > 0) { - // Store the changed subblocks for the collaborative function to pick up - ;(window as any).__pendingSubblockUpdates = changedSubblocks - } - } - - set(newState) - get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically - - return true - }, - - toggleBlockWide: (id: string) => { - set((state) => ({ - blocks: { - ...state.blocks, - [id]: { - ...state.blocks[id], - isWide: !state.blocks[id].isWide, - }, - }, - edges: [...state.edges], - loops: { ...state.loops }, - })) - get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically - }, - - setBlockWide: (id: string, isWide: boolean) => { - set((state) => ({ - blocks: { - ...state.blocks, - [id]: { - ...state.blocks[id], - isWide, - }, - }, - edges: [...state.edges], - loops: { ...state.loops }, - })) - get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically - }, - - setBlockAdvancedMode: (id: string, advancedMode: boolean) => { - set((state) => ({ - blocks: { - ...state.blocks, - [id]: { - ...state.blocks[id], - advancedMode, - }, - }, - edges: [...state.edges], - loops: { ...state.loops }, - })) - get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically - }, - - setBlockTriggerMode: (id: string, triggerMode: boolean) => { - set((state) => { - const block = state.blocks[id] - const blockConfig = block ? getBlock(block.type) : undefined - if (!block || !blockConfig) { - return state - } - - const runtimeState = resolveBlockRuntimeState({ - blockType: block.type, - blockConfig, - subBlocks: block.subBlocks, - triggerMode, - }) - - return { - blocks: { - ...state.blocks, - [id]: { - ...block, - subBlocks: runtimeState.subBlocks, - outputs: runtimeState.outputs, - triggerMode, - }, - }, - edges: [...state.edges], - loops: { ...state.loops }, - } - }) - get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically - }, - - updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => { - set((state) => { - const block = state.blocks[id] - if (!block) { - logger.warn(`Cannot update layout metrics: Block ${id} not found in workflow store`) - return state - } - - return { - blocks: { - ...state.blocks, - [id]: { - ...block, - height: dimensions.height, - layout: { - ...block.layout, - measuredWidth: dimensions.width, - measuredHeight: dimensions.height, - }, - }, - }, - edges: [...state.edges], - loops: { ...state.loops }, - } - }) - get().updateLastSaved() - // No sync needed for layout changes, just visual - }, - - updateLoopCount: (loopId: string, count: number) => - set((state) => { - const block = state.blocks[loopId] - if (!block || block.type !== 'loop') return state - - const newBlocks = { - ...state.blocks, - [loopId]: { - ...block, - data: { - ...block.data, - count: Math.max(1, Math.min(100, count)), // Clamp between 1-100 - }, - }, - } - - return { - blocks: newBlocks, - edges: [...state.edges], - loops: generateLoopBlocks(newBlocks), // Regenerate loops - } - }), - - updateLoopType: (loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') => - set((state) => { - const block = state.blocks[loopId] - if (!block || block.type !== 'loop') return state - - const newBlocks = { - ...state.blocks, - [loopId]: { - ...block, - data: { - ...block.data, - loopType, - }, - }, - } - - return { - blocks: newBlocks, - edges: [...state.edges], - loops: generateLoopBlocks(newBlocks), // Regenerate loops - } - }), - - updateLoopCollection: (loopId: string, collection: string) => - set((state) => { - const block = state.blocks[loopId] - if (!block || block.type !== 'loop') return state - - const loopType = block.data?.loopType || 'for' - - // Update the appropriate field based on loop type - const dataUpdate: any = { ...block.data } - if (loopType === 'while' || loopType === 'doWhile') { - dataUpdate.whileCondition = collection - } else { - dataUpdate.collection = collection - } - - const newBlocks = { - ...state.blocks, - [loopId]: { - ...block, - data: dataUpdate, - }, - } - - return { - blocks: newBlocks, - edges: [...state.edges], - loops: generateLoopBlocks(newBlocks), // Regenerate loops - } - }), - - // Function to convert UI loop blocks to execution format - generateLoopBlocks: () => { - return generateLoopBlocks(get().blocks) - }, - - triggerUpdate: () => { - set((state) => ({ - ...state, - lastUpdate: Date.now(), - })) - }, - - revertToDeployedState: async (deployedState: WorkflowState) => { - const activeWorkflowId = useWorkflowRegistry.getState().getActiveWorkflowId(channelId) - - if (!activeWorkflowId) { - logger.error('Cannot revert: no active workflow ID') - return - } - - // Preserving the workflow-specific deployment status if it exists - const deploymentStatus = useWorkflowRegistry - .getState() - .readWorkflowDeploymentStatus(activeWorkflowId) - - const newState = { - blocks: deployedState.blocks, - edges: deployedState.edges, - loops: deployedState.loops || {}, - parallels: deployedState.parallels || {}, - isDeployed: true, - needsRedeployment: false, - // Keep existing deployment statuses and update for the active workflow if needed - deploymentStatuses: { - ...get().deploymentStatuses, - ...(deploymentStatus - ? { - [activeWorkflowId]: deploymentStatus, - } - : {}), - }, - } - - // Update the main workflow state - set(newState) - - // Initialize subblock store with values from deployed state - const subBlockStore = useSubBlockStore.getState() - const values: Record<string, Record<string, any>> = {} - - // Extract subblock values from deployed blocks - Object.entries(deployedState.blocks).forEach(([blockId, block]) => { - values[blockId] = {} - Object.entries(block.subBlocks || {}).forEach(([subBlockId, subBlock]) => { - values[blockId][subBlockId] = subBlock.value - }) - }) - - // Update subblock store with deployed values - useSubBlockStore.setState({ - workflowValues: { - ...subBlockStore.workflowValues, - [activeWorkflowId]: values, - }, - }) - - get().updateLastSaved() - - // Call API to persist the revert to normalized tables - try { - const response = await fetch( - `/api/workflows/${activeWorkflowId}/deployments/active/revert`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - } - ) - - if (!response.ok) { - const errorData = await response.json() - logger.error('Failed to persist revert to deployed state:', errorData.error) - // Don't throw error to avoid breaking the UI, but log it - } else { - logger.info('Successfully persisted revert to deployed state') - } - } catch (error) { - logger.error('Error calling revert to deployed API:', error) - // Don't throw error to avoid breaking the UI - } - }, - - toggleBlockAdvancedMode: (id: string) => { - const block = get().blocks[id] - if (!block) return - - const newState = { - blocks: { - ...get().blocks, - [id]: { - ...block, - advancedMode: !block.advancedMode, - }, - }, - edges: [...get().edges], - loops: { ...get().loops }, - } - - set(newState) - - get().triggerUpdate() - // Note: Socket.IO handles real-time sync automatically - }, - - toggleBlockTriggerMode: (id: string) => { - const block = get().blocks[id] - if (!block) return - - const blockConfig = getBlock(block.type) - if (!blockConfig) return - - const newTriggerMode = !block.triggerMode - const runtimeState = resolveBlockRuntimeState({ - blockType: block.type, - blockConfig, - subBlocks: block.subBlocks, - triggerMode: newTriggerMode, - }) - - // When switching TO trigger mode, remove all incoming connections - let filteredEdges = [...get().edges] - if (newTriggerMode) { - // Remove edges where this block is the target - filteredEdges = filteredEdges.filter((edge) => edge.target !== id) - logger.info( - `Removed ${get().edges.length - filteredEdges.length} incoming connections for trigger mode`, - { - blockId: id, - blockType: block.type, - } - ) - } - - const newState = { - blocks: { - ...get().blocks, - [id]: { - ...block, - subBlocks: runtimeState.subBlocks, - outputs: runtimeState.outputs, - triggerMode: newTriggerMode, - }, - }, - edges: filteredEdges, - loops: { ...get().loops }, - parallels: { ...get().parallels }, - } - - set(newState) - get().updateLastSaved() - - // Handle webhook enable/disable when toggling trigger mode - const handleWebhookToggle = async () => { - try { - const activeWorkflowId = useWorkflowRegistry.getState().getActiveWorkflowId(channelId) - if (!activeWorkflowId) return - - // Check if there's a webhook for this block - const response = await fetch(`/api/webhooks?workflowId=${activeWorkflowId}&blockId=${id}`) - if (response.ok) { - const data = await response.json() - if (data.webhooks && data.webhooks.length > 0) { - const webhook = data.webhooks[0].webhook - - // Update webhook's isActive status based on trigger mode - const updateResponse = await fetch(`/api/webhooks/${webhook.id}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - isActive: newTriggerMode, - }), - }) - - if (!updateResponse.ok) { - logger.error('Failed to update webhook status') - } - } - } - } catch (error) { - logger.error('Error toggling webhook status:', error) - } - } - - // Handle webhook toggle asynchronously - handleWebhookToggle() - - // Note: Socket.IO handles real-time sync automatically - }, - - // Parallel block methods implementation - updateParallelCount: (parallelId: string, count: number) => { - const block = get().blocks[parallelId] - if (!block || block.type !== 'parallel') return - - const newBlocks = { - ...get().blocks, - [parallelId]: { - ...block, - data: { - ...block.data, - count: Math.max(1, Math.min(20, count)), // Clamp between 1-20 - }, - }, - } - - const newState = { - blocks: newBlocks, - edges: [...get().edges], - loops: { ...get().loops }, - parallels: generateParallelBlocks(newBlocks), // Regenerate parallels - } - - set(newState) - get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically - }, - - updateParallelCollection: (parallelId: string, collection: string) => { - const block = get().blocks[parallelId] - if (!block || block.type !== 'parallel') return - - const newBlocks = { - ...get().blocks, - [parallelId]: { - ...block, - data: { - ...block.data, - collection, - }, - }, - } - - const newState = { - blocks: newBlocks, - edges: [...get().edges], - loops: { ...get().loops }, - parallels: generateParallelBlocks(newBlocks), // Regenerate parallels - } - - set(newState) - get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically - }, - - updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => { - const block = get().blocks[parallelId] - if (!block || block.type !== 'parallel') return - - const newBlocks = { - ...get().blocks, - [parallelId]: { - ...block, - data: { - ...block.data, - parallelType, - }, - }, - } - - const newState = { - blocks: newBlocks, - edges: [...get().edges], - loops: { ...get().loops }, - parallels: generateParallelBlocks(newBlocks), // Regenerate parallels - } - - set(newState) - get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically - }, - - // Function to convert UI parallel blocks to execution format - generateParallelBlocks: () => { - return generateParallelBlocks(get().blocks) - }, - - setDragStartPosition: (position) => { - set({ dragStartPosition: position }) - }, - - getDragStartPosition: () => { - return get().dragStartPosition || null - }, - }) - -const createWorkflowStore = (channelId: string): StoreApi<WorkflowStore> => - createStore<WorkflowStore>()( - devtools(createWorkflowStoreState(channelId), { - name: `workflow-store-${channelId || 'global'}`, - }) - ) - -const resolveChannelKey = (channelId?: string) => - channelId && channelId.trim().length > 0 ? channelId : DEFAULT_WORKFLOW_CHANNEL_ID - -const workflowStoreMap = new Map<string, StoreApi<WorkflowStore>>() -const workflowStoreByWorkflowId = new Map<string, StoreApi<WorkflowStore>>() -const channelWorkflowBindings = new Map<string, string>() - -const getStoreForWorkflow = (channelKey: string, workflowId: string) => { - const bindingKey = `${channelKey}::${workflowId}` - if (!workflowStoreByWorkflowId.has(bindingKey)) { - workflowStoreByWorkflowId.set(bindingKey, createWorkflowStore(channelKey)) - } - return workflowStoreByWorkflowId.get(bindingKey)! -} - -export const readWorkflowStoreForChannel = (channelId?: string, workflowId?: string) => { - const key = resolveChannelKey(channelId) - - if (workflowId) { - channelWorkflowBindings.set(key, workflowId) - return getStoreForWorkflow(key, workflowId) - } - - const boundWorkflow = channelWorkflowBindings.get(key) - if (boundWorkflow) { - return getStoreForWorkflow(key, boundWorkflow) - } - - if (!workflowStoreMap.has(key)) { - workflowStoreMap.set(key, createWorkflowStore(key)) - } - return workflowStoreMap.get(key)! -} - -export const readWorkflowStoreState = (channelId?: string) => - readWorkflowStoreForChannel(channelId).getState() - -export const setWorkflowStoreState = ( - partial: Parameters<StoreApi<WorkflowStore>['setState']>[0], - channelId?: string, - replace?: Parameters<StoreApi<WorkflowStore>['setState']>[1], - workflowId?: string -) => readWorkflowStoreForChannel(channelId, workflowId).setState(partial as any, replace) - -export const subscribeToWorkflowStore = ( - listener: Parameters<StoreApi<WorkflowStore>['subscribe']>[0], - channelId?: string -) => readWorkflowStoreForChannel(channelId).subscribe(listener) - -type SetStateParam = Parameters<StoreApi<WorkflowStore>['setState']>[0] -type SetStateReplace = Parameters<StoreApi<WorkflowStore>['setState']>[1] -type SubscribeParam = Parameters<StoreApi<WorkflowStore>['subscribe']>[0] - -type WorkflowStoreAccessor = { - getState: (channelId?: string) => WorkflowStore - setState: (partial: SetStateParam, replace?: SetStateReplace) => void - setStateForChannel: ( - partial: SetStateParam, - channelId?: string, - replace?: SetStateReplace, - workflowId?: string - ) => void - subscribe: (listener: SubscribeParam, channelId?: string) => () => void -} - -export const useWorkflowStore: WorkflowStoreAccessor = { - getState: (channelId) => readWorkflowStoreState(channelId), - setState: (partial, replace) => setWorkflowStoreState(partial, undefined, replace), - setStateForChannel: (partial, channelId, replace, workflowId) => - setWorkflowStoreState(partial, channelId, replace, workflowId), - subscribe: (listener, channelId) => subscribeToWorkflowStore(listener, channelId), -} diff --git a/apps/tradinggoose/tools/function/execute.ts b/apps/tradinggoose/tools/function/execute.ts index c5582a659..48693c81f 100644 --- a/apps/tradinggoose/tools/function/execute.ts +++ b/apps/tradinggoose/tools/function/execute.ts @@ -83,6 +83,9 @@ export const functionExecuteTool: ToolConfig<CodeExecutionInput, CodeExecutionOu blockNameMapping: params.blockNameMapping || {}, blockOutputSchemas: params.blockOutputSchemas || {}, userId: params._context?.userId, + ...(typeof params._context?.isDeployedContext === 'boolean' + ? { isDeployedContext: params._context.isDeployedContext } + : {}), ...(workflowId ? { workflowId } : workspaceId ? { workspaceId } : {}), isCustomTool: params.isCustomTool || false, } diff --git a/apps/tradinggoose/tools/function/types.ts b/apps/tradinggoose/tools/function/types.ts index 93ab1dfe2..e0704a529 100644 --- a/apps/tradinggoose/tools/function/types.ts +++ b/apps/tradinggoose/tools/function/types.ts @@ -15,6 +15,7 @@ export interface CodeExecutionInput { userId?: string executionId?: string submissionSource?: string + isDeployedContext?: boolean } isCustomTool?: boolean } diff --git a/apps/tradinggoose/tools/index.test.ts b/apps/tradinggoose/tools/index.test.ts index 820fe7113..0d66d550e 100644 --- a/apps/tradinggoose/tools/index.test.ts +++ b/apps/tradinggoose/tools/index.test.ts @@ -32,7 +32,6 @@ const dbMocks = vi.hoisted(() => { return { from, limit, select, setRows, where } }) -const listSkillsMock = vi.hoisted(() => vi.fn()) vi.mock('@tradinggoose/db', () => ({ db: { @@ -40,10 +39,6 @@ vi.mock('@tradinggoose/db', () => ({ }, })) -vi.mock('@/lib/skills/operations', () => ({ - listSkills: listSkillsMock, -})) - vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: vi.fn().mockResolvedValue('mock-internal-token'), })) @@ -185,7 +180,6 @@ describe('executeTool Function', () => { Promise.resolve([]).then(resolve, reject), })) dbMocks.limit.mockImplementation(() => Promise.resolve([])) - listSkillsMock.mockResolvedValue([]) // Mock fetch global.fetch = Object.assign( @@ -512,7 +506,7 @@ describe('executeTool Function', () => { description: 'Research the market before acting', }, ]) - listSkillsMock.mockResolvedValueOnce([ + dbMocks.limit.mockResolvedValueOnce([ { id: 'skill-1', name: 'market-research', @@ -558,6 +552,30 @@ describe('executeTool Function', () => { expect(global.fetch).not.toHaveBeenCalled() }) + it('should return a scoped error when selected skill content is missing', async () => { + const skillLoaderTool = buildLoadSkillTool('tradinggoose_internal_load_skill', [ + { + id: 'deleted-skill', + name: 'deleted', + description: 'Deleted skill', + }, + ]) + + const result = await executeTool( + skillLoaderTool.id, + { + ...skillLoaderTool.params, + skill_id: 'deleted-skill', + }, + false, + createMockExecutionContext() + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('was not found') + expect(global.fetch).not.toHaveBeenCalled() + }) + it('should handle non-existent tool', async () => { // Create the mock with a matching implementation vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/apps/tradinggoose/tools/index.ts b/apps/tradinggoose/tools/index.ts index 577a57088..4a31b5fe2 100644 --- a/apps/tradinggoose/tools/index.ts +++ b/apps/tradinggoose/tools/index.ts @@ -1,5 +1,9 @@ import { generateInternalToken } from '@/lib/auth/internal' -import { isCustomToolRuntimeId } from '@/lib/custom-tools/schema' +import { + getCustomToolEntityIdFromRuntimeId, + isCustomToolRuntimeId, + parseCustomToolSchemaText, +} from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { parseMcpToolId } from '@/lib/mcp/utils' import { validateExternalUrl } from '@/lib/security/input-validation' @@ -12,9 +16,9 @@ import type { ErrorInfo } from '@/tools/error-extractors' import { extractErrorMessage } from '@/tools/error-extractors' import type { ToolConfig, ToolResponse } from '@/tools/types' import { + createToolConfig, formatRequestParams, getTool, - getToolAsync, validateRequiredParametersAfterMerge, } from '@/tools/utils' @@ -43,6 +47,7 @@ function resolveExecutionScope( workflowLogId?: string toolExecutionId?: string submissionSource?: string + isDeployedContext?: boolean } { const context = params._context || {} @@ -54,6 +59,7 @@ function resolveExecutionScope( workflowLogId: executionContext?.workflowLogId ?? context.workflowLogId, toolExecutionId: context.toolExecutionId, submissionSource: executionContext?.submissionSource ?? context.submissionSource, + isDeployedContext: executionContext?.isDeployedContext ?? context.isDeployedContext, } } @@ -62,6 +68,72 @@ type ToolExecutionOptions = { signal?: AbortSignal } +async function resolveWorkflowWorkspaceId(workflowId: string): Promise<string | null> { + const [{ db }, { workflow }, { eq }] = await Promise.all([ + import('@tradinggoose/db'), + import('@tradinggoose/db/schema'), + import('drizzle-orm'), + ]) + const [row] = await db + .select({ workspaceId: workflow.workspaceId }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + return row?.workspaceId ?? null +} + +async function getServerCustomTool( + customToolId: string, + workflowId: string | undefined, + workspaceId: string | undefined, + isDeployedContext: boolean +): Promise<ToolConfig> { + const identifier = getCustomToolEntityIdFromRuntimeId(customToolId) + const scopedWorkspaceId = + workspaceId ?? (workflowId ? await resolveWorkflowWorkspaceId(workflowId) : null) + if (!scopedWorkspaceId) { + throw new Error(`Workspace context is required for custom tool ${identifier}`) + } + + const { readSavedEntityFieldsForExecution } = await import( + '@/lib/yjs/server/bootstrap-review-target' + ) + const fields = await readSavedEntityFieldsForExecution( + 'custom_tool', + identifier, + scopedWorkspaceId, + isDeployedContext + ) + + return createToolConfig( + { + title: String(fields.title ?? ''), + schema: parseCustomToolSchemaText(fields.schemaText), + code: String(fields.codeText ?? ''), + }, + customToolId, + false, + workflowId + ) +} + +export async function getToolAsync( + toolId: string, + workflowId?: string, + workspaceId?: string, + isDeployedContext = true +): Promise<ToolConfig | undefined> { + const builtInTool = getTool(toolId) + if (builtInTool) return builtInTool + + if (isCustomToolRuntimeId(toolId)) { + if (typeof window !== 'undefined') return getTool(toolId) + return getServerCustomTool(toolId, workflowId, workspaceId, isDeployedContext) + } + + return undefined +} + function generateScopedInternalToken(scope: ExecutionScope) { const workflowExecution = !scope.userId && scope.workflowId && scope.toolExecutionId @@ -274,14 +346,11 @@ export async function executeTool( } } - const content = await resolveSkillContent(skillId, scope.workspaceId) - if (!content) { - return { - success: false, - output: { error: `Skill "${skillId}" not found` }, - error: `Skill "${skillId}" not found`, - } - } + const content = await resolveSkillContent( + skillId, + scope.workspaceId, + scope.isDeployedContext !== false + ) return { success: true, @@ -291,10 +360,12 @@ export async function executeTool( // If it's a custom tool, use the async version with workflowId if (isCustomToolRuntimeId(toolId)) { - tool = await getToolAsync(toolId, scope.workflowId, scope.workspaceId, scope.userId) - if (!tool) { - logger.error(`[${requestId}] Custom tool not found: ${toolId}`) - } + tool = await getToolAsync( + toolId, + scope.workflowId, + scope.workspaceId, + scope.isDeployedContext !== false + ) } else if (isMcpTool) { return await executeMcpTool( toolId, @@ -325,6 +396,7 @@ export async function executeTool( workflowLogId: scope.workflowLogId, toolExecutionId: scope.toolExecutionId, submissionSource: scope.submissionSource, + isDeployedContext: scope.isDeployedContext, } if ( mergedContext.workflowId || @@ -332,7 +404,8 @@ export async function executeTool( mergedContext.executionId || mergedContext.workflowLogId || mergedContext.toolExecutionId || - mergedContext.submissionSource + mergedContext.submissionSource || + typeof mergedContext.isDeployedContext === 'boolean' ) { ;(contextParams as any)._context = mergedContext } @@ -1009,6 +1082,9 @@ async function executeMcpTool( arguments: toolArguments, workflowId, // Pass workflow context for user resolution workspaceId, // Pass workspace context for scoping + ...(typeof scope.isDeployedContext === 'boolean' + ? { isDeployedContext: scope.isDeployedContext } + : {}), } logger.info(`[${actualRequestId}] Making MCP tool request to ${toolName} on ${serverId}`, { diff --git a/apps/tradinggoose/tools/slack/message_reader.ts b/apps/tradinggoose/tools/slack/message_reader.ts index 490366ebe..59e5d0246 100644 --- a/apps/tradinggoose/tools/slack/message_reader.ts +++ b/apps/tradinggoose/tools/slack/message_reader.ts @@ -60,7 +60,7 @@ export const slackMessageReaderTool: ToolConfig< url: (params: SlackMessageReaderParams) => { const url = new URL('https://slack.com/api/conversations.history') url.searchParams.append('channel', params.channel) - // Cap limit at 15 due to Slack API restrictions for non-Marketplace apps + // Cap limit at 15 due to Slack API tier restrictions. url.searchParams.append('limit', String(Math.min(params.limit || 10, 15))) if (params.oldest) { diff --git a/apps/tradinggoose/tools/utils.test.ts b/apps/tradinggoose/tools/utils.test.ts index ba94a6726..8ee17a358 100644 --- a/apps/tradinggoose/tools/utils.test.ts +++ b/apps/tradinggoose/tools/utils.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { generateInternalToken } from '@/lib/auth/internal' +import { getToolAsync } from '@/tools' import type { ToolConfig } from '@/tools/types' import { createCustomToolRequestBody, @@ -7,11 +7,12 @@ import { executeRequest, formatRequestParams, getClientEnvVars, - getToolAsync, transformTable, validateRequiredParametersAfterMerge, } from '@/tools/utils' +const readSavedEntityFieldsForExecutionMock = vi.hoisted(() => vi.fn()) + vi.mock('@/lib/logs/console/logger', () => ({ createLogger: vi.fn().mockReturnValue({ debug: vi.fn(), @@ -21,8 +22,8 @@ vi.mock('@/lib/logs/console/logger', () => ({ }), })) -vi.mock('@/lib/auth/internal', () => ({ - generateInternalToken: vi.fn().mockResolvedValue('mock-internal-token'), +vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ + readSavedEntityFieldsForExecution: readSavedEntityFieldsForExecutionMock, })) vi.mock('@/stores/settings/environment/store', () => { @@ -43,6 +44,7 @@ vi.mock('@/stores/settings/environment/store', () => { const originalWindow = global.window beforeEach(() => { global.window = {} as any + readSavedEntityFieldsForExecutionMock.mockRejectedValue(new Error('not found')) }) afterEach(() => { @@ -758,22 +760,35 @@ describe('createCustomToolRequestBody', () => { expect(result).not.toHaveProperty('workspaceId') }) - it('fails closed before fetching custom tools when internal auth generation fails', async () => { + it('does not use the custom-tools API for server-side custom tool lookup', async () => { const serverWindow = global.window - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue(new Response(JSON.stringify({ data: [] }), { status: 200 }) as any) - vi.mocked(generateInternalToken).mockRejectedValueOnce(new Error('token boom')) + const fetchSpy = vi.spyOn(globalThis, 'fetch') + readSavedEntityFieldsForExecutionMock.mockResolvedValueOnce({ + title: 'Custom Weather Tool', + codeText: 'return params', + schemaText: JSON.stringify({ + type: 'function', + function: { + description: 'Get weather information', + parameters: { type: 'object', properties: {} }, + }, + }), + }) try { ;(global as any).window = undefined await expect( - getToolAsync('custom_custom-tool-123', undefined, 'workspace-456', 'user-123') - ).resolves.toBeUndefined() + getToolAsync('custom_custom-tool-123', undefined, 'workspace-456') + ).resolves.toBeDefined() - expect(generateInternalToken).toHaveBeenCalledWith('user-123') expect(fetchSpy).not.toHaveBeenCalled() + expect(readSavedEntityFieldsForExecutionMock).toHaveBeenCalledWith( + 'custom_tool', + 'custom-tool-123', + 'workspace-456', + true + ) } finally { global.window = serverWindow fetchSpy.mockRestore() @@ -782,75 +797,50 @@ 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 - ) + readSavedEntityFieldsForExecutionMock.mockResolvedValueOnce({ + title: 'Custom Weather Tool', + codeText: 'return params', + schemaText: JSON.stringify({ + type: 'function', + function: { + description: 'Get weather information', + parameters: { type: 'object', properties: {} }, + }, + }), + }) try { ;(global as any).window = undefined await expect( - getToolAsync('custom_custom-tool-123', 'workflow-123', 'workspace-456', 'user-123') + getToolAsync('custom_custom-tool-123', 'workflow-123', 'workspace-456') ).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) + expect(readSavedEntityFieldsForExecutionMock).toHaveBeenCalledWith( + 'custom_tool', + 'custom-tool-123', + 'workspace-456', + true + ) } finally { global.window = serverWindow - fetchSpy.mockRestore() } }) - it('does not resolve server-side custom tools by title', async () => { + it('surfaces execution-read failures instead of masking them as undefined', 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 + readSavedEntityFieldsForExecutionMock.mockRejectedValueOnce( + new Error('Saved custom_tool Custom Weather Tool was not found') ) try { ;(global as any).window = undefined await expect( - getToolAsync('custom_Custom Weather Tool', undefined, 'workspace-456', 'user-123') - ).resolves.toBeUndefined() + getToolAsync('custom_Custom Weather Tool', undefined, 'workspace-456') + ).rejects.toThrow('was not found') } finally { global.window = serverWindow - fetchSpy.mockRestore() } }) }) diff --git a/apps/tradinggoose/tools/utils.ts b/apps/tradinggoose/tools/utils.ts index 2b99c709f..39d9f5a76 100644 --- a/apps/tradinggoose/tools/utils.ts +++ b/apps/tradinggoose/tools/utils.ts @@ -3,7 +3,6 @@ import { isCustomToolRuntimeId, } from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' -import { getBaseUrl } from '@/lib/urls/utils' import { useCustomToolsStore } from '@/stores/custom-tools/store' import { useEnvironmentStore } from '@/stores/settings/environment/store' import { tools } from '@/tools/registry' @@ -301,6 +300,9 @@ export function createCustomToolRequestBody( ...(typeof context.submissionSource === 'string' && context.submissionSource ? { submissionSource: context.submissionSource } : {}), + ...(typeof context.isDeployedContext === 'boolean' + ? { isDeployedContext: context.isDeployedContext } + : {}), isCustomTool: true, // Flag to indicate this is a custom tool execution } } @@ -329,27 +331,13 @@ export function getTool(toolId: string): ToolConfig | undefined { return undefined } -// Get a tool by its ID asynchronously (supports server-side) -export async function getToolAsync( - toolId: string, - workflowId?: string, - workspaceId?: string, - userId?: string -): Promise<ToolConfig | undefined> { - // Check for built-in tools - const builtInTool = tools[toolId] - if (builtInTool) return builtInTool - - // Check if it's a custom tool - if (isCustomToolRuntimeId(toolId)) { - return getCustomTool(toolId, workflowId, workspaceId, userId) - } - - return undefined -} - // Helper function to create a tool config from a custom tool -function createToolConfig(customTool: any, customToolId: string): ToolConfig { +export function createToolConfig( + customTool: any, + customToolId: string, + isClient = true, + workflowId?: string +): ToolConfig { // Create a parameter schema from the custom tool schema const params = createParamSchema(customTool) @@ -366,7 +354,7 @@ function createToolConfig(customTool: any, customToolId: string): ToolConfig { url: '/api/function/execute', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), - body: createCustomToolRequestBody(customTool, true), + body: createCustomToolRequestBody(customTool, isClient, workflowId), }, // Standard response handling for custom tools @@ -385,95 +373,3 @@ function createToolConfig(customTool: any, customToolId: string): ToolConfig { }, } } - -// Create a tool config from a custom tool definition -async function getCustomTool( - customToolId: string, - workflowId?: string, - workspaceId?: string, - userId?: string -): Promise<ToolConfig | undefined> { - const identifier = getCustomToolEntityIdFromRuntimeId(customToolId) - - try { - const baseUrl = getBaseUrl() - const url = new URL('/api/tools/custom', baseUrl) - - if (workspaceId) { - url.searchParams.append('workspaceId', workspaceId) - } else if (workflowId) { - url.searchParams.append('workflowId', workflowId) - } - - const headers: Record<string, string> = {} - if (typeof window === 'undefined') { - try { - const { generateInternalToken } = await import('@/lib/auth/internal') - const internalToken = await generateInternalToken(userId) - headers.Authorization = `Bearer ${internalToken}` - } catch (error) { - logger.warn('Failed to generate internal token for custom tools fetch', { error }) - throw error - } - } - - const response = await fetch(url.toString(), { headers }) - - if (!response.ok) { - logger.error(`Failed to fetch custom tools: ${response.statusText}`) - return undefined - } - - const result = await response.json() - - if (!result.data || !Array.isArray(result.data)) { - logger.error(`Invalid response when fetching custom tools: ${JSON.stringify(result)}`) - return undefined - } - - const customTool = result.data.find((tool: any) => tool.id === identifier) - - if (!customTool) { - logger.error(`Custom tool not found: ${identifier}`) - return undefined - } - - // Create a parameter schema - const params = createParamSchema(customTool) - - // Create a tool config for the custom tool - return { - id: customToolId, - name: customTool.title, - description: customTool.schema.function?.description || '', - version: '1.0.0', - params, - - // Request configuration - for custom tools we'll use the execute endpoint - request: { - url: '/api/function/execute', - method: 'POST', - headers: () => ({ 'Content-Type': 'application/json' }), - body: createCustomToolRequestBody(customTool, false, workflowId), - }, - - // Same response handling as client-side - transformResponse: async (response: Response) => { - const data = await response.json() - - if (!data.success) { - throw new Error(data.error || 'Custom tool execution failed') - } - - return { - success: true, - output: data.output.result || data.output, - error: undefined, - } - }, - } - } catch (error) { - logger.error(`Error fetching custom tool ${identifier} from API:`, error) - return undefined - } -} diff --git a/apps/tradinggoose/triggers/gmail/poller.ts b/apps/tradinggoose/triggers/gmail/poller.ts index 85f657086..aef69346f 100644 --- a/apps/tradinggoose/triggers/gmail/poller.ts +++ b/apps/tradinggoose/triggers/gmail/poller.ts @@ -1,6 +1,6 @@ import { GmailIcon } from '@/components/icons/icons' import { createLogger } from '@/lib/logs/console/logger' -import { readActiveSubBlockValue } from '@/lib/yjs/workflow-session-registry' +import { readSubBlockValue } from '@/lib/yjs/workflow-session-registry' import type { TriggerConfig } from '@/triggers/types' const logger = createLogger('GmailPollingTrigger') @@ -35,7 +35,9 @@ export const gmailPollingTrigger: TriggerConfig = { required: false, options: [], // Will be populated dynamically from user's Gmail labels fetchOptions: async (blockId: string, _subBlockId: string, context) => { - const credentialId = readActiveSubBlockValue(blockId, 'triggerCredentials') as string | null + const credentialId = readSubBlockValue(context.workflowId, blockId, 'triggerCredentials') as + | string + | null if (!credentialId) { // Return a sentinel to prevent infinite retry loops when credential is missing throw new Error('No Gmail credential selected') diff --git a/apps/tradinggoose/triggers/imap/poller.ts b/apps/tradinggoose/triggers/imap/poller.ts index c94f6b5ee..efef6e2a9 100644 --- a/apps/tradinggoose/triggers/imap/poller.ts +++ b/apps/tradinggoose/triggers/imap/poller.ts @@ -1,6 +1,6 @@ import { MailServerIcon } from '@/components/icons/icons' import { createLogger } from '@/lib/logs/console/logger' -import { readActiveSubBlockValue } from '@/lib/yjs/workflow-session-registry' +import { readSubBlockValue } from '@/lib/yjs/workflow-session-registry' import type { TriggerConfig } from '@/triggers/types' const logger = createLogger('ImapPollingTrigger') @@ -83,15 +83,17 @@ export const imapPollingTrigger: TriggerConfig = { 'Choose which mailbox/folder(s) to monitor for new emails. Leave empty to monitor INBOX.', required: false, options: [], - fetchOptions: async (blockId: string, _subBlockId: string) => { - const host = readActiveSubBlockValue(blockId, 'host') as string | null - const port = readActiveSubBlockValue(blockId, 'port') as string | null - const secure = readActiveSubBlockValue(blockId, 'secure') as boolean | null - const rejectUnauthorized = readActiveSubBlockValue(blockId, 'rejectUnauthorized') as - | boolean - | null - const username = readActiveSubBlockValue(blockId, 'username') as string | null - const password = readActiveSubBlockValue(blockId, 'password') as string | null + fetchOptions: async (blockId: string, _subBlockId: string, context) => { + const host = readSubBlockValue(context.workflowId, blockId, 'host') as string | null + const port = readSubBlockValue(context.workflowId, blockId, 'port') as string | null + const secure = readSubBlockValue(context.workflowId, blockId, 'secure') as boolean | null + const rejectUnauthorized = readSubBlockValue( + context.workflowId, + blockId, + 'rejectUnauthorized' + ) as boolean | null + const username = readSubBlockValue(context.workflowId, blockId, 'username') as string | null + const password = readSubBlockValue(context.workflowId, blockId, 'password') as string | null if (!host || !username || !password) { throw new Error('Please enter IMAP server, username, and password first') diff --git a/apps/tradinggoose/triggers/outlook/poller.ts b/apps/tradinggoose/triggers/outlook/poller.ts index dedaf1c1a..dbae0961f 100644 --- a/apps/tradinggoose/triggers/outlook/poller.ts +++ b/apps/tradinggoose/triggers/outlook/poller.ts @@ -1,6 +1,6 @@ import { OutlookIcon } from '@/components/icons/icons' import { createLogger } from '@/lib/logs/console/logger' -import { readActiveSubBlockValue } from '@/lib/yjs/workflow-session-registry' +import { readSubBlockValue } from '@/lib/yjs/workflow-session-registry' import type { TriggerConfig } from '@/triggers/types' const logger = createLogger('OutlookPollingTrigger') @@ -35,7 +35,9 @@ export const outlookPollingTrigger: TriggerConfig = { required: false, options: [], // Will be populated dynamically fetchOptions: async (blockId: string, _subBlockId: string, context) => { - const credentialId = readActiveSubBlockValue(blockId, 'triggerCredentials') as string | null + const credentialId = readSubBlockValue(context.workflowId, blockId, 'triggerCredentials') as + | string + | null if (!credentialId) { throw new Error('No Outlook credential selected') } diff --git a/apps/tradinggoose/widgets/events.ts b/apps/tradinggoose/widgets/events.ts index 7668ebce4..7ae0a568f 100644 --- a/apps/tradinggoose/widgets/events.ts +++ b/apps/tradinggoose/widgets/events.ts @@ -17,9 +17,9 @@ export const QUICK_ORDER_WIDGET_UPDATE_PARAMS_EVENT = 'quick-order-widgets:updat export const HEATMAP_WIDGET_UPDATE_PARAMS_EVENT = 'heatmap-widgets:update-params' export type WorkflowWidgetSelectEventDetail = { - workflowId: string + workflowId?: string | null panelId?: string - widgetKey?: string + widgetKey: string } export type DataChartWidgetUpdateEventDetail = { diff --git a/apps/tradinggoose/widgets/hooks/use-workflow-widget-state.ts b/apps/tradinggoose/widgets/hooks/use-workflow-widget-state.ts index 6f9367865..11860c297 100644 --- a/apps/tradinggoose/widgets/hooks/use-workflow-widget-state.ts +++ b/apps/tradinggoose/widgets/hooks/use-workflow-widget-state.ts @@ -1,45 +1,32 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' -import { shallow } from 'zustand/shallow' -import { - type PairColorContext, - usePairColorStore, -} from '@/stores/dashboard/pair-store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { WORKSPACE_BOOTSTRAP_CHANNEL } from '@/stores/workflows/registry/types' +import { useMemo } from 'react' +import { useEntityList } from '@/lib/yjs/use-entity-fields' +import { type PairColorContext, usePairColorStore } from '@/stores/dashboard/pair-store' import { resolveWidgetChannel } from '@/widgets/hooks/use-widget-channel' import type { PairColor } from '@/widgets/pair-colors' import type { WidgetComponentProps } from '@/widgets/types' +import { resolveEntityId, resolveEntityIdFromList } from '@/widgets/utils/entity-selection' type UseWorkflowWidgetStateOptions = Pick< WidgetComponentProps, - 'params' | 'pairColor' | 'panelId' | 'widget' | 'onWidgetParamsChange' + 'params' | 'pairColor' | 'panelId' | 'widget' > & { workspaceId?: string fallbackWidgetKey: string - loggerScope?: string - activateWorkflow?: boolean usePairWorkflowContext?: boolean } -export type WorkflowWidgetLoadError = - | 'unableToLoadWorkflows' - | 'authenticationRequiredToLoadWorkflows' - type UseWorkflowWidgetStateResult = { resolvedPairColor: PairColor channelId: string - requestedWorkflowId: string | null resolvedWorkflowId: string | null hasLoadedWorkflows: boolean - loadError: WorkflowWidgetLoadError | null + loadError: 'unableToLoadWorkflows' | null isLoading: boolean workflowIds: string[] - activeWorkflowIdForChannel: string | null } -const MAX_METADATA_LOAD_ATTEMPTS = 2 const EMPTY_PAIR_CONTEXT: Readonly<PairColorContext> = Object.freeze({}) export const useWorkflowWidgetState = ({ @@ -48,10 +35,7 @@ export const useWorkflowWidgetState = ({ widget, panelId, params, - onWidgetParamsChange, fallbackWidgetKey, - loggerScope = 'workflow widget', - activateWorkflow = true, usePairWorkflowContext = true, }: UseWorkflowWidgetStateOptions): UseWorkflowWidgetStateResult => { const { resolvedPairColor, channelId } = resolveWidgetChannel({ @@ -60,226 +44,59 @@ export const useWorkflowWidgetState = ({ panelId, fallbackWidgetKey, }) - // Metadata is workspace-scoped, not pair-scoped. Loading it through the shared bootstrap channel - // avoids pair-context resets discarding in-flight metadata requests before a pair has an active workflow. - const metadataChannelId = WORKSPACE_BOOTSTRAP_CHANNEL const shouldUsePairWorkflowContext = usePairWorkflowContext && resolvedPairColor !== 'gray' const pairContext = usePairColorStore((state) => shouldUsePairWorkflowContext ? state.contexts[resolvedPairColor] : EMPTY_PAIR_CONTEXT ) - const { workflows, loadWorkflows, setActiveWorkflow } = useWorkflowRegistry( - (state) => ({ - workflows: state.workflows, - loadWorkflows: state.loadWorkflows, - setActiveWorkflow: state.setActiveWorkflow, - }), - shallow - ) - - const workflowMap = workflows ?? {} - const [hasRequestedLoad, setHasRequestedLoad] = useState(false) - const [loadError, setLoadError] = useState<WorkflowWidgetLoadError | null>(null) - const [loadAttempts, setLoadAttempts] = useState(0) - - const requestedWorkflowId = useMemo(() => { - if ( - (resolvedPairColor !== 'gray' && shouldUsePairWorkflowContext) || - !params || - typeof params !== 'object' - ) { - return null - } - - return 'workflowId' in params && params.workflowId ? String(params.workflowId) : null - }, [resolvedPairColor, params, shouldUsePairWorkflowContext]) + const { + members, + isLoading: isListLoading, + error: listError, + } = useEntityList('workflow', workspaceId) + + const storedWorkflowId = resolveEntityId('workflowId', { + params: shouldUsePairWorkflowContext ? null : params, + pairContext: shouldUsePairWorkflowContext ? pairContext : null, + }) - const rawActiveWorkflowIdForChannel = useWorkflowRegistry((state) => - state.getActiveWorkflowId(channelId) + const workflowIds = useMemo( + () => + [...members] + .sort((left, right) => (right.createdAt ?? '').localeCompare(left.createdAt ?? '')) + .map((member) => member.entityId), + [members] ) - const metadataHydration = useWorkflowRegistry((state) => state.getHydration(metadataChannelId)) - const isChannelHydrating = useWorkflowRegistry((state) => state.isChannelHydrating(channelId)) - const isMetadataChannelHydrating = useWorkflowRegistry((state) => - state.isChannelHydrating(metadataChannelId) - ) - - const workspaceWorkflowMap = useMemo(() => { - if (!workspaceId) { - return {} - } - - return Object.fromEntries( - Object.entries(workflowMap).filter(([, workflow]) => workflow?.workspaceId === workspaceId) - ) - }, [workflowMap, workspaceId]) - - const workflowIds = useMemo(() => Object.keys(workspaceWorkflowMap), [workspaceWorkflowMap]) - - const workspaceHasWorkflows = workflowIds.length > 0 - - useEffect(() => { - setLoadError(null) - setHasRequestedLoad(false) - setLoadAttempts(0) - }, [workspaceId, metadataChannelId]) - - useEffect(() => { - if (!workspaceId) { - return - } - - if (workspaceHasWorkflows) { - return - } - - if ( - metadataHydration.phase === 'metadata-loading' || - metadataHydration.phase === 'state-loading' - ) { - return - } - - if ( - metadataHydration.phase !== 'idle' && - metadataHydration.phase !== 'error' && - metadataHydration.phase !== 'metadata-ready' - ) { - return - } - - if (loadAttempts >= MAX_METADATA_LOAD_ATTEMPTS) { - return - } - - let cancelled = false - setHasRequestedLoad(true) - setLoadAttempts((previous) => previous + 1) - loadWorkflows({ workspaceId, channelId: metadataChannelId }).catch((error) => { - if (cancelled) { - return - } - - console.error(`Failed to load workflows for ${loggerScope}`, error) - setLoadError( - error instanceof Error && - (error.message === 'Unauthorized' || error.message === 'Forbidden') - ? 'authenticationRequiredToLoadWorkflows' - : 'unableToLoadWorkflows' - ) - }) - - return () => { - cancelled = true - } - }, [ - workspaceId, - workspaceHasWorkflows, - metadataHydration.phase, - loadAttempts, - loadWorkflows, - loggerScope, - metadataChannelId, - ]) + const hasWorkflowMembers = workflowIds.length > 0 + const hasLoadedWorkflows = + !workspaceId || hasWorkflowMembers || Boolean(listError) || !isListLoading const resolvedWorkflowId = useMemo(() => { - if (workflowIds.length === 0) { - return null - } - - const pairWorkflowId = - shouldUsePairWorkflowContext && - pairContext.workflowId && - workspaceWorkflowMap[pairContext.workflowId] - ? pairContext.workflowId - : null - - if (pairWorkflowId) { - return pairWorkflowId - } - - if (shouldUsePairWorkflowContext) { - return null - } - - const channelWorkflowId = - rawActiveWorkflowIdForChannel && workspaceWorkflowMap[rawActiveWorkflowIdForChannel] - ? rawActiveWorkflowIdForChannel - : null + if (!workspaceId || (!hasWorkflowMembers && (listError || isListLoading))) return null - if (channelWorkflowId) { - return channelWorkflowId - } - - if (requestedWorkflowId && workspaceWorkflowMap[requestedWorkflowId]) { - return requestedWorkflowId - } - - return workflowIds[0] + return resolveEntityIdFromList({ + requestedEntityId: storedWorkflowId, + entityIds: workflowIds, + useDefaultEntity: !shouldUsePairWorkflowContext, + }) }, [ workflowIds, - pairContext.workflowId, - workspaceWorkflowMap, - rawActiveWorkflowIdForChannel, - requestedWorkflowId, + storedWorkflowId, + workspaceId, + hasWorkflowMembers, + listError, + isListLoading, shouldUsePairWorkflowContext, ]) - const activeWorkflowIdForChannel = activateWorkflow - ? rawActiveWorkflowIdForChannel - : resolvedWorkflowId - - useEffect(() => { - if (!activateWorkflow) { - return - } - - if (!resolvedWorkflowId || activeWorkflowIdForChannel === resolvedWorkflowId) { - return - } - - setActiveWorkflow({ workflowId: resolvedWorkflowId, channelId }).catch((error) => { - console.error(`Failed to activate workflow for ${loggerScope}`, error) - }) - }, [ - activateWorkflow, - resolvedWorkflowId, - activeWorkflowIdForChannel, - setActiveWorkflow, - channelId, - loggerScope, - ]) - - const hasLoadedWorkflows = useMemo(() => { - if (!workspaceId) { - return true - } - if (workspaceHasWorkflows || Boolean(loadError)) { - return true - } - return hasRequestedLoad && metadataHydration.phase !== 'metadata-loading' - }, [workspaceId, workspaceHasWorkflows, loadError, hasRequestedLoad, metadataHydration.phase]) - - useEffect(() => { - if (resolvedPairColor !== 'gray' || !resolvedWorkflowId || !onWidgetParamsChange) { - return - } - - if (requestedWorkflowId === resolvedWorkflowId) { - return - } - - const nextParams = { ...(params ?? {}), workflowId: resolvedWorkflowId } - onWidgetParamsChange(nextParams) - }, [resolvedPairColor, resolvedWorkflowId, requestedWorkflowId, onWidgetParamsChange, params]) + const loadError: 'unableToLoadWorkflows' | null = listError ? 'unableToLoadWorkflows' : null return { resolvedPairColor, channelId, - requestedWorkflowId, resolvedWorkflowId, hasLoadedWorkflows, loadError, - isLoading: isMetadataChannelHydrating || isChannelHydrating, + isLoading: isListLoading && !hasWorkflowMembers, workflowIds, - activeWorkflowIdForChannel: activeWorkflowIdForChannel ?? null, } } diff --git a/apps/tradinggoose/widgets/layout.ts b/apps/tradinggoose/widgets/layout.ts index 24abc2367..37d3d284d 100644 --- a/apps/tradinggoose/widgets/layout.ts +++ b/apps/tradinggoose/widgets/layout.ts @@ -176,7 +176,7 @@ export function createDefaultLayoutState(): LayoutNode { { id: createLayoutNodeId(), type: 'panel', - widget: { key: 'empty', pairColor: 'gray', params: { workflowId: 'default' } }, + widget: { key: 'empty', pairColor: 'gray', params: null }, }, { id: createLayoutNodeId(), diff --git a/apps/tradinggoose/widgets/utils/custom-tool-selection.ts b/apps/tradinggoose/widgets/utils/custom-tool-selection.ts index d38794274..0e84215a6 100644 --- a/apps/tradinggoose/widgets/utils/custom-tool-selection.ts +++ b/apps/tradinggoose/widgets/utils/custom-tool-selection.ts @@ -1,19 +1,16 @@ import { CUSTOM_TOOL_WIDGET_SELECT_EVENT } from '@/widgets/events' import type { PairColor } from '@/widgets/pair-colors' import { - createSelectionPersistenceHook, createEmitSelectionChange, + createSelectionPersistenceHook, type UseSelectionPersistenceOptions, } from '@/widgets/utils/selection-persistence-factory' -const DEFAULT_SCOPE_KEY = 'editor_custom_tool' - // Hook const useCustomToolSelectionPersistenceGeneric = createSelectionPersistenceHook({ eventName: CUSTOM_TOOL_WIDGET_SELECT_EVENT, detailIdKey: 'customToolId', - defaultScopeKey: DEFAULT_SCOPE_KEY, }) interface UseCustomToolSelectionPersistenceOptions { @@ -22,7 +19,7 @@ interface UseCustomToolSelectionPersistenceOptions { params?: Record<string, unknown> | null pairColor?: PairColor onCustomToolSelect?: (customToolId: string | null) => void - scopeKey?: string + scopeKey: string } export function useCustomToolSelectionPersistence({ diff --git a/apps/tradinggoose/widgets/utils/entity-selection.test.ts b/apps/tradinggoose/widgets/utils/entity-selection.test.ts index c5b33da4e..832a91201 100644 --- a/apps/tradinggoose/widgets/utils/entity-selection.test.ts +++ b/apps/tradinggoose/widgets/utils/entity-selection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { readEntitySelectionState } from './entity-selection' +import { readEntitySelectionState, resolveEntityIdFromList } from './entity-selection' describe('entity id resolution', () => { it('uses linked pair entity id over stale widget params', () => { @@ -17,4 +17,20 @@ describe('entity id resolution', () => { selectedEntityId: 'skill-linked', }) }) + + it('does not fall back when a requested entity id is missing', () => { + const entityIds = ['a', 'b'] + expect( + resolveEntityIdFromList({ requestedEntityId: 'deleted', fallbackEntityId: 'a', entityIds }) + ).toBeNull() + expect(resolveEntityIdFromList({ requestedEntityId: 'deleted', entityIds })).toBeNull() + }) + + it('defaults only when no entity id was requested', () => { + const entityIds = ['a', 'b'] + expect(resolveEntityIdFromList({ fallbackEntityId: 'b', entityIds })).toBe('b') + expect(resolveEntityIdFromList({ entityIds })).toBe('a') + expect(resolveEntityIdFromList({ requestedEntityId: '', entityIds })).toBe('a') + expect(resolveEntityIdFromList({ entityIds, useDefaultEntity: false })).toBeNull() + }) }) diff --git a/apps/tradinggoose/widgets/utils/entity-selection.ts b/apps/tradinggoose/widgets/utils/entity-selection.ts index 3211ef096..67c0eed4e 100644 --- a/apps/tradinggoose/widgets/utils/entity-selection.ts +++ b/apps/tradinggoose/widgets/utils/entity-selection.ts @@ -1,7 +1,15 @@ +import { useEffect } from 'react' +import type { PairColor } from '@/widgets/pair-colors' + export interface EntitySelectionState { selectedEntityId: string | null } +const normalizeEntityId = (value: string | null | undefined): string | null => { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + export function resolveEntityId( key: string, { @@ -14,11 +22,37 @@ export function resolveEntityId( ): string | null { if (pairContext) { const value = Object.hasOwn(pairContext, key) ? pairContext[key] : null - return typeof value === 'string' && value.trim().length > 0 ? value : null + return typeof value === 'string' ? normalizeEntityId(value) : null } const value = params?.[key] - return typeof value === 'string' && value.trim().length > 0 ? value : null + return typeof value === 'string' ? normalizeEntityId(value) : null +} + +/** + * Shared dashboard entity selection policy: + * - a non-empty requested id is authoritative and resolves to null if absent; + * - an empty requested id may use a fallback/default when the caller allows it; + * - linked color-pair widgets pass useDefaultEntity=false, so they never auto-claim. + */ +export function resolveEntityIdFromList({ + requestedEntityId, + fallbackEntityId, + entityIds, + useDefaultEntity = true, +}: { + requestedEntityId?: string | null + fallbackEntityId?: string | null + entityIds: readonly string[] + useDefaultEntity?: boolean +}): string | null { + const requested = normalizeEntityId(requestedEntityId) + if (requested) return entityIds.includes(requested) ? requested : null + + const fallback = normalizeEntityId(fallbackEntityId) + if (fallback && entityIds.includes(fallback)) return fallback + + return useDefaultEntity ? (entityIds[0] ?? null) : null } export function readEntitySelectionState(options: { @@ -33,3 +67,33 @@ export function readEntitySelectionState(options: { }), } } + +export function usePersistResolvedEntityId({ + entityId, + entityIdKey, + onWidgetParamsChange, + pairColor = 'gray', + params, +}: { + entityId?: string | null + entityIdKey: string + onWidgetParamsChange?: (params: Record<string, unknown> | null) => void + pairColor?: PairColor + params?: Record<string, unknown> | null +}) { + useEffect(() => { + // Gray widgets own their local params, so an auto-claimed default becomes stable. + // Missing explicit ids resolve to null and are intentionally not rewritten here. + if (pairColor !== 'gray') return + if (!entityId) return + if (!onWidgetParamsChange) return + + const currentEntityId = resolveEntityId(entityIdKey, { params }) + if (currentEntityId === entityId) return + + onWidgetParamsChange({ + ...(params ?? {}), + [entityIdKey]: entityId, + }) + }, [entityId, entityIdKey, onWidgetParamsChange, pairColor, params]) +} diff --git a/apps/tradinggoose/widgets/utils/indicator-selection.ts b/apps/tradinggoose/widgets/utils/indicator-selection.ts index 57473bd73..fa140bc5e 100644 --- a/apps/tradinggoose/widgets/utils/indicator-selection.ts +++ b/apps/tradinggoose/widgets/utils/indicator-selection.ts @@ -1,19 +1,16 @@ import { INDICATOR_WIDGET_SELECT_EVENT } from '@/widgets/events' import type { PairColor } from '@/widgets/pair-colors' import { - createSelectionPersistenceHook, createEmitSelectionChange, + createSelectionPersistenceHook, type UseSelectionPersistenceOptions, } from '@/widgets/utils/selection-persistence-factory' -const DEFAULT_SCOPE_KEY = 'editor_indicator' - // Hook const useIndicatorSelectionPersistenceGeneric = createSelectionPersistenceHook({ eventName: INDICATOR_WIDGET_SELECT_EVENT, detailIdKey: 'indicatorId', - defaultScopeKey: DEFAULT_SCOPE_KEY, }) interface UseIndicatorSelectionPersistenceOptions { @@ -22,7 +19,7 @@ interface UseIndicatorSelectionPersistenceOptions { params?: Record<string, unknown> | null pairColor?: PairColor onIndicatorSelect?: (indicatorId: string | null) => void - scopeKey?: string + scopeKey: string } export function useIndicatorSelectionPersistence({ diff --git a/apps/tradinggoose/widgets/utils/selection-persistence-factory.ts b/apps/tradinggoose/widgets/utils/selection-persistence-factory.ts index ed293b546..4f60f8546 100644 --- a/apps/tradinggoose/widgets/utils/selection-persistence-factory.ts +++ b/apps/tradinggoose/widgets/utils/selection-persistence-factory.ts @@ -33,9 +33,8 @@ export interface UseSelectionPersistenceOptions { * selected entity ID into widget params (gray pair) or calls a callback * (non-gray pair). * - * All four entity-selection files (`custom-tool-selection`, `skill-selection`, - * `indicator-selection`, `mcp-selection`) share identical logic; only the - * event name, detail ID key, and params ID key differ. + * Entity-selection files share identical logic; only the event name, detail ID + * key, and params ID key differ. */ export function createSelectionPersistenceHook(config: SelectionPersistenceConfig) { const { eventName, detailIdKey, defaultScopeKey } = config @@ -112,7 +111,11 @@ export interface EmitSelectionChangeOptions { * Creates an emit function that dispatches a `CustomEvent` with the selected entity ID. */ export function createEmitSelectionChange(config: EmitSelectionChangeConfig) { - return function emitSelectionChange({ entityId, panelId, widgetKey }: EmitSelectionChangeOptions) { + return function emitSelectionChange({ + entityId, + panelId, + widgetKey, + }: EmitSelectionChangeOptions) { if (!widgetKey) return window.dispatchEvent( diff --git a/apps/tradinggoose/widgets/utils/skill-selection.ts b/apps/tradinggoose/widgets/utils/skill-selection.ts index 52e45688a..858b5110b 100644 --- a/apps/tradinggoose/widgets/utils/skill-selection.ts +++ b/apps/tradinggoose/widgets/utils/skill-selection.ts @@ -1,19 +1,16 @@ import { SKILL_WIDGET_SELECT_EVENT } from '@/widgets/events' import type { PairColor } from '@/widgets/pair-colors' import { - createSelectionPersistenceHook, createEmitSelectionChange, + createSelectionPersistenceHook, type UseSelectionPersistenceOptions, } from '@/widgets/utils/selection-persistence-factory' -const DEFAULT_SCOPE_KEY = 'editor_skill' - // Hook const useSkillSelectionPersistenceGeneric = createSelectionPersistenceHook({ eventName: SKILL_WIDGET_SELECT_EVENT, detailIdKey: 'skillId', - defaultScopeKey: DEFAULT_SCOPE_KEY, }) interface UseSkillSelectionPersistenceOptions { @@ -22,7 +19,7 @@ interface UseSkillSelectionPersistenceOptions { params?: Record<string, unknown> | null pairColor?: PairColor onSkillSelect?: (skillId: string | null) => void - scopeKey?: string + scopeKey: string } export function useSkillSelectionPersistence({ @@ -49,9 +46,6 @@ interface EmitSkillSelectionOptions { widgetKey: string } -export function emitSkillSelectionChange({ - skillId, - ...rest -}: EmitSkillSelectionOptions) { +export function emitSkillSelectionChange({ skillId, ...rest }: EmitSkillSelectionOptions) { emitGeneric({ ...rest, entityId: skillId }) } diff --git a/apps/tradinggoose/widgets/utils/use-pending-entity-selection.test.tsx b/apps/tradinggoose/widgets/utils/use-pending-entity-selection.test.tsx new file mode 100644 index 000000000..1f40cfeb0 --- /dev/null +++ b/apps/tradinggoose/widgets/utils/use-pending-entity-selection.test.tsx @@ -0,0 +1,62 @@ +/** @vitest-environment jsdom */ + +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { usePendingEntitySelection } from './use-pending-entity-selection' + +const onSelect = vi.fn() +const selectRef: { current: ((entityId: string) => void) | null } = { current: null } +let root: ReturnType<typeof createRoot> | null = null +const previousActEnvironment = (globalThis as any).IS_REACT_ACT_ENVIRONMENT + +function Harness({ members }: { members: Array<{ entityId: string }> }) { + selectRef.current = usePendingEntitySelection(members, onSelect) + return null +} + +const render = (members: Array<{ entityId: string }>) => + act(async () => { + root!.render(<Harness members={members} />) + }) +const select = (entityId: string) => + act(async () => { + selectRef.current!(entityId) + }) + +beforeEach(() => { + ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true + onSelect.mockReset() + root = createRoot(document.body.appendChild(document.createElement('div'))) +}) + +afterEach(async () => { + await act(async () => { + root?.unmount() + }) + root = null + ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment +}) + +describe('usePendingEntitySelection', () => { + it('fires listed ids immediately, defers unlisted ids, and lets the latest request win', async () => { + await render([{ entityId: 'a' }]) + await select('a') + expect(onSelect).toHaveBeenLastCalledWith('a') + + await select('b') + expect(onSelect).toHaveBeenCalledTimes(1) + await render([{ entityId: 'a' }, { entityId: 'b' }]) + expect(onSelect).toHaveBeenLastCalledWith('b') + + await select('c') + await select('a') + expect(onSelect).toHaveBeenLastCalledWith('a') + await render([{ entityId: 'a' }, { entityId: 'b' }, { entityId: 'c' }]) + expect(onSelect).toHaveBeenCalledTimes(3) + + await select('never-appears') + await render([{ entityId: 'a' }, { entityId: 'b' }, { entityId: 'c' }]) + expect(onSelect).toHaveBeenCalledTimes(3) + }) +}) diff --git a/apps/tradinggoose/widgets/utils/use-pending-entity-selection.ts b/apps/tradinggoose/widgets/utils/use-pending-entity-selection.ts new file mode 100644 index 000000000..1a16fcf89 --- /dev/null +++ b/apps/tradinggoose/widgets/utils/use-pending-entity-selection.ts @@ -0,0 +1,36 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' + +/** + * Selection gate for entity-list projections: fires `onSelect(entityId)` + * immediately when the id is in `members`, otherwise defers it until the + * projection lists the id. The latest request always wins; a pending id is + * discarded on unmount and never fires if it never appears. + */ +export function usePendingEntitySelection( + members: ReadonlyArray<{ entityId: string }>, + onSelect: (entityId: string) => void +): (entityId: string) => void { + const [pendingEntityId, setPendingEntityId] = useState<string | null>(null) + const membersRef = useRef(members) + membersRef.current = members + const onSelectRef = useRef(onSelect) + onSelectRef.current = onSelect + + useEffect(() => { + if (!pendingEntityId) return + if (!members.some((member) => member.entityId === pendingEntityId)) return + onSelectRef.current(pendingEntityId) + setPendingEntityId(null) + }, [members, pendingEntityId]) + + return useCallback((entityId: string) => { + if (membersRef.current.some((member) => member.entityId === entityId)) { + setPendingEntityId(null) + onSelectRef.current(entityId) + return + } + setPendingEntityId(entityId) + }, []) +} diff --git a/apps/tradinggoose/widgets/utils/workflow-selection.ts b/apps/tradinggoose/widgets/utils/workflow-selection.ts index a3751da70..414911309 100644 --- a/apps/tradinggoose/widgets/utils/workflow-selection.ts +++ b/apps/tradinggoose/widgets/utils/workflow-selection.ts @@ -1,113 +1,47 @@ -import { useEffect, useRef } from 'react' -import { isEqual } from 'lodash' -import { - WORKFLOW_WIDGET_SELECT_WORKFLOW_EVENT, - type WorkflowWidgetSelectEventDetail, -} from '@/widgets/events' -import type { WidgetInstance } from '@/widgets/layout' +import { WORKFLOW_WIDGET_SELECT_WORKFLOW_EVENT } from '@/widgets/events' import type { PairColor } from '@/widgets/pair-colors' +import { + createEmitSelectionChange, + createSelectionPersistenceHook, + type UseSelectionPersistenceOptions, +} from '@/widgets/utils/selection-persistence-factory' + +const useWorkflowSelectionPersistenceGeneric = createSelectionPersistenceHook({ + eventName: WORKFLOW_WIDGET_SELECT_WORKFLOW_EVENT, + detailIdKey: 'workflowId', +}) interface UseWorkflowSelectionPersistenceOptions { onWidgetParamsChange?: (params: Record<string, unknown> | null) => void panelId?: string - widget?: WidgetInstance | null - pairColor?: PairColor params?: Record<string, unknown> | null -} - -const isRecord = (value: unknown): value is Record<string, unknown> => - typeof value === 'object' && value !== null && !Array.isArray(value) - -const normalizeString = (value: unknown) => { - if (typeof value !== 'string') return undefined - const trimmed = value.trim() - return trimmed || undefined -} - -const sanitizeWorkflowWidgetParams = ( - params: Record<string, unknown> | null | undefined -): Record<string, unknown> | null => { - if (!params || !isRecord(params)) return null - - const { workflowId: rawWorkflowId, ...restParams } = params - const workflowId = normalizeString(rawWorkflowId) - const nextParams = workflowId ? { ...restParams, workflowId } : restParams - - return Object.keys(nextParams).length > 0 ? nextParams : null + pairColor?: PairColor + onWorkflowSelect?: (workflowId: string | null) => void + scopeKey: string } export function useWorkflowSelectionPersistence({ - onWidgetParamsChange, - panelId, - widget, - pairColor = 'gray', - params, + onWorkflowSelect, + ...rest }: UseWorkflowSelectionPersistenceOptions) { - const latestParamsRef = useRef<Record<string, unknown> | null>( - sanitizeWorkflowWidgetParams(params) - ) - - useEffect(() => { - latestParamsRef.current = sanitizeWorkflowWidgetParams(params) - }, [params]) - - useEffect(() => { - if (!onWidgetParamsChange) { - return - } - - const handleWorkflowSelect = (event: Event) => { - const detail = (event as CustomEvent<WorkflowWidgetSelectEventDetail>).detail - if (!detail?.workflowId) return - if (pairColor !== 'gray') return - if (panelId && detail.panelId && detail.panelId !== panelId) return - if (widget?.key && detail.widgetKey && detail.widgetKey !== widget.key) return - - const currentParams = latestParamsRef.current ?? {} - const nextParams = sanitizeWorkflowWidgetParams({ - ...currentParams, - workflowId: detail.workflowId, - }) - - if (isEqual(currentParams, nextParams)) return - latestParamsRef.current = nextParams - onWidgetParamsChange(nextParams) - } - - window.addEventListener( - WORKFLOW_WIDGET_SELECT_WORKFLOW_EVENT, - handleWorkflowSelect as EventListener - ) - - return () => { - window.removeEventListener( - WORKFLOW_WIDGET_SELECT_WORKFLOW_EVENT, - handleWorkflowSelect as EventListener - ) - } - }, [onWidgetParamsChange, panelId, pairColor, widget?.key]) + const opts: UseSelectionPersistenceOptions = { + ...rest, + onEntitySelect: onWorkflowSelect, + } + useWorkflowSelectionPersistenceGeneric(opts) } +const emitGeneric = createEmitSelectionChange({ + eventName: WORKFLOW_WIDGET_SELECT_WORKFLOW_EVENT, + detailIdKey: 'workflowId', +}) + interface EmitWorkflowSelectionOptions { - workflowId: string + workflowId?: string | null panelId?: string - widgetKey?: string + widgetKey: string } -export function emitWorkflowSelectionChange({ - workflowId, - panelId, - widgetKey, -}: EmitWorkflowSelectionOptions) { - if (!workflowId) return - - window.dispatchEvent( - new CustomEvent<WorkflowWidgetSelectEventDetail>(WORKFLOW_WIDGET_SELECT_WORKFLOW_EVENT, { - detail: { - workflowId, - panelId, - widgetKey, - }, - }) - ) +export function emitWorkflowSelectionChange({ workflowId, ...rest }: EmitWorkflowSelectionOptions) { + emitGeneric({ ...rest, entityId: workflowId }) } diff --git a/apps/tradinggoose/widgets/widgets/_shared/skill/components/skill-list-item.tsx b/apps/tradinggoose/widgets/widgets/_shared/skill/components/skill-list-item.tsx index 2b91ec30d..a99c894d8 100644 --- a/apps/tradinggoose/widgets/widgets/_shared/skill/components/skill-list-item.tsx +++ b/apps/tradinggoose/widgets/widgets/_shared/skill/components/skill-list-item.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { Pencil, ToolCase, Trash2 } from 'lucide-react' -import { useLocale } from 'next-intl' +import { useLocale, useMessages } from 'next-intl' import { AlertDialog, AlertDialogCancel, @@ -14,9 +14,8 @@ 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 type { SkillDefinition } from '@/lib/skills/types' import { cn } from '@/lib/utils' -import type { SkillDefinition } from '@/stores/skills/types' interface SkillListItemProps { skill: SkillDefinition diff --git a/apps/tradinggoose/widgets/widgets/components/custom-tool-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/custom-tool-dropdown.tsx index 5f3194b94..fb9f79cb9 100644 --- a/apps/tradinggoose/widgets/widgets/components/custom-tool-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/custom-tool-dropdown.tsx @@ -19,9 +19,7 @@ import { widgetHeaderMenuTextClassName, } from '@/components/widget-header-control' import { cn } from '@/lib/utils' -import { useCustomTools } from '@/hooks/queries/custom-tools' -import { useCustomToolsStore } from '@/stores/custom-tools/store' -import type { CustomToolDefinition } from '@/stores/custom-tools/types' +import { useEntityList } from '@/lib/yjs/use-entity-fields' const DROPDOWN_MAX_HEIGHT = '20rem' const DROPDOWN_VIEWPORT_HEIGHT = '14rem' @@ -30,7 +28,7 @@ const CUSTOM_TOOL_ICON_COLOR = '#d97706' interface CustomToolDropdownProps { workspaceId?: string | null value?: string | null - onChange?: (customToolId: string | null, tool?: CustomToolDefinition) => void + onChange?: (customToolId: string | null) => void disabled?: boolean placeholder?: string align?: 'start' | 'end' @@ -38,7 +36,13 @@ interface CustomToolDropdownProps { menuClassName?: string } -const getToolTitle = (tool?: CustomToolDefinition | null) => tool?.title.trim() ?? '' +type CustomToolDropdownOption = { + id: string + title: string + description: string +} + +const getToolTitle = (tool?: CustomToolDropdownOption | null) => tool?.title.trim() ?? '' export function CustomToolDropdown({ workspaceId, @@ -52,33 +56,23 @@ export function CustomToolDropdown({ }: CustomToolDropdownProps) { const copy = useMessages().workspace.widgets.customToolDropdown const [searchQuery, setSearchQuery] = useState('') - const { - data: queryTools = [], - error: toolsError, - isLoading: toolsLoading, - isFetching, - refetch, - } = useCustomTools(workspaceId ?? '') - const storedTools = useCustomToolsStore((state) => - workspaceId ? state.getAllTools(workspaceId) : [] - ) + const { members, error, isLoading: listLoading } = useEntityList('custom_tool', workspaceId) const workspaceTools = useMemo(() => { - const tools = queryTools.length > 0 ? queryTools : storedTools - return [...tools].sort((a, b) => a.title.localeCompare(b.title)) - }, [queryTools, storedTools]) + if (!workspaceId) return [] + return members.map((member) => ({ + id: member.entityId, + title: member.entityName, + description: member.entityDescription ?? '', + })) + }, [members, workspaceId]) const selectedToolId = value ?? null const selectedTool = workspaceTools.find((tool) => tool.id === selectedToolId) ?? null const hasTools = workspaceTools.length > 0 - const isLoading = (toolsLoading || isFetching) && !hasTools + const isLoading = listLoading && !hasTools const isDropdownDisabled = disabled || !workspaceId - const errorMessage = - toolsError instanceof Error - ? toolsError.message - : toolsError - ? copy.unableToLoadCustomTools - : null + const errorMessage = error || null const tooltipText = !workspaceId ? copy.selectWorkspaceToChooseCustomTools : errorMessage @@ -105,20 +99,14 @@ export function CustomToolDropdown({ return workspaceTools.filter((tool) => { const title = getToolTitle(tool).toLowerCase() - const description = tool.schema?.function?.description?.toLowerCase() ?? '' - return title.includes(normalizedQuery) || description.includes(normalizedQuery) + return ( + title.includes(normalizedQuery) || tool.description.toLowerCase().includes(normalizedQuery) + ) }) }, [searchQuery, workspaceTools]) - const handleRetry = () => { - if (!workspaceId) return - refetch().catch((error) => { - console.error('Failed to reload custom tools for custom tool dropdown', error) - }) - } - - const handleSelect = (tool: CustomToolDefinition) => { - onChange?.(tool.id, tool) + const handleSelect = (tool: CustomToolDropdownOption) => { + onChange?.(tool.id) } const renderMenuBody = () => { @@ -134,13 +122,6 @@ export function CustomToolDropdown({ return ( <div className='space-y-2 px-3 py-2 text-xs'> <p className='text-destructive'>{errorMessage}</p> - <button - type='button' - className='font-semibold text-primary text-xs hover:underline' - onClick={handleRetry} - > - {copy.retry} - </button> </div> ) } diff --git a/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx index ea9745ccc..4debcfe0e 100644 --- a/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/mcp-dropdown.tsx @@ -3,7 +3,6 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react' import { Check, ChevronDown, Loader2, Search, Server } from 'lucide-react' import { useMessages } from 'next-intl' -import { shallow } from 'zustand/shallow' import { DropdownMenu, DropdownMenuContent, @@ -20,11 +19,11 @@ import { widgetHeaderMenuTextClassName, } from '@/components/widget-header-control' import { cn } from '@/lib/utils' -import { useMcpServersStore } from '@/stores/mcp-servers/store' -import type { McpServerWithStatus } from '@/stores/mcp-servers/types' +import { useEntityList } from '@/lib/yjs/use-entity-fields' const DROPDOWN_MAX_HEIGHT = '20rem' const DROPDOWN_VIEWPORT_HEIGHT = '14rem' +const MCP_ICON_COLOR = '#64748b' interface McpDropdownProps { workspaceId?: string | null @@ -36,19 +35,14 @@ interface McpDropdownProps { triggerClassName?: string } -const getServerIconColor = (status?: McpServerWithStatus['connectionStatus']) => { - if (status === 'connected') { - return '#10b981' - } - - if (status === 'error') { - return '#ef4444' - } - - return '#64748b' +type McpDropdownOption = { + id: string + name: string + workspaceId: string + enabled?: boolean } -const getServerLabel = (server?: McpServerWithStatus | null, fallbackLabel?: string) => +const getServerLabel = (server?: McpDropdownOption | null, fallbackLabel?: string) => server?.name || server?.id || fallbackLabel || '' export function McpDropdown({ @@ -62,26 +56,21 @@ export function McpDropdown({ }: McpDropdownProps) { const copy = useMessages().workspace.widgets.mcpDropdown const [searchQuery, setSearchQuery] = useState('') - const { servers, isLoading, error, fetchServers } = useMcpServersStore( - (state) => ({ - servers: state.servers, - isLoading: state.isLoading, - error: state.error, - fetchServers: state.fetchServers, - }), - shallow - ) + const { members, isLoading, error } = useEntityList('mcp_server', workspaceId) const workspaceServers = useMemo(() => { if (!workspaceId) return [] - return servers - .filter( - (server) => - server.workspaceId === workspaceId && !server.deletedAt && server.enabled !== false - ) + return members + .filter((member) => member.enabled !== false) + .map((member) => ({ + id: member.entityId, + name: member.entityName, + workspaceId, + enabled: member.enabled, + })) .sort((a, b) => getServerLabel(a).localeCompare(getServerLabel(b))) - }, [servers, workspaceId]) + }, [members, workspaceId]) const selectedServerId = value ?? null const selectedServer = workspaceServers.find((server) => server.id === selectedServerId) ?? null @@ -100,16 +89,6 @@ export function McpDropdown({ setSearchQuery('') }, [workspaceId]) - useEffect(() => { - if (!workspaceId || hasServers) { - return - } - - fetchServers(workspaceId).catch((fetchError) => { - console.error('Failed to load MCP servers for dropdown', fetchError) - }) - }, [fetchServers, hasServers, workspaceId]) - const handleSearchInputKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => { if (event.key === 'Escape') return @@ -131,14 +110,7 @@ export function McpDropdown({ }) }, [searchQuery, workspaceServers]) - const handleRetry = () => { - if (!workspaceId) return - fetchServers(workspaceId).catch((fetchError) => { - console.error('Failed to reload MCP servers for dropdown', fetchError) - }) - } - - const handleSelect = (server: McpServerWithStatus) => { + const handleSelect = (server: McpDropdownOption) => { onChange?.(server.id) } @@ -155,13 +127,6 @@ export function McpDropdown({ return ( <div className='space-y-2 px-3 py-2 text-xs'> <p className='text-destructive'>{error}</p> - <button - type='button' - className='font-semibold text-primary text-xs hover:underline' - onClick={handleRetry} - > - {copy.retry} - </button> </div> ) } @@ -195,7 +160,6 @@ export function McpDropdown({ <div className='flex flex-col gap-1'> {filteredServers.map((server) => { const isSelected = server.id === selectedServerId - const iconColor = getServerIconColor(server.connectionStatus) return ( <DropdownMenuItem @@ -210,13 +174,13 @@ export function McpDropdown({ <div className='flex min-w-0 items-center gap-2'> <span className='h-5 w-5 rounded-xs p-0.5' - style={{ backgroundColor: `${iconColor}20` }} + style={{ backgroundColor: `${MCP_ICON_COLOR}20` }} aria-hidden='true' > <Server className='h-full w-full' aria-hidden='true' - style={{ color: iconColor }} + style={{ color: MCP_ICON_COLOR }} /> </span> <span className={cn(widgetHeaderMenuTextClassName, 'truncate')}> @@ -233,14 +197,13 @@ export function McpDropdown({ const chevronClassName = 'h-4 w-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180' - const selectedIconColor = getServerIconColor(selectedServer?.connectionStatus) const iconBadge = ( <span className='h-5 w-5 rounded-xs p-0.5' - style={{ backgroundColor: `${selectedIconColor}20` }} + style={{ backgroundColor: `${MCP_ICON_COLOR}20` }} aria-hidden='true' > - <Server className='h-full w-full' aria-hidden='true' style={{ color: selectedIconColor }} /> + <Server className='h-full w-full' aria-hidden='true' style={{ color: MCP_ICON_COLOR }} /> </span> ) const labelContent = selectedServer ? ( diff --git a/apps/tradinggoose/widgets/widgets/components/pine-indicator-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/pine-indicator-dropdown.tsx index b1d6a1bda..8cb8c7f09 100644 --- a/apps/tradinggoose/widgets/widgets/components/pine-indicator-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/pine-indicator-dropdown.tsx @@ -14,9 +14,8 @@ import { widgetHeaderControlClassName } from '@/components/widget-header-control import { getStableVibrantColor } from '@/lib/colors' import { DEFAULT_INDICATORS_META } from '@/lib/indicators/default' import { cn } from '@/lib/utils' -import { useIndicators } from '@/hooks/queries/indicators' +import { useEntityList } from '@/lib/yjs/use-entity-fields' import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' -import { useIndicatorsStore } from '@/stores/indicators/store' const FALLBACK_COLOR = '#3972F6' @@ -65,7 +64,6 @@ export function IndicatorDropdown({ const widgetsCopy = useWorkspaceWidgetsMessages() const copy = widgetsCopy.indicatorDropdown const [internalValue, setInternalValue] = useState<string[]>([]) - const [loadError, setLoadError] = useState<string | null>(null) const [searchQuery, setSearchQuery] = useState('') const [dropdownOpen, setDropdownOpen] = useState(false) const [activeFilterId, setActiveFilterId] = useState<IndicatorFilterId>( @@ -76,21 +74,20 @@ export function IndicatorDropdown({ const isMultiSelect = selectionMode === 'multiple' const { - isLoading: queryLoading, - error: queryError, - refetch, - isFetching, - } = useIndicators(workspaceId ?? '') - - const indicators = useIndicatorsStore((state) => - workspaceId ? state.getAllIndicators(workspaceId) : [] - ) + members, + isLoading: listLoading, + error: listError, + } = useEntityList('indicator', workspaceId) const workspaceIndicators = useMemo(() => { if (!workspaceId) return [] - const scoped = [...indicators] + const scoped = members.map((member) => ({ + id: member.entityId, + name: member.entityName, + color: member.color, + })) return scoped.sort((a, b) => a.name.localeCompare(b.name)) - }, [indicators, workspaceId]) + }, [members, workspaceId]) const defaultIndicatorOptions = useMemo<IndicatorOption[]>( () => @@ -141,8 +138,9 @@ export function IndicatorDropdown({ }, [isMultiSelect, selectedIndicatorIds, selectedIndicator, indicatorOptions]) const hasIndicators = indicatorOptions.length > 0 - const isLoading = queryLoading && !hasIndicators + const isLoading = listLoading && !hasIndicators const isDropdownDisabled = disabled || !workspaceId + const loadError = listError || null const tooltipText = !workspaceId ? copy.selectWorkspaceFirst @@ -153,7 +151,6 @@ export function IndicatorDropdown({ : copy.tooltip useEffect(() => { - setLoadError(null) setSearchQuery('') setDropdownOpen(false) setActiveFilterId(includeDefaults ? 'default' : 'custom') @@ -162,18 +159,6 @@ export function IndicatorDropdown({ } }, [workspaceId, isControlled, includeDefaults]) - useEffect(() => { - if (queryError) { - setLoadError(copy.failedToLoadIndicators) - } - }, [copy.failedToLoadIndicators, queryError]) - - useEffect(() => { - if (indicatorOptions.length > 0 && loadError) { - setLoadError(null) - } - }, [indicatorOptions.length, loadError]) - const selectedFilterId = useMemo<IndicatorFilterId>(() => { if (!includeDefaults) return 'custom' if (!firstSelectedIndicatorId) return 'default' @@ -191,15 +176,6 @@ export function IndicatorDropdown({ } } - const handleRetry = () => { - if (!workspaceId) return - setLoadError(null) - refetch().catch((error) => { - console.error('Failed to load indicators for indicator dropdown', error) - setLoadError(copy.failedToLoadIndicators) - }) - } - const handleToggleIndicator = (id: string) => { if (isMultiSelect) { const next = selectedIndicatorSet.has(id) @@ -254,7 +230,7 @@ export function IndicatorDropdown({ return groups }, [copy.customIndicators, copy.defaultIndicators, includeDefaults]) - const shouldShowLoadingState = (isLoading || isFetching) && !hasIndicators + const shouldShowLoadingState = isLoading && !hasIndicators const emptyContent = (() => { if (!workspaceId) return copy.selectWorkspaceFirst @@ -262,13 +238,6 @@ export function IndicatorDropdown({ return ( <div className='space-y-2 text-xs'> <p className='text-destructive'>{loadError}</p> - <button - type='button' - className='font-semibold text-primary text-xs hover:underline' - onClick={handleRetry} - > - {copy.retry} - </button> </div> ) } diff --git a/apps/tradinggoose/widgets/widgets/components/skill-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/skill-dropdown.tsx index c25f79181..e0797c05a 100644 --- a/apps/tradinggoose/widgets/widgets/components/skill-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/skill-dropdown.tsx @@ -2,6 +2,7 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react' import { Check, ChevronDown, Loader2, Search, ToolCase } from 'lucide-react' +import { useMessages } from 'next-intl' import { DropdownMenu, DropdownMenuContent, @@ -18,9 +19,7 @@ import { widgetHeaderMenuTextClassName, } from '@/components/widget-header-control' import { cn } from '@/lib/utils' -import { useSkills } from '@/hooks/queries/skills' -import { useMessages } from 'next-intl' -import type { SkillDefinition } from '@/stores/skills/types' +import { useEntityList } from '@/lib/yjs/use-entity-fields' const DROPDOWN_MAX_HEIGHT = '20rem' const DROPDOWN_VIEWPORT_HEIGHT = '14rem' @@ -29,7 +28,7 @@ const SKILL_ICON_COLOR = '#059669' interface SkillDropdownProps { workspaceId?: string | null value?: string | null - onChange?: (skillId: string | null, skill?: SkillDefinition) => void + onChange?: (skillId: string | null) => void disabled?: boolean placeholder?: string align?: 'start' | 'end' @@ -37,7 +36,13 @@ interface SkillDropdownProps { menuClassName?: string } -const getSkillTitle = (skill?: SkillDefinition | null, fallback = '') => skill?.name || fallback +type SkillDropdownOption = { + id: string + name: string + description: string +} + +const getSkillTitle = (skill?: SkillDropdownOption | null, fallback = '') => skill?.name || fallback export function SkillDropdown({ workspaceId, @@ -51,21 +56,25 @@ export function SkillDropdown({ }: SkillDropdownProps) { const copy = useMessages().workspace.widgets.skillDropdown const [searchQuery, setSearchQuery] = useState('') - const { - data: skills = [], - error: queryError, - isLoading: queryLoading, - isFetching, - refetch, - } = useSkills(workspaceId ?? '') + const { members, error, isLoading: listLoading } = useEntityList('skill', workspaceId) + const skills = useMemo<SkillDropdownOption[]>( + () => + workspaceId + ? members.map((member) => ({ + id: member.entityId, + name: member.entityName, + description: member.entityDescription ?? '', + })) + : [], + [members, workspaceId] + ) const selectedSkillId = value ?? null const selectedSkill = skills.find((skill) => skill.id === selectedSkillId) ?? null const hasSkills = skills.length > 0 - const isLoading = (queryLoading || isFetching) && !hasSkills + const isLoading = listLoading && !hasSkills const isDropdownDisabled = disabled || !workspaceId - const errorMessage = - queryError instanceof Error ? queryError.message : queryError ? copy.unableToLoadSkills : null + const errorMessage = error || null const tooltipText = !workspaceId ? copy.selectWorkspaceToChooseSkills : errorMessage @@ -94,20 +103,14 @@ export function SkillDropdown({ return skills.filter((skill) => { const name = skill.name.toLowerCase() - const description = skill.description.toLowerCase() - return name.includes(normalizedQuery) || description.includes(normalizedQuery) + return ( + name.includes(normalizedQuery) || skill.description.toLowerCase().includes(normalizedQuery) + ) }) }, [searchQuery, skills]) - const handleRetry = () => { - if (!workspaceId) return - refetch().catch((error) => { - console.error('Failed to reload skills for skill dropdown', error) - }) - } - - const handleSelect = (skill: SkillDefinition) => { - onChange?.(skill.id, skill) + const handleSelect = (skill: SkillDropdownOption) => { + onChange?.(skill.id) } const renderMenuBody = () => { @@ -123,13 +126,6 @@ export function SkillDropdown({ return ( <div className='space-y-2 px-3 py-2 text-xs'> <p className='text-destructive'>{errorMessage}</p> - <button - type='button' - className='font-semibold text-primary text-xs hover:underline' - onClick={handleRetry} - > - {copy.retry} - </button> </div> ) } diff --git a/apps/tradinggoose/widgets/widgets/components/workflow-dropdown.tsx b/apps/tradinggoose/widgets/widgets/components/workflow-dropdown.tsx index f37e4d280..43ad298da 100644 --- a/apps/tradinggoose/widgets/widgets/components/workflow-dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/components/workflow-dropdown.tsx @@ -2,7 +2,6 @@ import { type KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react' import { Check, ChevronDown, Loader2, Search, Workflow } from 'lucide-react' -import { shallow } from 'zustand/shallow' import { DropdownMenu, DropdownMenuContent, @@ -19,11 +18,9 @@ import { widgetHeaderMenuTextClassName, } from '@/components/widget-header-control' import { cn } from '@/lib/utils' +import { useEntityList } from '@/lib/yjs/use-entity-fields' import { useWorkflowDropdownMessages } from '@/i18n/workspace-widget-hooks' import { useSetPairColorContext } from '@/stores/dashboard/pair-store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import type { WorkflowMetadata } from '@/stores/workflows/registry/types' -import { WORKSPACE_BOOTSTRAP_CHANNEL } from '@/stores/workflows/registry/types' import type { PairColor } from '@/widgets/pair-colors' const DROPDOWN_MAX_HEIGHT = '20rem' @@ -32,14 +29,22 @@ const DROPDOWN_VIEWPORT_HEIGHT = '14rem' interface WorkflowDropdownProps { workspaceId?: string | null value?: string | null - onChange?: (workflowId: string, workflow?: WorkflowMetadata) => void + onChange?: (workflowId: string, workflow?: WorkflowDropdownOption) => void disabled?: boolean placeholder?: string pairColor?: PairColor align?: 'start' | 'end' triggerClassName?: string menuClassName?: string - includeMarketplace?: boolean +} + +type WorkflowDropdownOption = { + id: string + name: string + description: string + color: string + workspaceId: string + folderId?: string | null } export function WorkflowDropdown({ @@ -52,56 +57,37 @@ export function WorkflowDropdown({ align = 'start', triggerClassName, menuClassName, - includeMarketplace = true, }: WorkflowDropdownProps) { const copy = useWorkflowDropdownMessages() - const [loadError, setLoadError] = useState<string | null>(null) - const [hasRequestedLoad, setHasRequestedLoad] = useState(false) const [searchQuery, setSearchQuery] = useState('') const resolvedPairColor = pairColor && pairColor !== 'gray' ? pairColor : 'gray' const isPairContextActive = resolvedPairColor !== 'gray' - const metadataChannelId = WORKSPACE_BOOTSTRAP_CHANNEL - - const { - workflows: registryWorkflows, - metadataHydrationPhase, - loadWorkflows, - } = useWorkflowRegistry( - (state) => ({ - workflows: state.workflows, - metadataHydrationPhase: state.getHydration(metadataChannelId).phase, - loadWorkflows: state.loadWorkflows, - }), - shallow - ) + const { members, error, isLoading } = useEntityList('workflow', workspaceId) const setPairContext = useSetPairColorContext() - const workspaceWorkflows = useMemo(() => { + const workspaceWorkflows = useMemo<WorkflowDropdownOption[]>(() => { if (!workspaceId) return [] - const scoped = Object.values(registryWorkflows ?? {}).filter((workflow) => { - if (!workflow || workflow.workspaceId !== workspaceId) { - return false - } - - if (includeMarketplace) { - return true - } - - return !workflow.marketplaceData - }) - - return scoped.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) - }, [registryWorkflows, workspaceId, includeMarketplace]) + return [...members] + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')) + .map((member) => ({ + id: member.entityId, + name: member.entityName, + description: member.entityDescription ?? '', + color: member.color ?? '#64748b', + workspaceId, + folderId: member.folderId ?? null, + })) + }, [members, workspaceId]) const selectedWorkflowId = value ?? null const selectedWorkflow = workspaceWorkflows.find((workflow) => workflow.id === selectedWorkflowId) - const isLoading = metadataHydrationPhase === 'metadata-loading' + const hasWorkflows = workspaceWorkflows.length > 0 const isDropdownDisabled = disabled || !workspaceId const tooltipText = !workspaceId ? copy.selectWorkspaceFirst - : loadError + : error && !hasWorkflows ? copy.unableToLoad : disabled ? copy.workflowSelectionUnavailable @@ -109,40 +95,10 @@ export function WorkflowDropdown({ const resolvedPlaceholder = placeholder ?? copy.selectWorkflow useEffect(() => { - setLoadError(null) - setHasRequestedLoad(false) setSearchQuery('') }, [workspaceId]) - useEffect(() => { - if (!workspaceId || workspaceWorkflows.length > 0 || hasRequestedLoad) { - return - } - - let cancelled = false - setHasRequestedLoad(true) - setLoadError(null) - - loadWorkflows({ workspaceId, channelId: metadataChannelId }).catch((error) => { - if (!cancelled) { - console.error('Failed to load workflows for workflow dropdown', error) - setLoadError(copy.failedToLoad) - } - }) - - return () => { - cancelled = true - } - }, [ - workspaceId, - workspaceWorkflows.length, - hasRequestedLoad, - loadWorkflows, - metadataChannelId, - copy.failedToLoad, - ]) - - const handleSelect = (workflow: WorkflowMetadata) => { + const handleSelect = (workflow: WorkflowDropdownOption) => { if (!workflow) { return } @@ -154,12 +110,6 @@ export function WorkflowDropdown({ onChange?.(workflow.id, workflow) } - const handleRetry = () => { - if (!workspaceId) return - setLoadError(null) - setHasRequestedLoad(false) - } - const handleSearchInputKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => { if (event.key === 'Escape') return @@ -176,6 +126,7 @@ export function WorkflowDropdown({ const name = workflow.name || copy.untitledWorkflow return ( name.toLowerCase().includes(normalizedQuery) || + workflow.description.toLowerCase().includes(normalizedQuery) || workflow.id.toLowerCase().includes(normalizedQuery) ) }) @@ -190,17 +141,10 @@ export function WorkflowDropdown({ ) } - if (loadError) { + if (error && !hasWorkflows) { return ( <div className='space-y-2 px-3 py-2 text-xs'> - <p className='text-destructive'>{loadError}</p> - <button - type='button' - className='font-semibold text-primary text-xs hover:underline' - onClick={handleRetry} - > - {copy.retry} - </button> + <p className='text-destructive'>{error}</p> </div> ) } 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 c16e82182..5f853d4e1 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 @@ -179,7 +179,6 @@ describe('CustomToolEditor export', () => { activeSection='schema' blockId='dashboard-custom-tool-editor' toolId='tool-1' - onSave={vi.fn()} onSectionChange={onSectionChange} exportRef={exportRef as MutableRefObject<() => void>} saveRef={saveRef as MutableRefObject<() => void>} @@ -222,7 +221,6 @@ describe('CustomToolEditor export', () => { activeSection='code' blockId='dashboard-custom-tool-editor' toolId='tool-1' - onSave={vi.fn()} onSectionChange={onSectionChange} exportRef={exportRef as MutableRefObject<() => void>} saveRef={saveRef as MutableRefObject<() => void>} @@ -310,7 +308,6 @@ describe('CustomToolEditor export', () => { activeSection='schema' blockId='dashboard-custom-tool-editor' toolId='tool-1' - onSave={vi.fn()} onSectionChange={onSectionChange} exportRef={exportRef as MutableRefObject<() => void>} saveRef={saveRef as MutableRefObject<() => void>} 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 ca51e66d2..0b18afb20 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 @@ -30,7 +30,6 @@ interface CustomToolEditorProps { toolId: string doc: Y.Doc | null save: () => Promise<void> - onSave: () => void onSectionChange: (section: CustomToolEditorSection) => void exportRef: MutableRefObject<() => void> saveRef: MutableRefObject<() => void> @@ -42,7 +41,6 @@ export function CustomToolEditor({ toolId, doc, save, - onSave, onSectionChange, exportRef, saveRef, @@ -354,8 +352,6 @@ IMPORTANT FORMATTING RULES: setFunctionCode(latestFunctionCode) await save() - - onSave() } catch (error) { logger.error('Error saving custom tool:', { error }) setSchemaError(copy.validation.failedToSave) @@ -364,7 +360,6 @@ IMPORTANT FORMATTING RULES: }, [ parseCurrentSchema, doc, - onSave, onSectionChange, save, functionCode, diff --git a/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx b/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx index c1ca91a33..191ec260f 100644 --- a/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_custom_tool/index.tsx @@ -1,18 +1,16 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Download, Save, SquareTerminal } from 'lucide-react' 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 { useSavedEntityYjsSession } from '@/lib/yjs/use-entity-fields' -import { useCustomTools } from '@/hooks/queries/custom-tools' +import { useEntityList, useSavedEntityYjsSession } from '@/lib/yjs/use-entity-fields' 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' +import { DEFAULT_WORKFLOW_CHANNEL_ID } from '@/stores/workflows/workflow/types' import { CUSTOM_TOOL_EDITOR_ACTION_EVENT, type CustomToolEditorActionEventDetail, @@ -23,6 +21,10 @@ import { emitCustomToolSelectionChange, useCustomToolSelectionPersistence, } from '@/widgets/utils/custom-tool-selection' +import { + resolveEntityIdFromList, + usePersistResolvedEntityId, +} from '@/widgets/utils/entity-selection' import { CUSTOM_TOOL_EDITOR_WIDGET_KEY, resolveCustomToolId, @@ -35,9 +37,6 @@ import { import { WidgetStateMessage } from '@/widgets/widgets/editor_indicator/components/widget-state-message' import { WorkflowRouteProvider } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' -const sortCustomTools = (tools: CustomToolDefinition[]) => - [...tools].sort((a, b) => a.title.localeCompare(b.title)) - function emitCustomToolEditorAction(detail: CustomToolEditorActionEventDetail) { window.dispatchEvent( new CustomEvent<CustomToolEditorActionEventDetail>(CUSTOM_TOOL_EDITOR_ACTION_EVENT, { @@ -106,10 +105,8 @@ function EditorCustomToolWidgetBody({ panelId, widget, }: WidgetComponentProps) { - const locale = useLocale() as LocaleCode const copy = useMessages().workspace.widgets.customToolEditor const workspaceId = context?.workspaceId ?? null - const { data: queryTools = [], isLoading, error, refetch } = useCustomTools(workspaceId ?? '') const resolvedPairColor = (pairColor ?? 'gray') as PairColor const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) @@ -118,21 +115,25 @@ function EditorCustomToolWidgetBody({ const saveRef = useRef<() => void>(() => {}) const [activeSection, setActiveSection] = useState<CustomToolEditorSection>('schema') - const tools = useMemo(() => sortCustomTools(queryTools), [queryTools]) - const paramsCustomToolId = resolveCustomToolId({ params }) const requestedCustomToolId = isLinkedToColorPair ? (pairContext?.customToolId ?? null) : paramsCustomToolId const normalizedRequestedCustomToolId = requestedCustomToolId?.trim() ?? '' - const hasRequestedTool = - normalizedRequestedCustomToolId.length > 0 && - tools.some((tool) => tool.id === normalizedRequestedCustomToolId) - const selectedToolId = hasRequestedTool - ? normalizedRequestedCustomToolId - : isLinkedToColorPair - ? null - : (tools[0]?.id ?? null) + const hasRequestedCustomTool = normalizedRequestedCustomToolId.length > 0 + const { + members: customToolMembers, + isLoading: isCustomToolListLoading, + error: customToolListError, + } = useEntityList('custom_tool', workspaceId) + const requestedCustomToolMember = hasRequestedCustomTool + ? customToolMembers.find((member) => member.entityId === normalizedRequestedCustomToolId) + : null + const selectedToolId = resolveEntityIdFromList({ + requestedEntityId: requestedCustomToolId, + entityIds: customToolMembers.map((member) => member.entityId), + useDefaultEntity: !isLinkedToColorPair, + }) useCustomToolSelectionPersistence({ onWidgetParamsChange, @@ -162,40 +163,15 @@ function EditorCustomToolWidgetBody({ [panelId, widget?.key] ) - const selectedTool = selectedToolId - ? (tools.find((tool) => tool.id === selectedToolId) ?? null) - : null const customToolSession = useSavedEntityYjsSession('custom_tool', selectedToolId, workspaceId) - useEffect(() => { - if (!selectedToolId) return - if (isLinkedToColorPair) { - if (pairContext?.customToolId === selectedToolId) { - return - } - - setPairContext(resolvedPairColor, { customToolId: selectedToolId }) - return - } - - if (!onWidgetParamsChange || paramsCustomToolId === selectedToolId) { - return - } - - onWidgetParamsChange({ - ...(params ?? {}), - customToolId: selectedToolId, - }) - }, [ - isLinkedToColorPair, + usePersistResolvedEntityId({ + entityId: selectedToolId, + entityIdKey: 'customToolId', onWidgetParamsChange, - pairContext?.customToolId, + pairColor: resolvedPairColor, params, - paramsCustomToolId, - resolvedPairColor, - selectedToolId, - setPairContext, - ]) + }) useEffect(() => { if (!selectedToolId) { @@ -217,15 +193,25 @@ function EditorCustomToolWidgetBody({ return <WidgetStateMessage message={copy.body.selectWorkspace} /> } - if (error && tools.length === 0) { - return ( - <WidgetStateMessage - message={error instanceof Error ? error.message : copy.body.failedToLoadCustomTools} - /> - ) + if (customToolListError && customToolMembers.length === 0) { + return <WidgetStateMessage message={customToolListError} /> + } + + if ( + hasRequestedCustomTool && + !isCustomToolListLoading && + !customToolListError && + !requestedCustomToolMember && + !selectedToolId + ) { + return <WidgetStateMessage message={copy.body.customToolNotFound} /> + } + + if (customToolSession.error) { + return <WidgetStateMessage message={customToolSession.error} /> } - if (isLoading && tools.length === 0) { + if (isCustomToolListLoading || customToolSession.isLoading) { return ( <div className='flex h-full w-full items-center justify-center'> <LoadingAgent size='md' /> @@ -237,32 +223,12 @@ function EditorCustomToolWidgetBody({ return ( <WidgetStateMessage message={ - isLinkedToColorPair - ? normalizedRequestedCustomToolId.length > 0 - ? copy.body.customToolNotFound - : copy.body.noSharedCustomToolSelected - : copy.body.noCustomToolsYet + isLinkedToColorPair ? copy.body.noSharedCustomToolSelected : copy.body.noCustomToolsYet } /> ) } - if (!selectedTool) { - return <WidgetStateMessage message={copy.body.customToolNotFound} /> - } - - if (customToolSession.error) { - return <WidgetStateMessage message={customToolSession.error} /> - } - - if (customToolSession.isLoading) { - return ( - <div className='flex h-full w-full items-center justify-center'> - <LoadingAgent size='md' /> - </div> - ) - } - return ( <WorkflowRouteProvider workspaceId={workspaceId} @@ -276,11 +242,6 @@ function EditorCustomToolWidgetBody({ save={customToolSession.save} toolId={selectedToolId} onSectionChange={syncActiveSection} - onSave={() => { - refetch().catch((refetchError) => { - console.error('Failed to refresh custom tools after save', refetchError) - }) - }} exportRef={exportRef} saveRef={saveRef} blockId='dashboard-custom-tool-editor' 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 e27cae7cf..884267f16 100644 --- a/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx @@ -1,13 +1,16 @@ 'use client' -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useRef } from 'react' import { useMessages } from 'next-intl' import { LoadingAgent } from '@/components/ui/loading-agent' -import { useSavedEntityYjsSession } from '@/lib/yjs/use-entity-fields' -import { useIndicators } from '@/hooks/queries/indicators' +import { useEntityList, 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 { + resolveEntityIdFromList, + usePersistResolvedEntityId, +} from '@/widgets/utils/entity-selection' import { useIndicatorEditorActions } from '@/widgets/utils/indicator-editor-actions' import { useIndicatorSelectionPersistence } from '@/widgets/utils/indicator-selection' import { IndicatorCodePanel } from '@/widgets/widgets/editor_indicator/components/pine-indicator-code-panel' @@ -26,7 +29,6 @@ export function EditorIndicatorWidgetBody({ }: EditorIndicatorWidgetBodyProps) { const copy = useMessages().workspace.widgets.indicatorEditor.body const workspaceId = context?.workspaceId ?? null - const { data: indicators = [], isLoading, error } = useIndicators(workspaceId ?? '') const resolvedPairColor = (pairColor ?? 'gray') as PairColor const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) @@ -37,61 +39,37 @@ export function EditorIndicatorWidgetBody({ ? (pairContext?.indicatorId ?? null) : paramsIndicatorId - const workspaceIndicators = workspaceId - ? indicators.filter((indicator) => indicator.workspaceId === workspaceId) - : [] const normalizedRequestedIndicatorId = requestedIndicatorId?.trim() ?? '' - const hasRequestedIndicator = - normalizedRequestedIndicatorId.length > 0 && - workspaceIndicators.some((indicator) => indicator.id === normalizedRequestedIndicatorId) - const indicatorId = hasRequestedIndicator - ? normalizedRequestedIndicatorId - : isLinkedToColorPair - ? null - : (workspaceIndicators[0]?.id ?? null) - const indicator = indicatorId - ? (workspaceIndicators.find((candidate) => candidate.id === indicatorId) ?? null) + const hasRequestedIndicator = normalizedRequestedIndicatorId.length > 0 + const { + members: indicatorMembers, + isLoading: isIndicatorListLoading, + error: indicatorListError, + } = useEntityList('indicator', workspaceId) + const requestedIndicatorMember = hasRequestedIndicator + ? indicatorMembers.find((member) => member.entityId === normalizedRequestedIndicatorId) : null + const indicatorId = resolveEntityIdFromList({ + requestedEntityId: requestedIndicatorId, + entityIds: indicatorMembers.map((member) => member.entityId), + useDefaultEntity: !isLinkedToColorPair, + }) const indicatorSession = useSavedEntityYjsSession('indicator', indicatorId, workspaceId) - useEffect(() => { - if (!indicatorId) { - return - } - - if (isLinkedToColorPair) { - if (pairContext?.indicatorId === indicatorId) { - return - } - - setPairContext(resolvedPairColor, { indicatorId }) - return - } - - if (!onWidgetParamsChange || paramsIndicatorId === indicatorId) { - return - } - - onWidgetParamsChange({ - ...(params ?? {}), - indicatorId, - }) - }, [ - indicatorId, - isLinkedToColorPair, + usePersistResolvedEntityId({ + entityId: indicatorId, + entityIdKey: 'indicatorId', onWidgetParamsChange, - pairContext?.indicatorId, + pairColor: resolvedPairColor, params, - paramsIndicatorId, - resolvedPairColor, - setPairContext, - ]) + }) useIndicatorSelectionPersistence({ onWidgetParamsChange, panelId, params, pairColor: resolvedPairColor, + scopeKey: 'editor_indicator', onIndicatorSelect: (nextId) => { if (!isLinkedToColorPair) return if (pairContext?.indicatorId === nextId) return @@ -127,37 +105,17 @@ export function EditorIndicatorWidgetBody({ return <WidgetStateMessage message={copy.selectWorkspace} /> } - if (error) { - return ( - <WidgetStateMessage - message={error instanceof Error ? error.message : copy.failedToLoadIndicators} - /> - ) - } - - if (isLoading && workspaceIndicators.length === 0) { - return ( - <div className='flex h-full w-full items-center justify-center'> - <LoadingAgent size='md' /> - </div> - ) - } - - if (!indicatorId) { - return ( - <WidgetStateMessage - message={ - isLinkedToColorPair - ? normalizedRequestedIndicatorId.length > 0 - ? copy.indicatorNotFound - : copy.noSharedIndicatorSelected - : copy.selectIndicatorToEdit - } - /> - ) + if (indicatorListError && indicatorMembers.length === 0) { + return <WidgetStateMessage message={indicatorListError} /> } - if (!indicator) { + if ( + hasRequestedIndicator && + !isIndicatorListLoading && + !indicatorListError && + !requestedIndicatorMember && + !indicatorId + ) { return <WidgetStateMessage message={copy.indicatorNotFound} /> } @@ -165,7 +123,7 @@ export function EditorIndicatorWidgetBody({ return <WidgetStateMessage message={indicatorSession.error} /> } - if (indicatorSession.isLoading) { + if (isIndicatorListLoading || indicatorSession.isLoading) { return ( <div className='flex h-full w-full items-center justify-center'> <LoadingAgent size='md' /> @@ -173,6 +131,14 @@ export function EditorIndicatorWidgetBody({ ) } + if (!indicatorId) { + return ( + <WidgetStateMessage + message={isLinkedToColorPair ? copy.noSharedIndicatorSelected : copy.selectIndicatorToEdit} + /> + ) + } + return ( <div className='flex h-full w-full flex-col overflow-hidden'> <IndicatorCodePanel diff --git a/apps/tradinggoose/widgets/widgets/editor_indicator/utils.ts b/apps/tradinggoose/widgets/widgets/editor_indicator/utils.ts index ce9f93223..1289640a6 100644 --- a/apps/tradinggoose/widgets/widgets/editor_indicator/utils.ts +++ b/apps/tradinggoose/widgets/widgets/editor_indicator/utils.ts @@ -1,11 +1,6 @@ -import { readEntitySelectionState } from '@/widgets/utils/entity-selection' +import { readEntitySelectionState, resolveEntityId } from '@/widgets/utils/entity-selection' export { readEntitySelectionState } -export const getIndicatorIdFromParams = (params?: Record<string, unknown> | null) => { - const indicatorId = params?.indicatorId - - return typeof indicatorId === 'string' && indicatorId.trim().length > 0 - ? indicatorId.trim() - : null -} +export const getIndicatorIdFromParams = (params?: Record<string, unknown> | null) => + resolveEntityId('indicatorId', { params }) 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 774227d1b..70d28153e 100644 --- a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx @@ -6,16 +6,18 @@ 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 { useEntityList, 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' import type { PairColor } from '@/widgets/pair-colors' import type { WidgetComponentProps } from '@/widgets/types' +import { + resolveEntityIdFromList, + usePersistResolvedEntityId, +} from '@/widgets/utils/entity-selection' import { useMcpEditorActions } from '@/widgets/utils/mcp-editor-actions' import { useMcpSelectionPersistence } from '@/widgets/utils/mcp-selection' import { McpServerForm } from '@/widgets/widgets/_shared/mcp/components/mcp-server-form' @@ -27,39 +29,9 @@ import { import { WidgetStateMessage } from '@/widgets/widgets/editor_indicator/components/widget-state-message' type EditorMcpWidgetBodyProps = WidgetComponentProps +type McpConnectionStatus = 'connected' | 'disconnected' | 'error' -const formatRelativeTime = ( - dateString: string | undefined, - copy: { - justNow: string - minutesAgo: string - hoursAgo: string - daysAgo: string - weeksAgo: string - monthsAgo: string - yearsAgo: string - } -) => { - if (!dateString) return null - const date = new Date(dateString) - const now = new Date() - const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000) - - if (diffInSeconds < 60) return copy.justNow - if (diffInSeconds < 3600) - return formatTemplate(copy.minutesAgo, { count: Math.floor(diffInSeconds / 60) }) - if (diffInSeconds < 86400) - return formatTemplate(copy.hoursAgo, { count: Math.floor(diffInSeconds / 3600) }) - if (diffInSeconds < 604800) - return formatTemplate(copy.daysAgo, { count: Math.floor(diffInSeconds / 86400) }) - if (diffInSeconds < 2592000) - return formatTemplate(copy.weeksAgo, { count: Math.floor(diffInSeconds / 604800) }) - if (diffInSeconds < 31536000) - return formatTemplate(copy.monthsAgo, { count: Math.floor(diffInSeconds / 2592000) }) - return formatTemplate(copy.yearsAgo, { count: Math.floor(diffInSeconds / 31536000) }) -} - -const getStatusClassName = (status?: McpServerWithStatus['connectionStatus']) => { +const getStatusClassName = (status?: McpConnectionStatus) => { if (status === 'connected') { return 'border-green-700 bg-green-500/10 text-green-700' } @@ -72,7 +44,7 @@ const getStatusClassName = (status?: McpServerWithStatus['connectionStatus']) => } const getStatusLabel = ( - status: McpServerWithStatus['connectionStatus'] | undefined, + status: McpConnectionStatus | undefined, copy: { connected: string error: string @@ -168,39 +140,30 @@ export function EditorMcpWidgetBody({ const initialFormDataRef = useRef<McpServerFormData>(createDefaultMcpServerFormData()) const initializedServerIdRef = useRef<string | null>(null) const defaultFormData = useMemo(() => createDefaultMcpServerFormData(), []) - const { - servers, - isLoading: isServersLoading, - error: serverError, - fetchServers, - refreshServer, - } = useMcpServersStore((state) => ({ - servers: state.servers, - isLoading: state.isLoading, - error: state.error, - fetchServers: state.fetchServers, - refreshServer: state.refreshServer, - })) const { refreshTools, getToolsByServer } = useMcpTools(workspaceId ?? '') const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest() + const { + members: serverMembers, + isLoading: isServerListLoading, + error: serverListError, + } = useEntityList('mcp_server', workspaceId) - const selectedServerId = resolveMcpServerId({ + const requestedServerId = resolveMcpServerId({ params, pairContext: isLinkedToColorPair ? pairContext : null, }) - - const workspaceServers = useMemo( - () => - workspaceId - ? servers - .filter((server) => server.workspaceId === workspaceId && !server.deletedAt) - .sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id)) - : [], - [servers, workspaceId] - ) - const selectedServer = selectedServerId - ? (workspaceServers.find((server) => server.id === selectedServerId) ?? null) + const requestedServerMember = requestedServerId + ? serverMembers.find((member) => member.entityId === requestedServerId) : null + const selectedServerId = resolveEntityIdFromList({ + requestedEntityId: requestedServerId, + entityIds: serverMembers.map((member) => member.entityId), + useDefaultEntity: !isLinkedToColorPair, + }) + + const selectedServerStatus = selectedServerId + ? serverMembers.find((member) => member.entityId === selectedServerId)?.connectionStatus + : undefined const selectedServerTools = selectedServerId ? getToolsByServer(selectedServerId) : [] const serverSession = useSavedEntityYjsSession('mcp_server', selectedServerId, workspaceId) const [formDataState, setFormDataState] = useMcpServerYjsFormData( @@ -208,13 +171,13 @@ export function EditorMcpWidgetBody({ defaultFormData ) - useEffect(() => { - if (!workspaceId) return - - fetchServers(workspaceId).catch((fetchError) => { - console.error('Failed to load MCP servers for editor widget', fetchError) - }) - }, [fetchServers, workspaceId]) + usePersistResolvedEntityId({ + entityId: selectedServerId, + entityIdKey: 'mcpServerId', + onWidgetParamsChange, + pairColor: resolvedPairColor, + params, + }) useEffect(() => { if (!selectedServerId || !serverSession.doc) { @@ -287,24 +250,16 @@ export function EditorMcpWidgetBody({ } try { - const refreshResult = await refreshServerApi( - selectedServerId, - workspaceId, - copy.failedToRefreshMcpServer - ) - await refreshServer(workspaceId, selectedServerId, refreshResult?.data) + await refreshServerApi(selectedServerId, workspaceId, copy.failedToRefreshMcpServer) await refreshTools() - await fetchServers(workspaceId) } catch (refreshError) { console.error('Failed to refresh MCP server tools', refreshError) setSaveError(copy.failedToRefreshMcpServer) } }, [ copy.failedToRefreshMcpServer, - fetchServers, formDataState.enabled, formDataState.url, - refreshServer, refreshTools, selectedServerId, workspaceId, @@ -324,7 +279,6 @@ export function EditorMcpWidgetBody({ await serverSession.save() initialFormDataRef.current = formDataState if (formDataState.enabled === false || !formDataState.url?.trim()) { - await fetchServers(workspaceId) await refreshTools() } else { await handleRefreshTools() @@ -336,7 +290,6 @@ export function EditorMcpWidgetBody({ }, [ copy.failedToSaveMcpServer, copy.serverNameRequired, - fetchServers, formDataState, handleRefreshTools, refreshTools, @@ -360,11 +313,21 @@ export function EditorMcpWidgetBody({ return <WidgetStateMessage message={copy.selectWorkspaceToEdit} /> } - if (serverError && workspaceServers.length === 0 && !isServersLoading) { - return <WidgetStateMessage message={serverError} /> + if (serverListError && serverMembers.length === 0) { + return <WidgetStateMessage message={serverListError} /> } - if (isServersLoading && workspaceServers.length === 0) { + if ( + requestedServerId && + !isServerListLoading && + !serverListError && + !requestedServerMember && + !selectedServerId + ) { + return <WidgetStateMessage message={copy.mcpServerNotFound} /> + } + + if (!selectedServerId && isServerListLoading) { return ( <div className='flex h-full w-full items-center justify-center'> <LoadingAgent size='md' /> @@ -380,10 +343,6 @@ export function EditorMcpWidgetBody({ ) } - if (!selectedServer) { - return <WidgetStateMessage message={copy.mcpServerNotFound} /> - } - if (serverSession.error) { return <WidgetStateMessage message={serverSession.error} /> } @@ -396,7 +355,13 @@ export function EditorMcpWidgetBody({ ) } - const displayStatus = selectedServer.connectionStatus ?? 'disconnected' + const displayStatus: McpConnectionStatus = testResult + ? testResult.success + ? 'connected' + : 'error' + : selectedServerStatus === 'connected' || selectedServerStatus === 'error' + ? selectedServerStatus + : 'disconnected' return ( <div className='flex h-full w-full flex-col overflow-hidden'> @@ -416,23 +381,6 @@ export function EditorMcpWidgetBody({ {formDataState.transport.toUpperCase()} </span> </div> - <div className='flex flex-wrap items-center gap-2 text-muted-foreground text-xs'> - {selectedServer.lastToolsRefresh ? ( - <span> - {formatTemplate(copy.toolsRefreshed, { - time: - formatRelativeTime(selectedServer.lastToolsRefresh, copy.relativeTime) ?? '', - })} - </span> - ) : null} - {selectedServer.lastConnected ? ( - <span> - {formatTemplate(copy.lastConnected, { - time: formatRelativeTime(selectedServer.lastConnected, copy.relativeTime) ?? '', - })} - </span> - ) : null} - </div> </div> <McpServerForm @@ -473,13 +421,6 @@ export function EditorMcpWidgetBody({ )} </div> - {selectedServer.lastError ? ( - <div className='rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive text-sm'> - <p className='font-medium'>{copy.lastError}</p> - <p className='text-destructive/80 text-xs'>{selectedServer.lastError}</p> - </div> - ) : null} - {saveError ? ( <div className='rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive text-sm'> {saveError} 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 32c65d558..5892b5e5f 100644 --- a/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx @@ -1,16 +1,22 @@ 'use client' -import { useEffect, useRef } from 'react' +import { useRef } from 'react' import { useMessages } from 'next-intl' import { LoadingAgent } from '@/components/ui/loading-agent' -import { useSavedEntityYjsSession } from '@/lib/yjs/use-entity-fields' -import { useSkills } from '@/hooks/queries/skills' +import { useEntityList, 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 { + resolveEntityIdFromList, + usePersistResolvedEntityId, +} from '@/widgets/utils/entity-selection' import { useSkillEditorActions } from '@/widgets/utils/skill-editor-actions' import { useSkillSelectionPersistence } from '@/widgets/utils/skill-selection' -import { getSkillIdFromParams } from '@/widgets/widgets/_shared/skill/utils' +import { + getSkillIdFromParams, + SKILL_EDITOR_WIDGET_KEY, +} from '@/widgets/widgets/_shared/skill/utils' import { WidgetStateMessage } from '@/widgets/widgets/editor_indicator/components/widget-state-message' import { SkillEditor } from '@/widgets/widgets/editor_skill/skill-editor' @@ -26,7 +32,6 @@ export function EditorSkillWidgetBody({ }: EditorSkillWidgetBodyProps) { const copy = useMessages().workspace.widgets.skillEditor.body const workspaceId = context?.workspaceId ?? null - const { data: skills = [], isLoading, error } = useSkills(workspaceId ?? '') const resolvedPairColor = (pairColor ?? 'gray') as PairColor const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) @@ -37,55 +42,36 @@ export function EditorSkillWidgetBody({ const paramsSkillId = getSkillIdFromParams(params) const requestedSkillId = isLinkedToColorPair ? (pairContext?.skillId ?? null) : paramsSkillId const normalizedRequestedSkillId = requestedSkillId?.trim() ?? '' - const hasRequestedSkill = - normalizedRequestedSkillId.length > 0 && - skills.some((skill) => skill.id === normalizedRequestedSkillId) - const skillId = hasRequestedSkill - ? normalizedRequestedSkillId - : isLinkedToColorPair - ? null - : (skills[0]?.id ?? null) - const skill = skillId ? (skills.find((candidate) => candidate.id === skillId) ?? null) : null + const hasRequestedSkill = normalizedRequestedSkillId.length > 0 + const { + members: skillMembers, + isLoading: isSkillListLoading, + error: skillListError, + } = useEntityList('skill', workspaceId) + const requestedSkillMember = hasRequestedSkill + ? skillMembers.find((member) => member.entityId === normalizedRequestedSkillId) + : null + const skillId = resolveEntityIdFromList({ + requestedEntityId: requestedSkillId, + entityIds: skillMembers.map((member) => member.entityId), + useDefaultEntity: !isLinkedToColorPair, + }) const skillSession = useSavedEntityYjsSession('skill', skillId, workspaceId) - useEffect(() => { - if (!skillId) { - return - } - - if (isLinkedToColorPair) { - if (pairContext?.skillId === skillId) { - return - } - - setPairContext(resolvedPairColor, { skillId }) - return - } - - if (!onWidgetParamsChange || paramsSkillId === skillId) { - return - } - - onWidgetParamsChange({ - ...(params ?? {}), - skillId, - }) - }, [ - isLinkedToColorPair, + usePersistResolvedEntityId({ + entityId: skillId, + entityIdKey: 'skillId', onWidgetParamsChange, - pairContext?.skillId, + pairColor: resolvedPairColor, params, - paramsSkillId, - resolvedPairColor, - setPairContext, - skillId, - ]) + }) useSkillSelectionPersistence({ onWidgetParamsChange, panelId, params, pairColor: resolvedPairColor, + scopeKey: SKILL_EDITOR_WIDGET_KEY, onSkillSelect: (nextSkillId) => { if (!isLinkedToColorPair) return if (pairContext?.skillId === nextSkillId) return @@ -104,37 +90,17 @@ export function EditorSkillWidgetBody({ return <WidgetStateMessage message={copy.selectWorkspace} /> } - if (error && skills.length === 0) { - return ( - <WidgetStateMessage - message={error instanceof Error ? error.message : copy.failedToLoadSkills} - /> - ) - } - - if (isLoading && skills.length === 0) { - return ( - <div className='flex h-full w-full items-center justify-center'> - <LoadingAgent size='md' /> - </div> - ) - } - - if (!skillId) { - return ( - <WidgetStateMessage - message={ - isLinkedToColorPair - ? normalizedRequestedSkillId.length > 0 - ? copy.skillNotFound - : copy.noSharedSkillSelected - : copy.selectSkillToEdit - } - /> - ) + if (skillListError && skillMembers.length === 0) { + return <WidgetStateMessage message={skillListError} /> } - if (!skill) { + if ( + hasRequestedSkill && + !isSkillListLoading && + !skillListError && + !requestedSkillMember && + !skillId + ) { return <WidgetStateMessage message={copy.skillNotFound} /> } @@ -142,7 +108,7 @@ export function EditorSkillWidgetBody({ return <WidgetStateMessage message={skillSession.error} /> } - if (skillSession.isLoading) { + if (isSkillListLoading || skillSession.isLoading) { return ( <div className='flex h-full w-full items-center justify-center'> <LoadingAgent size='md' /> @@ -150,6 +116,14 @@ export function EditorSkillWidgetBody({ ) } + if (!skillId) { + return ( + <WidgetStateMessage + message={isLinkedToColorPair ? copy.noSharedSkillSelected : copy.selectSkillToEdit} + /> + ) + } + return ( <div className='flex h-full w-full flex-col overflow-hidden'> <SkillEditor 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 d960a0107..563a303f5 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 @@ -102,46 +102,29 @@ export async function applyAutoLayoutToWorkflow( interface ApplyAutoLayoutParams { workflowId: string - channelId?: string options?: AutoLayoutOptions } export async function applyAutoLayoutToActiveWorkflow({ workflowId, - channelId, options = {}, }: ApplyAutoLayoutParams): Promise<{ success: boolean error?: string }> { - let resolvedWorkflowId: string | undefined = workflowId - try { const { getRegisteredWorkflowSession } = await import('@/lib/yjs/workflow-session-registry') const { readWorkflowSnapshot } = await import('@/lib/yjs/workflow-session') - const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') - - const registryState = useWorkflowRegistry.getState() - const activeWorkflowIdForChannel = registryState.getActiveWorkflowId(channelId) - resolvedWorkflowId = workflowId ?? activeWorkflowIdForChannel - if (!resolvedWorkflowId) { - logger.error('Auto layout aborted: no active workflow for channel', { channelId }) + if (!workflowId) { + logger.error('Auto layout aborted: no workflow selected') return { success: false, error: 'No workflow selected' } } - if (workflowId && workflowId !== activeWorkflowIdForChannel) { - logger.warn('Auto layout workflow mismatch detected, correcting', { - requestedWorkflowId: workflowId, - activeWorkflowIdForChannel, - channelId, - }) - } - - const session = getRegisteredWorkflowSession(resolvedWorkflowId) + const session = getRegisteredWorkflowSession(workflowId) if (!session?.doc) { logger.error('Auto layout aborted: no Yjs session for workflow', { - workflowId: resolvedWorkflowId, + workflowId, }) return { success: false, error: 'No active workflow session' } } @@ -151,19 +134,19 @@ export async function applyAutoLayoutToActiveWorkflow({ const hasLockedBlocks = Object.values(blocks).some((block) => Boolean(block.locked)) logger.info('Auto layout store data:', { - workflowId: resolvedWorkflowId, + workflowId, blockCount: Object.keys(blocks).length, edgeCount: edges.length, }) if (Object.keys(blocks).length === 0) { - logger.warn('No blocks to layout', { workflowId: resolvedWorkflowId }) + logger.warn('No blocks to layout', { workflowId }) return { success: false, error: 'No blocks to layout' } } if (hasLockedBlocks) { logger.info('Auto layout skipped: workflow contains locked blocks', { - workflowId: resolvedWorkflowId, + workflowId, }) return { success: false, @@ -171,21 +154,20 @@ export async function applyAutoLayoutToActiveWorkflow({ } } - const result = await applyAutoLayoutToWorkflow(resolvedWorkflowId, blocks, edges, options) + const result = await applyAutoLayoutToWorkflow(workflowId, blocks, edges, options) if (!result.success) { return { success: false, error: result.error } } logger.info('Successfully applied durable auto layout', { - workflowId: resolvedWorkflowId, - channelId, + workflowId, }) 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:', { - workflowId: resolvedWorkflowId ?? workflowId, + workflowId, error: errorMessage, }) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deploy-modal/deploy-modal.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deploy-modal/deploy-modal.tsx index c8d0ace73..b3ebb9af2 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deploy-modal/deploy-modal.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deploy-modal/deploy-modal.tsx @@ -60,7 +60,6 @@ import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' import { useWorkflowEditorActions } from '@/hooks/workflow/use-workflow-editor-actions' import { formatTemplate } from '@/i18n/utils' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { getTrigger, isNativeTrigger } from '@/triggers' @@ -89,11 +88,13 @@ interface DeployModalProps { open: boolean onOpenChange: (open: boolean) => void workflowId: string | null + isDeployed: boolean needsRedeployment: boolean setNeedsRedeployment: (value: boolean) => void deployedState: WorkflowState | null isLoadingDeployedState: boolean refetchDeployedState: () => Promise<void> + refetchDeploymentStatus: () => Promise<boolean> } interface ApiKey { @@ -239,21 +240,19 @@ export function DeployModal({ open, onOpenChange, workflowId, + isDeployed, needsRedeployment, setNeedsRedeployment, deployedState, isLoadingDeployedState, refetchDeployedState, + refetchDeploymentStatus, }: DeployModalProps) { const copy = useDeploymentCopy() const workflowEditorCopy = useWorkflowEditorCopy() - const { getLocalizedDefaultBlockName, workflowInspectorCopy } = useWorkflowI18n() + const { workflowInspectorCopy } = useWorkflowI18n() const workspaceId = useWorkspaceId() const userPermissions = useUserPermissionsContext() - const deploymentStatus = useWorkflowRegistry((state) => - state.readWorkflowDeploymentStatus(workflowId) - ) - const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus) const currentBlocks = useWorkflowBlocks() const { collaborativeToggleBlockAdvancedMode } = useWorkflowEditorActions() const [isSubmitting, setIsSubmitting] = useState(false) @@ -289,9 +288,7 @@ export function DeployModal({ ? document.getElementById(overlayRootId) : null const isWorkflowDeployed = - Boolean(deploymentStatus?.isDeployed) || - Boolean(deploymentInfo?.isDeployed) || - Boolean(deployedState) + isDeployed || Boolean(deploymentInfo?.isDeployed) || Boolean(deployedState) const mergedBlocks = workflowId ? mergeSubblockState(currentBlocks, workflowId) : currentBlocks const blockList = Object.values(mergedBlocks) const shouldDisableTriggerWrite = !userPermissions.canEdit @@ -332,7 +329,7 @@ export function DeployModal({ return { key: `trigger-${block.id}`, blockId: block.id, - label: getLocalizedDefaultBlockName(block.type, block.name), + label: block.name, triggerId, icon: triggerDef?.icon ?? @@ -824,6 +821,7 @@ export function DeployModal({ const data = await response.json() if (!data.isDeployed) { + setNeedsRedeployment(false) setDeploymentInfo(null) return } @@ -838,9 +836,10 @@ export function DeployModal({ pinnedApiKeyId: data.pinnedApiKeyId ?? null, endpoint, exampleCommand: `curl -X POST -H "X-API-Key: ${data.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`, - needsRedeployment, + needsRedeployment: Boolean(data.needsRedeployment), hasReusableApiKey: Boolean(data.hasReusableApiKey), }) + setNeedsRedeployment(Boolean(data.needsRedeployment)) } catch (error) { logger.error('Error fetching deployment info:', { error }) } finally { @@ -882,13 +881,8 @@ export function DeployModal({ const responseData = await response.json() - const isActivating = versionToActivate !== null - const isDeployedStatus = isActivating ? true : (responseData.isDeployed ?? false) - const deployedAtTime = responseData.deployedAt ? new Date(responseData.deployedAt) : undefined const apiKeyFromResponse = responseData.apiKey || normalizedApiKey || '' - setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, apiKeyFromResponse) - const matchingKey = apiKeys.find( (k) => k.key === apiKeyFromResponse || k.id === normalizedApiKey ) @@ -898,10 +892,7 @@ export function DeployModal({ const isActivatingVersion = versionToActivate !== null setNeedsRedeployment(isActivatingVersion) - if (workflowId) { - useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, isActivatingVersion) - } - + await refetchDeploymentStatus() await refetchDeployedState() await fetchVersions() @@ -918,9 +909,10 @@ export function DeployModal({ pinnedApiKeyId: deploymentData.pinnedApiKeyId ?? null, endpoint: apiEndpoint, exampleCommand: `curl -X POST -H "X-API-Key: ${deploymentData.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`, - needsRedeployment: isActivatingVersion, + needsRedeployment: Boolean(deploymentData.needsRedeployment), hasReusableApiKey: Boolean(deploymentData.hasReusableApiKey), }) + setNeedsRedeployment(Boolean(deploymentData.needsRedeployment)) } setVersionToActivate(null) @@ -1040,7 +1032,10 @@ export function DeployModal({ throw new Error(errorData.error || 'Failed to undeploy workflow') } - setDeploymentStatus(workflowId, false) + setNeedsRedeployment(false) + await refetchDeploymentStatus() + await refetchDeployedState() + setDeploymentInfo(null) setPublishedChat(null) onOpenChange(false) } catch (error: unknown) { @@ -1068,20 +1063,10 @@ export function DeployModal({ throw new Error(errorData.error || 'Failed to redeploy workflow') } - const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json() - - setDeploymentStatus( - workflowId, - newDeployStatus, - deployedAt ? new Date(deployedAt) : undefined, - apiKey - ) + await response.json() setNeedsRedeployment(false) - if (workflowId) { - useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false) - } - + await refetchDeploymentStatus() await refetchDeployedState() await fetchVersions() diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deployment-controls/deployment-controls.test.ts b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deployment-controls/deployment-controls.test.ts index 308880a49..4982602c8 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deployment-controls/deployment-controls.test.ts +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deployment-controls/deployment-controls.test.ts @@ -1,211 +1,33 @@ -/** - * Deployment Controls Change Detection Logic Tests - * - * This file tests the core logic of how DeploymentControls handles change detection, - * specifically focusing on the needsRedeployment prop handling and state management. - */ +import { describe, expect, it } from 'vitest' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +describe('DeploymentControls status logic', () => { + it('uses the parent redeployment state directly', () => { + let needsRedeployment = false + expect(needsRedeployment).toBe(false) -const mockDeploymentStatus = { - isDeployed: false, - needsRedeployment: false, -} + needsRedeployment = true + expect(needsRedeployment).toBe(true) -const mockWorkflowRegistry = { - getState: vi.fn(() => ({ - readWorkflowDeploymentStatus: vi.fn((workflowId) => mockDeploymentStatus), - })), -} - -vi.mock('@/stores/workflows/registry/store', () => ({ - useWorkflowRegistry: vi.fn((selector) => { - if (typeof selector === 'function') { - return selector(mockWorkflowRegistry.getState()) - } - return mockWorkflowRegistry.getState() - }), -})) - -describe('DeploymentControls Change Detection Logic', () => { - beforeEach(() => { - vi.clearAllMocks() - mockDeploymentStatus.isDeployed = false - mockDeploymentStatus.needsRedeployment = false - }) - - afterEach(() => { - vi.resetAllMocks() - }) - - describe('needsRedeployment Priority Logic', () => { - it('should prioritize parent needsRedeployment over workflow registry', () => { - const parentNeedsRedeployment = true - const workflowRegistryNeedsRedeployment = false - - const workflowNeedsRedeployment = parentNeedsRedeployment - - expect(workflowNeedsRedeployment).toBe(true) - expect(workflowNeedsRedeployment).not.toBe(workflowRegistryNeedsRedeployment) - }) - - it('should handle false needsRedeployment correctly', () => { - const parentNeedsRedeployment = false - const workflowNeedsRedeployment = parentNeedsRedeployment - - expect(workflowNeedsRedeployment).toBe(false) - }) - - it('should maintain consistency with parent state changes', () => { - let parentNeedsRedeployment = false - let workflowNeedsRedeployment = parentNeedsRedeployment - - expect(workflowNeedsRedeployment).toBe(false) - - parentNeedsRedeployment = true - workflowNeedsRedeployment = parentNeedsRedeployment - - expect(workflowNeedsRedeployment).toBe(true) - - parentNeedsRedeployment = false - workflowNeedsRedeployment = parentNeedsRedeployment - - expect(workflowNeedsRedeployment).toBe(false) - }) - }) - - describe('Deployment Status Integration', () => { - it('should handle deployment status correctly', () => { - mockDeploymentStatus.isDeployed = true - mockDeploymentStatus.needsRedeployment = false - - const deploymentStatus = mockWorkflowRegistry - .getState() - .readWorkflowDeploymentStatus('test-id') - - expect(deploymentStatus.isDeployed).toBe(true) - expect(deploymentStatus.needsRedeployment).toBe(false) - }) - - it('should handle missing deployment status', () => { - const tempMockRegistry = { - getState: vi.fn(() => ({ - readWorkflowDeploymentStatus: vi.fn(() => null), - })), - } - - // Temporarily replace the mock - const originalMock = mockWorkflowRegistry.getState - mockWorkflowRegistry.getState = tempMockRegistry.getState as any - - const deploymentStatus = mockWorkflowRegistry - .getState() - .readWorkflowDeploymentStatus('test-id') - - expect(deploymentStatus).toBe(null) - - mockWorkflowRegistry.getState = originalMock - }) - - it('should handle undefined deployment status properties', () => { - mockWorkflowRegistry.getState = vi.fn(() => ({ - readWorkflowDeploymentStatus: vi.fn(() => ({})), - })) as any - - const deploymentStatus = mockWorkflowRegistry - .getState() - .readWorkflowDeploymentStatus('test-id') - - const isDeployed = deploymentStatus?.isDeployed || false - expect(isDeployed).toBe(false) - }) - }) - - describe('Change Detection Scenarios', () => { - it('should handle the redeployment cycle correctly', () => { - // Scenario 1: Initial state - deployed, no changes - mockDeploymentStatus.isDeployed = true - let parentNeedsRedeployment = false - let shouldShowIndicator = mockDeploymentStatus.isDeployed && parentNeedsRedeployment - - expect(shouldShowIndicator).toBe(false) - - // Scenario 2: User makes changes - parentNeedsRedeployment = true - shouldShowIndicator = mockDeploymentStatus.isDeployed && parentNeedsRedeployment - - expect(shouldShowIndicator).toBe(true) - - // Scenario 3: User redeploys - parentNeedsRedeployment = false // Reset after redeployment - shouldShowIndicator = mockDeploymentStatus.isDeployed && parentNeedsRedeployment - - expect(shouldShowIndicator).toBe(false) - }) - - it('should not show indicator when workflow is not deployed', () => { - mockDeploymentStatus.isDeployed = false - const parentNeedsRedeployment = true - const shouldShowIndicator = mockDeploymentStatus.isDeployed && parentNeedsRedeployment - - expect(shouldShowIndicator).toBe(false) - }) - - it('should show correct tooltip messages based on state', () => { - const getTooltipMessage = (isDeployed: boolean, needsRedeployment: boolean) => { - if (isDeployed && needsRedeployment) { - return 'Workflow changes detected' - } - if (isDeployed) { - return 'Deployment Settings' - } - return 'Deploy as API' - } - - // Not deployed - expect(getTooltipMessage(false, false)).toBe('Deploy as API') - expect(getTooltipMessage(false, true)).toBe('Deploy as API') - - // Deployed, no changes - expect(getTooltipMessage(true, false)).toBe('Deployment Settings') - - // Deployed, changes detected - expect(getTooltipMessage(true, true)).toBe('Workflow changes detected') - }) + needsRedeployment = false + expect(needsRedeployment).toBe(false) }) - describe('Error Handling', () => { - it('should handle null activeWorkflowId gracefully', () => { - const deploymentStatus = mockWorkflowRegistry.getState().readWorkflowDeploymentStatus(null) - - expect(deploymentStatus).toBeDefined() - }) + it('shows the previous-version indicator only for deployed workflows with changes', () => { + expect(false && true).toBe(false) + expect(true && false).toBe(false) + expect(true && true).toBe(true) }) - describe('Props Integration', () => { - it('should correctly pass needsRedeployment to child components', () => { - const parentNeedsRedeployment = true - const propsToModal = { - needsRedeployment: parentNeedsRedeployment, - workflowId: 'test-id', - } - - expect(propsToModal.needsRedeployment).toBe(true) - }) - - it('should maintain prop consistency across re-renders', () => { - let needsRedeployment = false - - let componentProps = { needsRedeployment } - expect(componentProps.needsRedeployment).toBe(false) - - needsRedeployment = true - componentProps = { needsRedeployment } - expect(componentProps.needsRedeployment).toBe(true) + it('uses the deployment status supplied by the control-bar owner', () => { + const getTooltipMessage = (isDeployed: boolean, needsRedeployment: boolean) => { + if (isDeployed && needsRedeployment) return 'Workflow changes detected' + if (isDeployed) return 'Deployment Settings' + return 'Deploy as API' + } - needsRedeployment = false - componentProps = { needsRedeployment } - expect(componentProps.needsRedeployment).toBe(false) - }) + expect(getTooltipMessage(false, false)).toBe('Deploy as API') + expect(getTooltipMessage(false, true)).toBe('Deploy as API') + expect(getTooltipMessage(true, false)).toBe('Deployment Settings') + expect(getTooltipMessage(true, true)).toBe('Workflow changes detected') }) }) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deployment-controls/deployment-controls.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deployment-controls/deployment-controls.tsx index b9dcf7ee9..114773688 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deployment-controls/deployment-controls.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/deployment-controls/deployment-controls.tsx @@ -6,40 +6,39 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { widgetHeaderIconButtonClassName } from '@/components/widget-header-control' import { cn } from '@/lib/utils' -import { DeployModal } from '@/widgets/widgets/editor_workflow/components/control-bar/components' -import { useDeploymentCopy } from '@/widgets/widgets/editor_workflow/copy' import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' +import { DeployModal } from '@/widgets/widgets/editor_workflow/components/control-bar/components' +import { useDeploymentCopy } from '@/widgets/widgets/editor_workflow/copy' type ControlVariant = 'workspace' | 'widget' interface DeploymentControlsProps { activeWorkflowId: string | null + isDeployed: boolean needsRedeployment: boolean setNeedsRedeployment: (value: boolean) => void deployedState: WorkflowState | null isLoadingDeployedState: boolean refetchDeployedState: () => Promise<void> + refetchDeploymentStatus: () => Promise<boolean> userPermissions: WorkspaceUserPermissions variant?: ControlVariant } export function DeploymentControls({ activeWorkflowId, + isDeployed, needsRedeployment, setNeedsRedeployment, deployedState, isLoadingDeployedState, refetchDeployedState, + refetchDeploymentStatus, userPermissions, variant = 'workspace', }: DeploymentControlsProps) { const copy = useDeploymentCopy() - const deploymentStatus = useWorkflowRegistry((state) => - state.readWorkflowDeploymentStatus(activeWorkflowId) - ) - const isDeployed = deploymentStatus?.isDeployed || false const workflowNeedsRedeployment = needsRedeployment const isPreviousVersionActive = isDeployed && workflowNeedsRedeployment @@ -123,9 +122,9 @@ export function DeploymentControls({ {isDeployed && workflowNeedsRedeployment && ( <div className='pointer-events-none absolute right-1 bottom-1 flex items-center justify-center'> <div className='relative'> - <div className='absolute inset-0 h-[6px] w-[6px] animate-ping rounded-full bg-yellow-500/50' /> - <div className='zoom-in fade-in relative h-[6px] w-[6px] animate-in rounded-full bg-yellow-500/80 duration-300' /> - </div> + <div className='absolute inset-0 h-[6px] w-[6px] animate-ping rounded-full bg-yellow-500/50' /> + <div className='zoom-in fade-in relative h-[6px] w-[6px] animate-in rounded-full bg-yellow-500/80 duration-300' /> + </div> <span className='sr-only'>{copy.needsRedeployment}</span> </div> )} @@ -138,11 +137,13 @@ export function DeploymentControls({ open={isModalOpen} onOpenChange={setIsModalOpen} workflowId={activeWorkflowId} + isDeployed={isDeployed} needsRedeployment={workflowNeedsRedeployment} setNeedsRedeployment={setNeedsRedeployment} deployedState={deployedState as WorkflowState} isLoadingDeployedState={isLoadingDeployedState} refetchDeployedState={refetchWithErrorHandling} + refetchDeploymentStatus={refetchDeploymentStatus} /> </> ) 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 5e16b0a8b..727db4d58 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,10 +6,10 @@ 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 { useEntityList } from '@/lib/yjs/use-entity-fields' import { useWorkflowJsonStore } from '@/stores/workflows/json/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useWorkflowEditorCopy } from '@/widgets/widgets/editor_workflow/copy' import { useWorkflowRoute } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' +import { useWorkflowEditorCopy } from '@/widgets/widgets/editor_workflow/copy' const logger = createLogger('ExportControls') @@ -23,11 +23,11 @@ interface ExportControlsProps { export function ExportControls({ disabled = false, variant = 'workspace' }: ExportControlsProps) { const copy = useWorkflowEditorCopy() const [isExporting, setIsExporting] = useState(false) - const { workflows } = useWorkflowRegistry() - const { workflowId, channelId } = useWorkflowRoute() + const { workspaceId, workflowId } = useWorkflowRoute() + const { members } = useEntityList('workflow', workspaceId) const { getJson: readWorkflowExportJson } = useWorkflowJsonStore() - const currentWorkflow = workflowId ? workflows[workflowId] : null + const currentWorkflow = members.find((member) => member.entityId === workflowId) ?? null const downloadFile = (content: string, filename: string, mimeType: string) => { try { @@ -55,14 +55,16 @@ export function ExportControls({ disabled = false, variant = 'workspace' }: Expo try { const jsonContent = await readWorkflowExportJson({ workflowId, - channelId, + name: currentWorkflow.entityName, + description: currentWorkflow.entityDescription, + workspaceId, }) if (!jsonContent) { throw new Error('Failed to generate JSON') } - const filename = `${currentWorkflow.name.replace(/[^a-z0-9]/gi, '-')}.json` + const filename = `${currentWorkflow.entityName.replace(/[^a-z0-9]/gi, '-')}.json` downloadFile(jsonContent, filename, 'application/json') logger.info('Workflow exported as JSON') } catch (error) { diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/index.ts b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/index.ts index ae7a07da3..8e2999a06 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/index.ts +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/index.ts @@ -1,6 +1,5 @@ export { DeployModal } from './deploy-modal/deploy-modal' export { DeploymentControls } from './deployment-controls/deployment-controls' export { ExportControls } from './export-controls/export-controls' -export { TemplateModal } from './template-modal/template-modal' export { UserAvatarStack } from './user-avatar-stack/user-avatar-stack' export { WebhookSettings } from './webhook-settings/webhook-settings' diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/template-modal/template-modal.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/template-modal/template-modal.tsx deleted file mode 100644 index aad9afa0c..000000000 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/template-modal/template-modal.tsx +++ /dev/null @@ -1,811 +0,0 @@ -'use client' - -import { useEffect, useMemo, useState } from 'react' -import { zodResolver } from '@hookform/resolvers/zod' -import { - Award, - BarChart3, - Bell, - BookOpen, - Bot, - Brain, - Briefcase, - Calculator, - Cloud, - Code, - Cpu, - CreditCard, - Database, - DollarSign, - Edit, - Eye, - FileText, - Folder, - Globe, - HeadphonesIcon, - Layers, - Lightbulb, - LineChart, - Loader2, - Mail, - Megaphone, - MessageSquare, - NotebookPen, - Phone, - Play, - Search, - Server, - Settings, - ShoppingCart, - Star, - Target, - TrendingUp, - User, - Users, - Workflow, - Wrench, - X, - Zap, -} from 'lucide-react' -import { useForm } from 'react-hook-form' -import { z } from 'zod' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' -import { Button } from '@/components/ui/button' -import { ColorPicker } from '@/components/ui/color-picker' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form' -import { Input } from '@/components/ui/input' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { Skeleton } from '@/components/ui/skeleton' -import { Textarea } from '@/components/ui/textarea' -import { useSession } from '@/lib/auth-client' -import { createLogger } from '@/lib/logs/console/logger' -import { cn } from '@/lib/utils' -import { buildWorkflowStateForTemplate } from '@/lib/workflows/state-builder' -import { categories } from '@/app/workspace/[workspaceId]/templates/templates' -import type { Messages } from 'next-intl' -import { useWorkspaceBlockEditorCopy } from '@/widgets/widgets/editor_workflow/copy' - -const logger = createLogger('TemplateModal') - -type TemplateFormData = { - name: string - description: string - author: string - category: string - icon: string - color: string -} - -interface TemplateModalProps { - open: boolean - onOpenChange: (open: boolean) => void - workflowId: string -} - -const icons = [ - // Content & Documentation - { value: 'FileText', component: FileText }, - { value: 'NotebookPen', component: NotebookPen }, - { value: 'BookOpen', component: BookOpen }, - { value: 'Edit', component: Edit }, - - // Analytics & Charts - { value: 'BarChart3', component: BarChart3 }, - { value: 'LineChart', component: LineChart }, - { value: 'TrendingUp', component: TrendingUp }, - { value: 'Target', component: Target }, - - // Database & Storage - { value: 'Database', component: Database }, - { value: 'Server', component: Server }, - { value: 'Cloud', component: Cloud }, - { value: 'Folder', component: Folder }, - - // Marketing & Communication - { value: 'Megaphone', component: Megaphone }, - { value: 'Mail', component: Mail }, - { value: 'MessageSquare', component: MessageSquare }, - { value: 'Phone', component: Phone }, - { value: 'Bell', component: Bell }, - - // Sales & Finance - { value: 'DollarSign', component: DollarSign }, - { value: 'CreditCard', component: CreditCard }, - { value: 'Calculator', component: Calculator }, - { value: 'ShoppingCart', component: ShoppingCart }, - { value: 'Briefcase', component: Briefcase }, - - // Support & Service - { value: 'HeadphonesIcon', component: HeadphonesIcon }, - { value: 'User', component: User }, - { value: 'Users', component: Users }, - { value: 'Settings', component: Settings }, - { value: 'Wrench', component: Wrench }, - - // AI & Technology - { value: 'Bot', component: Bot }, - { value: 'Brain', component: Brain }, - { value: 'Cpu', component: Cpu }, - { value: 'Code', component: Code }, - { value: 'Zap', component: Zap }, - - // Workflow & Process - { value: 'Workflow', component: Workflow }, - { value: 'Search', component: Search }, - { value: 'Play', component: Play }, - { value: 'Layers', component: Layers }, - - // General - { value: 'Lightbulb', component: Lightbulb }, - { value: 'Star', component: Star }, - { value: 'Globe', component: Globe }, - { value: 'Award', component: Award }, -] - -function getTemplateCategoryLabel( - category: (typeof categories)[number]['value'], - copy: Messages['workspace']['widgets']['blockEditor']['templateModal'] -) { - switch (category) { - case 'marketing': - return copy.categories.marketing - case 'sales': - return copy.categories.sales - case 'finance': - return copy.categories.finance - case 'support': - return copy.categories.support - case 'artificial-intelligence': - return copy.categories.artificialIntelligence - case 'other': - return copy.categories.other - } -} - -function getTemplateIconLabel( - icon: (typeof icons)[number]['value'], - copy: Messages['workspace']['widgets']['blockEditor']['templateModal'] -) { - switch (icon) { - case 'FileText': - return copy.icons.fileText - case 'NotebookPen': - return copy.icons.notebook - case 'BookOpen': - return copy.icons.book - case 'Edit': - return copy.icons.edit - case 'BarChart3': - return copy.icons.barChart - case 'LineChart': - return copy.icons.lineChart - case 'TrendingUp': - return copy.icons.trendingUp - case 'Target': - return copy.icons.target - case 'Database': - return copy.icons.database - case 'Server': - return copy.icons.server - case 'Cloud': - return copy.icons.cloud - case 'Folder': - return copy.icons.folder - case 'Megaphone': - return copy.icons.megaphone - case 'Mail': - return copy.icons.mail - case 'MessageSquare': - return copy.icons.message - case 'Phone': - return copy.icons.phone - case 'Bell': - return copy.icons.bell - case 'DollarSign': - return copy.icons.dollarSign - case 'CreditCard': - return copy.icons.creditCard - case 'Calculator': - return copy.icons.calculator - case 'ShoppingCart': - return copy.icons.shoppingCart - case 'Briefcase': - return copy.icons.briefcase - case 'HeadphonesIcon': - return copy.icons.headphones - case 'User': - return copy.icons.user - case 'Users': - return copy.icons.users - case 'Settings': - return copy.icons.settings - case 'Wrench': - return copy.icons.wrench - case 'Bot': - return copy.icons.bot - case 'Brain': - return copy.icons.brain - case 'Cpu': - return copy.icons.cpu - case 'Code': - return copy.icons.code - case 'Zap': - return copy.icons.zap - case 'Workflow': - return copy.icons.workflow - case 'Search': - return copy.icons.search - case 'Play': - return copy.icons.play - case 'Layers': - return copy.icons.layers - case 'Lightbulb': - return copy.icons.lightbulb - case 'Star': - return copy.icons.star - case 'Globe': - return copy.icons.globe - case 'Award': - return copy.icons.award - } -} - -export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalProps) { - const copy = useWorkspaceBlockEditorCopy().templateModal - const { data: session } = useSession() - const [isSubmitting, setIsSubmitting] = useState(false) - const [iconPopoverOpen, setIconPopoverOpen] = useState(false) - const [existingTemplate, setExistingTemplate] = useState<any>(null) - const [isLoadingTemplate, setIsLoadingTemplate] = useState(false) - const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [isDeleting, setIsDeleting] = useState(false) - const templateSchema = useMemo( - () => - z.object({ - name: z - .string() - .min(1, copy.validation.nameRequired) - .max(100, copy.validation.nameTooLong), - description: z - .string() - .min(1, copy.validation.descriptionRequired) - .max(500, copy.validation.descriptionTooLong), - author: z - .string() - .min(1, copy.validation.authorRequired) - .max(100, copy.validation.authorTooLong), - category: z.string().min(1, copy.validation.categoryRequired), - icon: z.string().min(1, copy.validation.iconRequired), - color: z.string().regex(/^#[0-9A-F]{6}$/i, copy.validation.colorInvalid), - }), - [copy.validation] - ) - - const form = useForm<TemplateFormData>({ - resolver: zodResolver(templateSchema), - defaultValues: { - name: '', - description: '', - author: session?.user?.name || session?.user?.email || '', - category: '', - icon: 'FileText', - color: '#3972F6', - }, - }) - - // Watch form state to determine if all required fields are valid - const formValues = form.watch() - const isFormValid = - form.formState.isValid && - formValues.name?.trim() && - formValues.description?.trim() && - formValues.author?.trim() && - formValues.category - - // Check for existing template when modal opens - useEffect(() => { - if (open && workflowId) { - checkExistingTemplate() - } - }, [open, workflowId]) - - const checkExistingTemplate = async () => { - setIsLoadingTemplate(true) - try { - const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`) - if (response.ok) { - const result = await response.json() - const template = result.data?.[0] || null - setExistingTemplate(template) - - // Pre-fill form with existing template data - if (template) { - form.reset({ - name: template.name, - description: template.description, - author: template.author, - category: template.category, - icon: template.icon, - color: template.color, - }) - } else { - // No existing template found - setExistingTemplate(null) - // Reset form to defaults - form.reset({ - name: '', - description: '', - author: session?.user?.name || session?.user?.email || '', - category: '', - icon: 'FileText', - color: '#3972F6', - }) - } - } - } catch (error) { - logger.error('Error checking existing template:', error) - setExistingTemplate(null) - } finally { - setIsLoadingTemplate(false) - } - } - - const onSubmit = async (data: TemplateFormData) => { - if (!session?.user) { - logger.error('User not authenticated') - return - } - - form.clearErrors('root') - setIsSubmitting(true) - - try { - // Create the template state from current workflow using the same format as deployment - const templateState = buildWorkflowStateForTemplate(workflowId) - if (!templateState) { - form.setError('root', { - type: 'manual', - message: copy.errors.workflowNotReady, - }) - return - } - - const templateData = { - workflowId, - name: data.name, - description: data.description || '', - author: data.author, - category: data.category, - icon: data.icon, - color: data.color, - state: templateState, - } - - let response - if (existingTemplate) { - // Update existing template - response = await fetch(`/api/templates/${existingTemplate.id}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(templateData), - }) - } else { - // Create new template - response = await fetch('/api/templates', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(templateData), - }) - } - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || (existingTemplate ? copy.errors.update : copy.errors.create)) - } - - const result = await response.json() - logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully:`, result) - - // Reset form and close modal - form.reset() - onOpenChange(false) - - // TODO: Show success toast/notification - } catch (error) { - logger.error('Failed to create template:', error) - // TODO: Show error toast/notification - } finally { - setIsSubmitting(false) - } - } - - const SelectedIconComponent = - icons.find((icon) => icon.value === form.watch('icon'))?.component || FileText - const selectedIconLabel = getTemplateIconLabel(form.watch('icon'), copy) - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent - className='flex h-[70vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]' - hideCloseButton - > - <DialogHeader className='flex-shrink-0 border-b px-6 py-4'> - <div className='flex items-center justify-between'> - <div className='flex items-center gap-3'> - <DialogTitle className='font-medium text-lg'> - {isLoadingTemplate - ? copy.title.loading - : existingTemplate - ? copy.title.update - : copy.title.publish} - </DialogTitle> - {existingTemplate && ( - <div className='flex items-center gap-1'> - {existingTemplate.stars > 0 && ( - <div className='flex items-center gap-1 rounded-full bg-yellow-50 px-2 py-1 dark:bg-yellow-900/20'> - <Star className='h-3 w-3 fill-yellow-400 text-yellow-400' /> - <span className='font-medium text-xs text-yellow-700 dark:text-yellow-300'> - {existingTemplate.stars} - </span> - </div> - )} - {existingTemplate.views > 0 && ( - <div className='flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 dark:bg-blue-900/20'> - <Eye className='h-3 w-3 text-blue-500' /> - <span className='font-medium text-blue-700 text-xs dark:text-blue-300'> - {existingTemplate.views} - </span> - </div> - )} - </div> - )} - </div> - <Button - variant='ghost' - size='icon' - className={cn( - 'h-8 w-8 rounded-md p-0 text-muted-foreground/70 transition-all duration-200', - 'hover:scale-105 hover:bg-card/50 hover:text-foreground', - 'active:scale-95', - 'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1' - )} - onClick={() => onOpenChange(false)} - > - <X className='h-4 w-4' /> - <span className='sr-only'>{copy.actions.close}</span> - </Button> - </div> - </DialogHeader> - - <Form {...form}> - <form - onSubmit={form.handleSubmit(onSubmit)} - className='flex flex-1 flex-col overflow-hidden' - > - <div className='flex-1 overflow-y-auto px-6 py-4'> - {isLoadingTemplate ? ( - <div className='space-y-6'> - {/* Icon and Color row */} - <div className='flex gap-3'> - <div className='w-20'> - <Skeleton className='mb-2 h-4 w-8' /> {/* Label */} - <Skeleton className='h-10 w-20' /> {/* Icon picker */} - </div> - <div className='w-20'> - <Skeleton className='mb-2 h-4 w-10' /> {/* Label */} - <Skeleton className='h-10 w-20' /> {/* Color picker */} - </div> - </div> - - {/* Name field */} - <div> - <Skeleton className='mb-2 h-4 w-12' /> {/* Label */} - <Skeleton className='h-10 w-full' /> {/* Input */} - </div> - - {/* Author and Category row */} - <div className='grid grid-cols-2 gap-4'> - <div> - <Skeleton className='mb-2 h-4 w-14' /> {/* Label */} - <Skeleton className='h-10 w-full' /> {/* Input */} - </div> - <div> - <Skeleton className='mb-2 h-4 w-16' /> {/* Label */} - <Skeleton className='h-10 w-full' /> {/* Select */} - </div> - </div> - - {/* Description field */} - <div> - <Skeleton className='mb-2 h-4 w-20' /> {/* Label */} - <Skeleton className='h-20 w-full' /> {/* Textarea */} - </div> - </div> - ) : ( - <div className='space-y-5'> - <div className='flex gap-3'> - <FormField - control={form.control} - name='icon' - render={({ field }) => ( - <FormItem className='w-20'> - <FormLabel className='!text-foreground font-medium text-sm'> - {copy.fields.icon} - </FormLabel> - <Popover open={iconPopoverOpen} onOpenChange={setIconPopoverOpen}> - <PopoverTrigger asChild> - <Button - variant='outline' - role='combobox' - aria-label={selectedIconLabel} - title={selectedIconLabel} - className='h-10 w-20 rounded-sm border-border/50 p-0 transition-all duration-200 hover:border-border hover:bg-card/50' - > - <SelectedIconComponent className='h-4 w-4' /> - </Button> - </PopoverTrigger> - <PopoverContent className='z-50 w-84 rounded-sm p-0' align='start'> - <div className='p-3'> - <div className='grid max-h-80 grid-cols-8 gap-2 overflow-y-auto'> - {icons.map((icon) => { - const IconComponent = icon.component - return ( - <button - key={icon.value} - type='button' - aria-label={getTemplateIconLabel(icon.value, copy)} - title={getTemplateIconLabel(icon.value, copy)} - onClick={() => { - field.onChange(icon.value) - setIconPopoverOpen(false) - }} - className={cn( - 'flex h-8 w-8 items-center justify-center rounded-md border border-border/40 transition-all duration-200', - 'hover:scale-105 hover:border-border hover:bg-card/50 active:scale-95', - field.value === icon.value && - 'border-primary/30 bg-[var(--primary)]/10 text-primary' - )} - > - <IconComponent className='h-4 w-4' /> - </button> - ) - })} - </div> - </div> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name='color' - render={({ field }) => ( - <FormItem className='w-20'> - <FormLabel className='!text-foreground font-medium text-sm'> - {copy.fields.color} - </FormLabel> - <FormControl> - <ColorPicker - value={field.value} - onChange={field.onChange} - onBlur={field.onBlur} - className='h-10 w-20 rounded-sm' - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <FormField - control={form.control} - name='name' - render={({ field }) => ( - <FormItem> - <FormLabel className='!text-foreground font-medium text-sm'> - {copy.fields.name} - </FormLabel> - <FormControl> - <Input - placeholder={copy.placeholders.name} - className='h-10 rounded-sm' - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <div className='grid grid-cols-2 gap-4'> - <FormField - control={form.control} - name='author' - render={({ field }) => ( - <FormItem> - <FormLabel className='!text-foreground font-medium text-sm'> - {copy.fields.author} - </FormLabel> - <FormControl> - <Input - placeholder={copy.placeholders.author} - className='h-10 rounded-sm' - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name='category' - render={({ field }) => ( - <FormItem> - <FormLabel className='!text-foreground font-medium text-sm'> - {copy.fields.category} - </FormLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger className='h-10 rounded-sm'> - <SelectValue placeholder={copy.placeholders.category} /> - </SelectTrigger> - </FormControl> - <SelectContent> - {categories.map((category) => ( - <SelectItem key={category.value} value={category.value}> - {getTemplateCategoryLabel(category.value, copy)} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <FormField - control={form.control} - name='description' - render={({ field }) => ( - <FormItem> - <FormLabel className='!text-foreground font-medium text-sm'> - {copy.fields.description} - </FormLabel> - <FormControl> - <Textarea - placeholder={copy.placeholders.description} - className='min-h-[80px] resize-none rounded-sm' - rows={3} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - )} - </div> - - {/* Fixed Footer */} - <div className='mt-auto border-t px-6 py-4'> - <div className='flex items-center gap-3'> - {form.formState.errors.root?.message ? ( - <p className='text-destructive text-sm'>{form.formState.errors.root.message}</p> - ) : null} - {existingTemplate && ( - <Button - type='button' - variant='destructive' - onClick={() => setShowDeleteDialog(true)} - disabled={isSubmitting || isLoadingTemplate} - className='h-9 rounded-sm px-4' - > - {copy.actions.delete} - </Button> - )} - <Button - type='submit' - disabled={isSubmitting || !isFormValid || isLoadingTemplate} - className={cn( - 'ml-auto h-9 rounded-sm px-4 font-[480]', - 'bg-primary hover:bg-primary-hover', - 'shadow-[0_0_0_0_var(--primary)] ', - 'text-white transition-all duration-200', - 'disabled:opacity-50 disabled:hover:bg-primary disabled:hover:shadow-none' - )} - > - {isSubmitting ? ( - <> - <Loader2 className='mr-2 h-4 w-4 animate-spin' /> - {existingTemplate ? copy.submit.updating : copy.submit.publishing} - </> - ) : existingTemplate ? ( - copy.submit.update - ) : ( - copy.submit.publish - )} - </Button> - </div> - </div> - </form> - </Form> - {existingTemplate && ( - <AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>{copy.delete.title}</AlertDialogTitle> - <AlertDialogDescription>{copy.delete.description}</AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter className='flex'> - <AlertDialogCancel className='h-9 w-full rounded-sm' disabled={isDeleting}> - {copy.delete.cancel} - </AlertDialogCancel> - <AlertDialogAction - className='h-9 w-full rounded-sm bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600' - disabled={isDeleting} - onClick={async () => { - if (!existingTemplate) return - setIsDeleting(true) - try { - const resp = await fetch(`/api/templates/${existingTemplate.id}`, { - method: 'DELETE', - }) - if (!resp.ok) { - const err = await resp.json().catch(() => ({})) - throw new Error(err.error || copy.errors.delete) - } - setShowDeleteDialog(false) - onOpenChange(false) - } catch (err) { - logger.error('Failed to delete template', err) - } finally { - setIsDeleting(false) - } - }} - > - {isDeleting ? copy.delete.deleting : copy.delete.confirm} - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - )} - </DialogContent> - </Dialog> - ) -} diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.test.ts b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.test.ts index a2701d39e..d457582fe 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.test.ts +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.test.ts @@ -19,10 +19,6 @@ vi.mock('@/lib/yjs/workflow-session', () => ({ getVariablesSnapshot: vi.fn(() => ({})), })) -vi.mock('@/stores/workflows/registry/store', () => ({ - useWorkflowRegistry: vi.fn(() => ({})), -})) - vi.mock('@/lib/logs/console/logger', () => ({ createLogger: () => ({ error: vi.fn(), 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 bcd8dd20b..c566f168c 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,6 +1,6 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AlertTriangle, ChevronDown, LayoutDashboard, Play, RefreshCw, X } from 'lucide-react' import { Button, @@ -32,7 +32,6 @@ import { import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useWorkflowExecution } from '@/hooks/workflow/use-workflow-execution' import { formatTemplate } from '@/i18n/utils' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { DeploymentControls, @@ -99,8 +98,9 @@ export function ControlBar({ const copy = useWorkflowEditorCopy() const { data: session } = useSession() const { workflowId, channelId } = useWorkflowRoute() - const isRegistryLoading = useWorkflowRegistry((state) => state.isLoading) const activeWorkflowId = workflowId + const activeWorkflowIdRef = useRef(activeWorkflowId) + activeWorkflowIdRef.current = activeWorkflowId const { isExecuting, isWorkflowSessionReady, handleRunWorkflow, handleCancelExecution } = useWorkflowExecution() @@ -113,6 +113,7 @@ export function ControlBar({ const [autoLayoutError, setAutoLayoutError] = useState<string | null>(null) // Deployed state management + const [isDeployed, setIsDeployed] = useState(false) const [deployedState, setDeployedState] = useState<WorkflowState | null>(null) const [isLoadingDeployedState, setIsLoadingDeployedState] = useState<boolean>(false) @@ -151,24 +152,55 @@ export function ControlBar({ isWorkflowBlocked || !userPermissions.canEdit || !canRunWithShortcut ) - // Get deployment status from registry - const deploymentStatus = useWorkflowRegistry((state) => - state.readWorkflowDeploymentStatus(activeWorkflowId) - ) - const isDeployed = deploymentStatus?.isDeployed || false - // Update the time display every minute useEffect(() => { const interval = setInterval(() => forceUpdate({}), 60000) return () => clearInterval(interval) }, []) + const fetchDeploymentStatus = useCallback(async () => { + if (!activeWorkflowId) { + setIsDeployed(false) + setChangeDetected(false) + return false + } + + const requestWorkflowId = activeWorkflowId + + try { + const response = await fetch(`/api/workflows/${requestWorkflowId}/status`) + if (requestWorkflowId !== activeWorkflowIdRef.current) { + return false + } + + if (!response.ok) { + logger.error('Failed to fetch workflow status:', response.status, response.statusText) + setIsDeployed(false) + setChangeDetected(false) + return false + } + + const data = await response.json() + const nextIsDeployed = Boolean(data.isDeployed) + setIsDeployed(nextIsDeployed) + setChangeDetected(Boolean(data.needsRedeployment)) + return nextIsDeployed + } catch (error) { + logger.error('Error fetching workflow status:', error) + if (requestWorkflowId === activeWorkflowIdRef.current) { + setIsDeployed(false) + setChangeDetected(false) + } + return false + } + }, [activeWorkflowId]) + /** * Fetches the deployed state of the workflow from the server * This is the single source of truth for deployed workflow state */ - const fetchDeployedState = async () => { - if (!activeWorkflowId || !isDeployed) { + const fetchDeployedState = useCallback(async () => { + if (!activeWorkflowId) { setDeployedState(null) return } @@ -176,17 +208,13 @@ export function ControlBar({ // Store the workflow ID at the start of the request to prevent race conditions const requestWorkflowId = activeWorkflowId - // Helper to get current active workflow ID for race condition checks - const getCurrentActiveWorkflowId = () => - useWorkflowRegistry.getState().getActiveWorkflowId(channelId) - try { setIsLoadingDeployedState(true) const response = await fetch(`/api/workflows/${requestWorkflowId}/deployed`) // Check if the workflow ID changed during the request (user navigated away) - if (requestWorkflowId !== getCurrentActiveWorkflowId()) { + if (requestWorkflowId !== activeWorkflowIdRef.current) { logger.debug('Workflow changed during deployed state fetch, ignoring response') return } @@ -201,76 +229,44 @@ export function ControlBar({ const data = await response.json() - if (requestWorkflowId === getCurrentActiveWorkflowId()) { + if (requestWorkflowId === activeWorkflowIdRef.current) { setDeployedState(data.deployedState || null) } else { logger.debug('Workflow changed after deployed state response, ignoring result') } } catch (error) { logger.error('Error fetching deployed state:', { error }) - if (requestWorkflowId === getCurrentActiveWorkflowId()) { + if (requestWorkflowId === activeWorkflowIdRef.current) { setDeployedState(null) } } finally { - if (requestWorkflowId === getCurrentActiveWorkflowId()) { + if (requestWorkflowId === activeWorkflowIdRef.current) { setIsLoadingDeployedState(false) } } - } + }, [activeWorkflowId]) useEffect(() => { - if (!activeWorkflowId) { - setDeployedState(null) - setIsLoadingDeployedState(false) - return - } + void fetchDeploymentStatus() + }, [fetchDeploymentStatus, currentBlocks, currentEdges]) - if (isRegistryLoading) { + useEffect(() => { + if (!activeWorkflowId) { setDeployedState(null) setIsLoadingDeployedState(false) return } if (isDeployed) { - fetchDeployedState() + void fetchDeployedState() } else { setDeployedState(null) setIsLoadingDeployedState(false) } - }, [activeWorkflowId, isDeployed, isRegistryLoading]) - - useEffect(() => { - if (!activeWorkflowId || !deployedState) { - setChangeDetected(false) - return - } - - if (isLoadingDeployedState) { - return - } - - // Check if the live workflow state differs from the deployed state - const checkForChanges = async () => { - try { - const response = await fetch(`/api/workflows/${activeWorkflowId}/status`) - if (response.ok) { - const data = await response.json() - setChangeDetected(data.needsRedeployment || false) - } else { - logger.error('Failed to fetch workflow status:', response.status, response.statusText) - setChangeDetected(false) - } - } catch (error) { - logger.error('Error fetching workflow status:', error) - setChangeDetected(false) - } - } - - checkForChanges() - }, [activeWorkflowId, deployedState, currentBlocks, currentEdges, isLoadingDeployedState]) + }, [activeWorkflowId, fetchDeployedState, isDeployed]) useEffect(() => { - if (session?.user?.id && !isRegistryLoading) { + if (session?.user?.id) { checkUserUsage().then((usage) => { if (usage) { setUsageExceeded(usage.isExceeded) @@ -278,7 +274,7 @@ export function ControlBar({ } }) } - }, [session?.user?.id, isRegistryLoading]) + }, [session?.user?.id]) /** * Check user usage limits and cache results @@ -330,11 +326,13 @@ export function ControlBar({ const renderDeployButton = () => ( <DeploymentControls activeWorkflowId={activeWorkflowId} + isDeployed={isDeployed} needsRedeployment={changeDetected} setNeedsRedeployment={setChangeDetected} deployedState={deployedState} isLoadingDeployedState={isLoadingDeployedState} refetchDeployedState={fetchDeployedState} + refetchDeploymentStatus={fetchDeploymentStatus} userPermissions={userPermissions} variant={variant} /> @@ -358,7 +356,6 @@ export function ControlBar({ const result = await applyAutoLayoutToActiveWorkflow({ workflowId: activeWorkflowId!, - channelId, }) if (result.success) { diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/subflows/subflow-node.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/subflows/subflow-node.tsx index 077e9db58..0c26bcf92 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/subflows/subflow-node.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/subflows/subflow-node.tsx @@ -84,7 +84,7 @@ export interface SubflowNodeData extends Record<string, unknown> { type SubflowNode = Node<SubflowNodeData, 'subflowNode'> export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<SubflowNode>) => { - const { workflowEditorCopy: copy, getLocalizedDefaultBlockName } = useWorkflowI18n() + const { workflowEditorCopy: copy } = useWorkflowI18n() const { getNodes } = useReactFlow() const updateNodeInternals = useUpdateNodeInternals() const userPermissions = useUserPermissionsContext() @@ -103,7 +103,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf const endHandleId = isLoop ? 'loop-end-source' : 'parallel-end-source' const endTargetHandleId = isLoop ? 'loop-end-target' : 'parallel-end-target' const blockColor = subflowConfig.bgColor - const blockName = getLocalizedDefaultBlockName(data.kind, data.name) + const blockName = data.name const BlockIcon = subflowConfig.icon const hasPriorityRing = Boolean(data?.hasNestedError) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/dropdown.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/dropdown.tsx index ad8e457a8..8c0184cf3 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/dropdown.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/dropdown.tsx @@ -10,10 +10,10 @@ import { } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' +import { useEntityList } from '@/lib/yjs/use-entity-fields' import { useWorkflowBlocks } from '@/lib/yjs/use-workflow-doc' import type { SubBlockConfig, SubBlockOption } from '@/blocks/types' import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { DEFAULT_WORKFLOW_CHANNEL_ID } from '@/stores/workflows/workflow/types' import { useDependsOnGate } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-depends-on-gate' import { useSubBlockValue } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value' @@ -98,12 +98,16 @@ export function Dropdown({ const routeContext = useOptionalWorkflowRoute() const resolvedChannelId = routeContext?.channelId ?? DEFAULT_WORKFLOW_CHANNEL_ID - const routeWorkflowId = routeContext?.workflowId ?? null - const activeWorkflowId = useWorkflowRegistry((state) => - state.getActiveWorkflowId(resolvedChannelId) - ) - const resolvedWorkflowId = activeWorkflowId ?? routeWorkflowId + const resolvedWorkflowId = routeContext?.workflowId ?? null const allBlocks = useWorkflowBlocks() + const blockType = allBlocks[blockId]?.type + const isWorkflowSelector = + subBlockId === 'workflowId' && (blockType === 'workflow' || blockType === 'workflow_input') + const { + members: workflowMembers, + isLoading: isLoadingWorkflowOptions, + error: workflowOptionsError, + } = useEntityList('workflow', isWorkflowSelector ? routeContext?.workspaceId : null) const blockContextValues = useMemo(() => { if (!resolvedWorkflowId) return undefined const block = allBlocks[blockId] @@ -192,12 +196,33 @@ export function Dropdown({ }) }, [fetchedOptions]) + const workflowOptions = useMemo<DropdownOptionObject[]>( + () => + workflowMembers + .filter((member) => member.entityId !== resolvedWorkflowId) + .map((member) => ({ + id: member.entityId, + label: member.entityName || `Workflow ${member.entityId.slice(0, 8)}`, + searchLabel: [member.entityName, member.entityDescription].filter(Boolean).join(' '), + })), + [resolvedWorkflowId, workflowMembers] + ) + const availableOptions = useMemo<Array<string | DropdownOptionObject>>(() => { + if (isWorkflowSelector) { + return workflowOptions + } if (fetchOptions && normalizedFetchedOptions.length > 0) { return normalizedFetchedOptions } return evaluatedOptions ?? [] - }, [fetchOptions, normalizedFetchedOptions, evaluatedOptions]) + }, [ + isWorkflowSelector, + workflowOptions, + fetchOptions, + normalizedFetchedOptions, + evaluatedOptions, + ]) const getOptionValue = ( option: @@ -214,7 +239,21 @@ export function Dropdown({ return typeof option === 'string' ? option : hasExplicitValue(option) ? option.value : option.id } - const optionsReady = fetchOptions ? hasFetchedOptions && !isLoadingOptions && !fetchError : true + const hasWorkflowOptions = workflowOptions.length > 0 + const workflowOptionsUnavailable = Boolean(workflowOptionsError && !hasWorkflowOptions) + const resolvedFetchError = isWorkflowSelector + ? workflowOptionsUnavailable + ? workflowOptionsError + : null + : fetchError + const isLoadingAvailableOptions = isWorkflowSelector + ? isLoadingWorkflowOptions && !hasWorkflowOptions + : isLoadingOptions + const optionsReady = isWorkflowSelector + ? !isLoadingAvailableOptions && !workflowOptionsUnavailable + : fetchOptions + ? hasFetchedOptions && !isLoadingOptions && !fetchError + : true const hasValue = value !== null && value !== undefined && value !== '' const clearSelectedValue = useCallback(() => { @@ -485,7 +524,7 @@ export function Dropdown({ const hasOptions = filteredOptions.length > 0 const emptyMessage = - fetchError || (shouldFilter ? copy.noMatchingOptions : copy.noOptionsAvailable) + resolvedFetchError || (shouldFilter ? copy.noMatchingOptions : copy.noOptionsAvailable) const triggerLabel = selectedOption?.label ?? '' const triggerRightLabel = selectedOption?.rightLabel @@ -541,7 +580,7 @@ export function Dropdown({ className='allow-scroll max-h-48 overflow-y-auto p-1' style={{ scrollbarWidth: 'thin' }} > - {isLoadingOptions ? ( + {isLoadingAvailableOptions ? ( <DropdownMenuItem disabled className='justify-center text-muted-foreground'> {copy.loading} </DropdownMenuItem> diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx index fcc90fa71..2e68e0bf4 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-selector/file-selector-input.tsx @@ -7,8 +7,6 @@ import type { SubBlockConfig } from '@/blocks/types' import { useWorkflowEditorActions } from '@/hooks/workflow/use-workflow-editor-actions' import { translateWorkflowLabel } from '@/i18n/block-editor' import type { LocaleCode } from '@/i18n/utils' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { DEFAULT_WORKFLOW_CHANNEL_ID } from '@/stores/workflows/workflow/types' import { ConfluenceFileSelector, GoogleCalendarSelector, @@ -39,11 +37,7 @@ export function FileSelectorInput({ const locale = useLocale() as LocaleCode const { collaborativeSetSubblockValue } = useWorkflowEditorActions() const routeContext = useOptionalWorkflowRoute() - const resolvedChannelId = routeContext?.channelId ?? DEFAULT_WORKFLOW_CHANNEL_ID - const registryWorkflowId = useWorkflowRegistry((state) => - state.getActiveWorkflowId(resolvedChannelId) - ) - const workflowIdFromUrl = routeContext?.workflowId || registryWorkflowId || '' + const workflowIdFromUrl = routeContext?.workflowId || '' const workspaceIdFromRoute = routeContext?.workspaceId || '' const getContextValue = (key: string) => { const value = contextValues?.[key] diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-upload.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-upload.tsx index 011ec66ac..34f12313f 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-upload.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/file-upload.tsx @@ -21,11 +21,8 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { translateWorkflowLabel } from '@/i18n/block-editor' import { formatFileSize as formatLocalizedFileSize } from '@/i18n/formatters' import type { LocaleCode } from '@/i18n/utils' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { DEFAULT_WORKFLOW_CHANNEL_ID } from '@/stores/workflows/workflow/types' import { useSubBlockValue } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value' import { - useOptionalWorkflowRoute, useWorkflowId, useWorkspaceId, } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' @@ -83,12 +80,7 @@ export function FileUpload({ const fileInputRef = useRef<HTMLInputElement>(null) // Stores - const routeContext = useOptionalWorkflowRoute() - const resolvedChannelId = routeContext?.channelId ?? DEFAULT_WORKFLOW_CHANNEL_ID - const registryWorkflowId = useWorkflowRegistry((state) => - state.getActiveWorkflowId(resolvedChannelId) - ) - const activeWorkflowId = useWorkflowId() || registryWorkflowId || null + const activeWorkflowId = useWorkflowId() const workspaceId = useWorkspaceId() const value = storeValue 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 69e4d62c0..d2d222ffb 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 @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useMemo, useState } from 'react' import { Check, ChevronDown, RefreshCw } from 'lucide-react' import { useMessages } from 'next-intl' import { Button } from '@/components/ui/button' @@ -13,8 +13,8 @@ import { CommandList, } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { useEntityList } from '@/lib/yjs/use-entity-fields' import type { SubBlockConfig } from '@/blocks/types' -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' @@ -29,8 +29,17 @@ export function McpServerSelector({ blockId, subBlock, disabled = false }: McpSe const workspaceId = useWorkspaceId() const [open, setOpen] = useState(false) - const { fetchServers, isLoading, error } = useMcpServersStore() - const enabledServers = useEnabledServers() + const { members, isLoading, error } = useEntityList('mcp_server', workspaceId) + const enabledServers = useMemo( + () => + members + .filter((member) => member.enabled !== false) + .map((member) => ({ + id: member.entityId, + name: member.entityName, + })), + [members] + ) const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) @@ -40,15 +49,8 @@ export function McpServerSelector({ blockId, subBlock, disabled = false }: McpSe const selectedServer = enabledServers.find((server) => server.id === selectedServerId) - useEffect(() => { - fetchServers(workspaceId) - }, [fetchServers, workspaceId]) - const handleOpenChange = (isOpen: boolean) => { setOpen((prev) => (prev === isOpen ? prev : isOpen)) - if (isOpen) { - fetchServers(workspaceId) - } } const handleSelect = (serverId: string) => { diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx index 934972925..9a198de1d 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/schedule/schedule-config.tsx @@ -254,7 +254,7 @@ export function ScheduleConfig({ // Get the fully merged current state with updated values // This ensures we send the complete, correct workflow state to the backend - const currentWorkflowWithValues = readWorkflowWithValues(workflowId, channelId) + const currentWorkflowWithValues = readWorkflowWithValues(workflowId) if (!currentWorkflowWithValues) { setError('failedToGetCurrentWorkflowState') return false diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/skill-input/skill-input.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/skill-input/skill-input.tsx index 1db8efc0b..8401accb4 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/skill-input/skill-input.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/skill-input/skill-input.tsx @@ -4,7 +4,7 @@ import { useMemo, useState } from 'react' import { ToolCase, XIcon } from 'lucide-react' import { useLocale } from 'next-intl' import { cn } from '@/lib/utils' -import { useSkills } from '@/hooks/queries/skills' +import { useEntityList } from '@/lib/yjs/use-entity-fields' import { translateWorkflowLabel } from '@/i18n/block-editor' import type { LocaleCode } from '@/i18n/utils' import { formatTemplate } from '@/i18n/utils' @@ -29,7 +29,7 @@ export function SkillInput({ blockId, subBlockId, disabled = false }: SkillInput const locale = useLocale() as LocaleCode const workspaceId = useWorkspaceId() const [storedValue, setStoredValue] = useSubBlockValue<StoredSkill[]>(blockId, subBlockId) - const { data: workspaceSkills = [] } = useSkills(workspaceId) + const { members: skillMembers } = useEntityList('skill', workspaceId) const [selectorValue, setSelectorValue] = useState('') const selectedSkills = useMemo( @@ -43,23 +43,23 @@ export function SkillInput({ blockId, subBlockId, disabled = false }: SkillInput ) const dropdownOptions = useMemo(() => { - return workspaceSkills - .filter((skill) => !selectedSkillIds.has(skill.id)) + return skillMembers + .filter((skill) => !selectedSkillIds.has(skill.entityId)) .map((skill) => ({ - label: skill.name, - id: skill.id, + label: skill.entityName, + id: skill.entityId, icon: ToolCase, group: translateWorkflowLabel(locale, 'skills'), })) - }, [locale, selectedSkillIds, workspaceSkills]) + }, [locale, selectedSkillIds, skillMembers]) const handleSkillSelection = (skillId: string) => { if (disabled || !skillId) return - const skill = workspaceSkills.find((s) => s.id === skillId) + const skill = skillMembers.find((s) => s.entityId === skillId) if (!skill || selectedSkillIds.has(skillId)) return - setStoredValue([...selectedSkills, { skillId, name: skill.name }]) + setStoredValue([...selectedSkills, { skillId, name: skill.entityName }]) setSelectorValue('') } @@ -88,7 +88,7 @@ export function SkillInput({ blockId, subBlockId, disabled = false }: SkillInput <div className='flex min-h-[2.5rem] w-full flex-wrap gap-2 rounded-md border border-input bg-transparent p-2 text-sm ring-offset-background'> {selectedSkills.map((storedSkill) => { const resolvedName = - workspaceSkills.find((skill) => skill.id === storedSkill.skillId)?.name ?? + skillMembers.find((skill) => skill.entityId === storedSkill.skillId)?.entityName ?? storedSkill.name ?? storedSkill.skillId diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/workflow-block.test.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/workflow-block.test.tsx index d5af0f113..1247376b7 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/workflow-block.test.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/workflow-block.test.tsx @@ -241,6 +241,7 @@ describe('WorkflowBlock localization', () => { }) ) + expect(markup).toContain('Condition') expect(markup).toContain('Siguiente paso') expect(markup).toContain('Bloqueado') expect(markup).toContain('Deshabilitado') diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/workflow-block.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/workflow-block.tsx index abf946972..387084da2 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/workflow-block.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/workflow-block.tsx @@ -63,13 +63,9 @@ type WorkflowBlockNode = Node<WorkflowBlockProps, 'workflowBlock'> export const WorkflowBlock = memo( function WorkflowBlock({ id, data, selected }: NodeProps<WorkflowBlockNode>) { const { type, config, name, isActive: dataIsActive, isPending } = data - const { - formatWorkflowTemplate, - getLocalizedDefaultBlockName, - localizeWorkflowSubBlockConfig, - workflowLabelsCopy, - } = useWorkflowI18n() - const displayName = getLocalizedDefaultBlockName(type, name) + const { formatWorkflowTemplate, localizeWorkflowSubBlockConfig, workflowLabelsCopy } = + useWorkflowI18n() + const displayName = name // State management const [, setIsConnecting] = useState(false) diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-controlbar/controlbar.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-controlbar/controlbar.tsx index be78baa13..1616e1271 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-controlbar/controlbar.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-controlbar/controlbar.tsx @@ -1,30 +1,17 @@ 'use client' -import { useMemo } from 'react' import { TooltipProvider } from '@/components/ui/tooltip' import { widgetHeaderControlClassName } from '@/components/widget-header-control' import { WorkflowSessionProvider } from '@/lib/yjs/workflow-session-host' import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { WorkflowRouteProvider } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' -import { useWorkflowEditorCopy } from '@/widgets/widgets/editor_workflow/copy' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useWorkflowWidgetState } from '@/widgets/hooks/use-workflow-widget-state' import type { WidgetInstance } from '@/widgets/layout' -import { isPairColor, type PairColor } from '@/widgets/pair-colors' import { ControlBar } from '@/widgets/widgets/editor_workflow/components/control-bar/control-bar' +import { WorkflowRouteProvider } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' +import { useWorkflowEditorCopy } from '@/widgets/widgets/editor_workflow/copy' const FALLBACK_TEXT_CLASS = widgetHeaderControlClassName('text-muted-foreground/80') -export const readWorkflowWidgetChannelId = ( - pairColor: PairColor, - widgetKey: string, - panelId?: string -) => { - if (pairColor !== 'gray') { - return `pair-${pairColor}` - } - return `${widgetKey}-${panelId ?? 'panel'}` -} - interface WorkflowWidgetControlBarProps { workspaceId?: string widget?: WidgetInstance | null @@ -37,30 +24,26 @@ export function WorkflowWidgetControlBar({ panelId, }: WorkflowWidgetControlBarProps) { const copy = useWorkflowEditorCopy() - if (!workspaceId) { - return <span className={FALLBACK_TEXT_CLASS}>{copy.controlsUnavailable}</span> - } - - const resolvedPairColor = isPairColor(widget?.pairColor) ? widget?.pairColor : 'gray' - const widgetKey = widget?.key ?? 'workflow-editor' - const channelId = useMemo( - () => readWorkflowWidgetChannelId(resolvedPairColor, widgetKey, panelId), - [resolvedPairColor, widgetKey, panelId] - ) - - const activeWorkflowId = useWorkflowRegistry((state) => state.getActiveWorkflowId(channelId)) - - if (!activeWorkflowId) { + const { channelId, resolvedWorkflowId } = useWorkflowWidgetState({ + workspaceId, + pairColor: widget?.pairColor ?? 'gray', + widget, + panelId, + params: widget?.params ?? null, + fallbackWidgetKey: 'editor_workflow', + }) + + if (!workspaceId || !resolvedWorkflowId) { return <span className={FALLBACK_TEXT_CLASS}>{copy.controlsUnavailable}</span> } return ( <TooltipProvider delayDuration={100}> <WorkspacePermissionsProvider workspaceId={workspaceId} inheritUser> - <WorkflowSessionProvider workspaceId={workspaceId} workflowId={activeWorkflowId}> + <WorkflowSessionProvider workspaceId={workspaceId} workflowId={resolvedWorkflowId}> <WorkflowRouteProvider workspaceId={workspaceId} - workflowId={activeWorkflowId} + workflowId={resolvedWorkflowId} channelId={channelId} > <ControlBar diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-controlbar/index.ts b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-controlbar/index.ts index 78d565693..bbc556ff1 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-controlbar/index.ts +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-controlbar/index.ts @@ -1 +1 @@ -export { WorkflowWidgetControlBar, readWorkflowWidgetChannelId } from './controlbar' +export { WorkflowWidgetControlBar } from './controlbar' diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor-app.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor-app.tsx index 79b882ce6..ca3b5e3a0 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor-app.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor-app.tsx @@ -12,7 +12,6 @@ interface WorkflowEditorAppProps { workspaceId: string workflowId: string ui?: WorkflowCanvasUIConfig - disableNavigation?: boolean channelId?: string toolbarScopeId?: string viewportBounds?: { x: number; y: number; width: number; height: number } @@ -22,7 +21,6 @@ const WorkflowEditorApp = ({ workspaceId, workflowId, ui, - disableNavigation, channelId = DEFAULT_WORKFLOW_CHANNEL_ID, toolbarScopeId, viewportBounds, @@ -51,7 +49,6 @@ const WorkflowEditorApp = ({ channelId={channelId} toolbarScopeId={toolbarScopeId} ui={ui} - disableNavigation={disableNavigation} viewportBounds={viewportBounds} /> </WorkflowRouteProvider> diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/panel/node-editor-panel.test.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/panel/node-editor-panel.test.tsx index 9c35e86e7..a667ec5cd 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/panel/node-editor-panel.test.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/panel/node-editor-panel.test.tsx @@ -256,7 +256,7 @@ describe('NodeEditorPanel', () => { } }) - it('renders localized block titles and sub-block copy through the shared row builder', () => { + it('renders the raw block title and localized sub-block copy through the shared row builder', () => { mockSelectedBlock = { id: 'agent-1', type: 'agent', @@ -271,7 +271,8 @@ describe('NodeEditorPanel', () => { }) ) - expect(markup).toContain('Agente') + expect(markup).toContain('Agent') + expect(markup).not.toContain('Agente') expect(markup).toContain('Prompt del sistema') expect(markup).toContain('Prompt del usuario') expect(markup).toContain('Modelo') @@ -366,7 +367,7 @@ describe('NodeEditorPanel', () => { expect(mockCollaborativeUpdateBlockName).not.toHaveBeenCalled() expect(container.querySelector('input[type="text"]')).toBeNull() - expect(container.textContent).toContain('Agente') + expect(container.textContent).toContain('Agent') }) it('renders the missing-node fallback instead of crashing when the selected block is absent', () => { diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/panel/node-editor-panel.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/panel/node-editor-panel.tsx index c0d063761..5aee3e640 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/panel/node-editor-panel.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/panel/node-editor-panel.tsx @@ -46,8 +46,7 @@ const panelClassName = 'allow-scroll !m-2 max-h-[calc(100%-1rem)] min-w-0 w-[calc(100%-1rem)] max-w-96 overflow-y-auto rounded-lg border bg-card shadow-md' export function NodeEditorPanel({ selectedNodeId }: NodeEditorPanelProps) { - const { workflowEditorCopy, workflowInspectorCopy, getLocalizedDefaultBlockName } = - useWorkflowI18n() + const { workflowEditorCopy, workflowInspectorCopy } = useWorkflowI18n() const userPermissions = useUserPermissionsContext() const selectedBlock = useBlock(selectedNodeId ?? '') const selectedLoop = useLoop(selectedNodeId ?? '') @@ -241,9 +240,7 @@ export function NodeEditorPanel({ selectedNodeId }: NodeEditorPanelProps) { const subflowIterationInputValue = tempIterationValue ?? String(subflowIterations) const subflowMaxIterations = selectedBlock?.type === 'loop' ? 100 : 20 - const selectedBlockDisplayName = selectedBlock - ? getLocalizedDefaultBlockName(selectedBlock.type, selectedBlock.name) - : '' + const selectedBlockDisplayName = selectedBlock ? selectedBlock.name : '' const handleSubflowTypeChange = useCallback( (newType: string) => { diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-node.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-node.tsx index eb96f5e79..e5cd0f1f4 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-node.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-node.tsx @@ -97,15 +97,13 @@ function PreviewNodeCard({ } function LocalizedPreviewNode({ id, data }: NodeProps<PreviewCanvasNode>) { - const { getLocalizedDefaultBlockName, localizeWorkflowSubBlockConfig } = useWorkflowI18n() + const { localizeWorkflowSubBlockConfig } = useWorkflowI18n() const blockConfig = useMemo( () => getBlock(data.type) ?? data.config ?? null, [data.type, data.config] ) const previewStateRaw = data.subBlockValues ?? data.blockState?.subBlocks ?? {} - const localizedBlockName = blockConfig - ? getLocalizedDefaultBlockName(blockConfig.type, data.name) - : data.name + const blockName = data.name const triggerId = blockConfig ? resolveTriggerIdFromSubBlocks(previewStateRaw, blockConfig.triggers?.available) : null @@ -173,7 +171,7 @@ function LocalizedPreviewNode({ id, data }: NodeProps<PreviewCanvasNode>) { <PreviewNodeCard data={data} blockConfig={blockConfig} - title={localizedBlockName} + title={blockName} isEnabled={isEnabled} useHorizontalHandles={useHorizontalHandles} summary={summary} diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-readonly-guards.test.ts b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-readonly-guards.test.ts index 3d3d66497..96d99dba0 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-readonly-guards.test.ts +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-readonly-guards.test.ts @@ -12,8 +12,6 @@ const PREVIEW_RUNTIME_FILES = [ const BANNED_IMPORT_PATTERNS = [ 'hooks/workflow/use-workflow-editor-actions', - 'stores/workflows/workflow/store-client', - 'stores/workflows/workflow/store', 'stores/operation-queue/store', 'contexts/socket-context', ] diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-subflow.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-subflow.tsx index 8d339d47b..00d46da48 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-subflow.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/preview-subflow.tsx @@ -101,15 +101,10 @@ function PreviewSubflowCard({ } function LocalizedPreviewSubflow({ data }: NodeProps<PreviewCanvasSubflowNode>) { - const { workflowEditorCopy: copy, getLocalizedDefaultBlockName } = useWorkflowI18n() + const { workflowEditorCopy: copy } = useWorkflowI18n() return ( - <PreviewSubflowCard - data={data} - title={getLocalizedDefaultBlockName(data.kind, data.name)} - startLabel={copy.start} - endLabel={copy.end} - /> + <PreviewSubflowCard data={data} title={data.name} startLabel={copy.start} endLabel={copy.end} /> ) } @@ -124,7 +119,9 @@ function PrecomputedPreviewSubflow({ data }: NodeProps<PreviewCanvasSubflowNode> ) } -export const PreviewSubflow = memo(function PreviewSubflow(props: NodeProps<PreviewCanvasSubflowNode>) { +export const PreviewSubflow = memo(function PreviewSubflow( + props: NodeProps<PreviewCanvasSubflowNode> +) { return hasPrecomputedPreviewContent(props.data) ? ( <PrecomputedPreviewSubflow {...props} /> ) : ( diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/read-only-node-editor-panel.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/read-only-node-editor-panel.tsx index 91120cbc7..9f5cd0a72 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/read-only-node-editor-panel.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/preview/read-only-node-editor-panel.tsx @@ -24,7 +24,6 @@ export function ReadOnlyNodeEditorPanel({ const { workflowEditorCopy: copy, readOnlyPreviewCopy: previewCopy, - getLocalizedDefaultBlockName, localizeWorkflowSubBlockConfig, } = useWorkflowI18n() const selectedBlock = useMemo(() => { @@ -57,7 +56,7 @@ export function ReadOnlyNodeEditorPanel({ } const blockConfig = getBlock(selectedBlock.type) - const localizedBlockName = getLocalizedDefaultBlockName(selectedBlock.type, selectedBlock.name) + const blockName = selectedBlock.name const previewConfig = (() => { if (selectedBlock.type === 'loop') { const loop = workflowState.loops?.[selectedBlock.id] @@ -136,7 +135,7 @@ export function ReadOnlyNodeEditorPanel({ <p className='text-muted-foreground text-xs uppercase tracking-wide'> {copy.previewInspector} </p> - <h3 className='line-clamp-2 font-medium text-sm'>{localizedBlockName}</h3> + <h3 className='line-clamp-2 font-medium text-sm'>{blockName}</h3> </header> {previewConfig.subBlocks.length > 0 ? ( 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 0443b8d88..437995260 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 @@ -4,36 +4,34 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Background, ConnectionLineType, - ReactFlow, type Edge, type Node, + ReactFlow, useOnSelectionChange, useReactFlow, } from '@xyflow/react' import '@xyflow/react/dist/style.css' import { createLogger } from '@/lib/logs/console/logger' import { TriggerUtils } from '@/lib/workflows/triggers' +import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' +import { useWorkflowMutations } from '@/lib/yjs/use-workflow-doc' +import { useOptionalWorkflowSession } from '@/lib/yjs/workflow-session-host' import { useUserPermissionsContext, useWorkspacePermissionsContext, } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { getBlock } from '@/blocks' -import { useWorkflowEditorActions } from '@/hooks/workflow/use-workflow-editor-actions' -import { useOptionalWorkflowSession } from '@/lib/yjs/workflow-session-host' import { useStreamCleanup } from '@/hooks/use-stream-cleanup' import { useCurrentWorkflow } from '@/hooks/workflow' +import { useWorkflowEditorActions } from '@/hooks/workflow/use-workflow-editor-actions' import { useCopilotStore } from '@/stores/copilot/store' import { useExecutionStore } from '@/stores/execution/store' -import { hasWorkflowsInitiallyLoaded, useWorkflowRegistry } from '@/stores/workflows/registry/store' import { getUniqueBlockName } from '@/stores/workflows/utils' import { DEFAULT_WORKFLOW_CHANNEL_ID } from '@/stores/workflows/workflow/types' -import { YJS_ORIGINS } from '@/lib/yjs/transaction-origins' -import { useWorkflowMutations } from '@/lib/yjs/use-workflow-doc' import { isBlockProtected } from '@/stores/workflows/workflow/utils' import { ControlBar } from '@/widgets/widgets/editor_workflow/components/control-bar/control-bar' import { FloatingControls } from '@/widgets/widgets/editor_workflow/components/floating-controls/floating-controls' import { TriggerList } from '@/widgets/widgets/editor_workflow/components/trigger-list/trigger-list' -import { useWorkflowI18n } from '@/widgets/widgets/editor_workflow/copy' import { TriggerWarningDialog, TriggerWarningType, @@ -50,10 +48,6 @@ import { deriveCanvasNodes, getStableBlocksHash, } from '@/widgets/widgets/editor_workflow/components/workflow-editor/canvas/derive-canvas-nodes' -import { - shouldAutoFitWorkflowView, - WORKFLOW_FIT_VIEW_PADDING, -} from '@/widgets/widgets/editor_workflow/components/workflow-editor/canvas/workflow-view-fit' import { getNodeAbsolutePosition, getNodeSourceAnchorPosition, @@ -82,13 +76,17 @@ import { subscribeUpdateSubBlockValue, type UpdateSubBlockValuePayload, } from '@/widgets/widgets/editor_workflow/components/workflow-editor/canvas/workflow-editor-event-bus' +import { + shouldAutoFitWorkflowView, + WORKFLOW_FIT_VIEW_PADDING, +} from '@/widgets/widgets/editor_workflow/components/workflow-editor/canvas/workflow-view-fit' import { NodeEditorPanel } from '@/widgets/widgets/editor_workflow/components/workflow-editor/panel/node-editor-panel' import { registerToolbarAddBlockHandler, type ToolbarAddBlockRequest, } from '@/widgets/widgets/editor_workflow/components/workflow-toolbar/toolbar-add-block-dispatcher' import { useWorkflowRoute } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' -import { useRouter } from '@/i18n/navigation' +import { useWorkflowI18n } from '@/widgets/widgets/editor_workflow/copy' const logger = createLogger('Workflow') @@ -130,24 +128,15 @@ const defaultUIConfig: Required<WorkflowCanvasUIConfig> = { type WorkflowCanvasProps = { ui?: WorkflowCanvasUIConfig - disableNavigation?: boolean channelId?: string toolbarScopeId?: string viewportBounds?: WorkflowViewportBounds } const WorkflowCanvas = React.memo( - ({ - ui, - disableNavigation = false, - channelId, - toolbarScopeId, - viewportBounds, - }: WorkflowCanvasProps) => { + ({ ui, channelId, toolbarScopeId, viewportBounds }: WorkflowCanvasProps) => { const uiConfig = useMemo(() => ({ ...defaultUIConfig, ...ui }), [ui]) const { getLocalizedDefaultBlockName } = useWorkflowI18n() - // State - const [isWorkflowReady, setIsWorkflowReady] = useState(false) // State for tracking node dragging const [potentialParentId, setPotentialParentId] = useState<string | null>(null) @@ -193,23 +182,26 @@ const WorkflowCanvas = React.memo( // Throttled cursor tracking via Yjs Awareness const lastCursorBroadcast = useRef(0) const reactFlowInstance = useReactFlow() - const handleMouseMove = useCallback((event: React.MouseEvent) => { - const awareness = awarenessRef.current - if (!awareness) return - const now = performance.now() - if (now - lastCursorBroadcast.current < 50) return // 20fps throttle - lastCursorBroadcast.current = now - try { - const flowPosition = reactFlowInstance.screenToFlowPosition({ - x: event.clientX, - y: event.clientY, - }) - const current = awareness.getLocalState() ?? {} - awareness.setLocalState({ ...current, cursor: flowPosition }) - } catch { - // screenToFlowPosition can throw if ReactFlow is not initialized yet - } - }, [reactFlowInstance]) + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + const awareness = awarenessRef.current + if (!awareness) return + const now = performance.now() + if (now - lastCursorBroadcast.current < 50) return // 20fps throttle + lastCursorBroadcast.current = now + try { + const flowPosition = reactFlowInstance.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }) + const current = awareness.getLocalState() ?? {} + awareness.setLocalState({ ...current, cursor: flowPosition }) + } catch { + // screenToFlowPosition can throw if ReactFlow is not initialized yet + } + }, + [reactFlowInstance] + ) // State for trigger warning dialog const [triggerWarning, setTriggerWarning] = useState<{ @@ -223,7 +215,6 @@ const WorkflowCanvas = React.memo( }) // Hooks - const router = useRouter() const { workspaceId, workflowId } = useWorkflowRoute() const resolvedChannelId = useMemo(() => channelId ?? DEFAULT_WORKFLOW_CHANNEL_ID, [channelId]) const reactFlowId = useMemo(() => `workflow-${resolvedChannelId}`, [resolvedChannelId]) @@ -254,17 +245,7 @@ const WorkflowCanvas = React.memo( const containerHeightClass = viewportBounds ? 'h-full' : 'h-screen' const effectiveWorkflowId = workflowId ?? null - const shouldHandleNavigation = !disableNavigation - - const workflows = useWorkflowRegistry((state) => state.workflows) - const setActiveWorkflow = useWorkflowRegistry((state) => state.setActiveWorkflow) - const activeWorkflowId = useWorkflowRegistry((state) => - state.getActiveWorkflowId(resolvedChannelId) - ) - const hydration = useWorkflowRegistry((state) => state.getHydration(resolvedChannelId)) - const isChannelHydrating = useWorkflowRegistry((state) => - state.isChannelHydrating(resolvedChannelId) - ) + const isWorkflowReady = Boolean(effectiveWorkflowId && workflowSession?.doc) // Use the clean abstraction for current workflow state const currentWorkflow = useCurrentWorkflow() @@ -274,7 +255,12 @@ const WorkflowCanvas = React.memo( const storeUpdateBlockPosition = yjsMutations.updateBlockPosition // Local ref for tracking drag start position (used for undo/redo move entries) - const dragStartPositionRef = useRef<{id: string, x: number, y: number, parentId?: string | null} | null>(null) + const dragStartPositionRef = useRef<{ + id: string + x: number + y: number + parentId?: string | null + } | null>(null) // Get copilot cleanup function const copilotCleanup = useCopilotStore((state) => state.cleanup) @@ -458,8 +444,7 @@ const WorkflowCanvas = React.memo( ) const result = await applyAutoLayoutToActiveWorkflow({ - workflowId: activeWorkflowId!, - channelId: resolvedChannelId, + workflowId: effectiveWorkflowId!, }) if (result.success) { @@ -470,7 +455,7 @@ const WorkflowCanvas = React.memo( } catch (error) { logger.error('Auto layout error:', error) } - }, [activeWorkflowId, blocks, resolvedChannelId]) + }, [blocks, effectiveWorkflowId, resolvedChannelId]) const debouncedAutoLayout = useCallback(() => { const debounceTimer = setTimeout(() => { @@ -818,7 +803,13 @@ const WorkflowCanvas = React.memo( enableTriggerMode || false ) }, - [addBlock, blocks, getCanonicalUniqueBlockName, getLocalizedDefaultBlockName, projectViewportCenter] + [ + addBlock, + blocks, + getCanonicalUniqueBlockName, + getLocalizedDefaultBlockName, + projectViewportCenter, + ] ) // Update the onDrop handler @@ -1093,104 +1084,6 @@ const WorkflowCanvas = React.memo( [screenToFlowPosition, isPointInLoopNodeWrapper, getNodes, blocks] ) - // Initialize workflow when it exists in registry and isn't active - useEffect(() => { - const currentId = effectiveWorkflowId - if (!currentId || !workflows[currentId]) return - - if (activeWorkflowId !== currentId) { - setActiveWorkflow({ workflowId: currentId, channelId: resolvedChannelId }).catch( - (error) => { - logger.error('Failed to activate workflow for channel', { - error, - channelId: resolvedChannelId, - }) - } - ) - } - }, [effectiveWorkflowId, workflows, activeWorkflowId, setActiveWorkflow, resolvedChannelId]) - - // Track when workflow is ready for rendering - useEffect(() => { - const currentId = effectiveWorkflowId - - // Workflow is ready when: - // 1. We have an active workflow that matches the URL - // 2. The workflow exists in the registry - // 3. This channel is not currently hydrating - const shouldBeReady = - currentId !== null && - activeWorkflowId === currentId && - Boolean(workflows[currentId]) && - !isChannelHydrating - - setIsWorkflowReady(shouldBeReady) - }, [activeWorkflowId, effectiveWorkflowId, workflows, isChannelHydrating]) - - // Handle navigation and validation - useEffect(() => { - if (!shouldHandleNavigation) { - return - } - - const validateAndNavigate = async () => { - const workflowIds = Object.keys(workflows) - const currentId = workflowId - - // Wait for initial load to complete before making navigation decisions - if (!hasWorkflowsInitiallyLoaded() || hydration.phase === 'metadata-loading') { - return - } - - // If no workflows exist after loading, redirect to workspace root - if (workflowIds.length === 0) { - logger.info('No workflows found, redirecting to workspace root') - router.replace(`/workspace/${workspaceId}/dashboard`) - return - } - - // Navigate to existing workflow or first available - if (!workflows[currentId]) { - logger.info(`Workflow ${currentId} not found, redirecting to first available workflow`) - - // Validate that workflows belong to the current workspace before redirecting - const workspaceWorkflows = workflowIds.filter((id) => { - const workflow = workflows[id] - return workflow.workspaceId === workspaceId - }) - - if (workspaceWorkflows.length > 0) { - router.replace(`/workspace/${workspaceId}/dashboard`) - } else { - // No valid workflows for this workspace, redirect to workspace root - router.replace(`/workspace/${workspaceId}/dashboard`) - } - return - } - - // Validate that the current workflow belongs to the current workspace - const currentWorkflow = workflows[currentId] - if (currentWorkflow && currentWorkflow.workspaceId !== workspaceId) { - logger.warn( - `Workflow ${currentId} belongs to workspace ${currentWorkflow.workspaceId}, not ${workspaceId}` - ) - // Redirect to the correct workspace for this workflow - router.replace(`/workspace/${currentWorkflow.workspaceId}/dashboard`) - return - } - } - - validateAndNavigate() - }, [ - shouldHandleNavigation, - workflowId, - workflows, - hydration.phase, - workspaceId, - router, - hasWorkflowsInitiallyLoaded, - ]) - const blockConfigCache = useRef(new Map()) const getBlockConfig = useCallback((type: string) => { return getBlockConfigFromCache(blockConfigCache.current, type) @@ -1655,7 +1548,7 @@ const WorkflowCanvas = React.memo( ) }, [collaborativeSetSubblockValue, resolvedChannelId, effectiveWorkflowId]) - // Show skeleton UI while loading until the workflow store is hydrated + // Show skeleton UI until the routed workflow Yjs session is mounted. const showSkeletonUI = !isWorkflowReady if (showSkeletonUI) { @@ -1663,7 +1556,12 @@ const WorkflowCanvas = React.memo( <div className={`flex ${containerHeightClass} w-full flex-col overflow-hidden`}> <div className='relative h-full w-full flex-1 transition-all duration-200'> <div className='workflow-container h-full'> - <Background bgColor='transparent' color='hsl(var(--workflow-dots))' size={4} gap={40} /> + <Background + bgColor='transparent' + color='hsl(var(--workflow-dots))' + size={4} + gap={40} + /> </div> </div> </div> @@ -1671,7 +1569,10 @@ const WorkflowCanvas = React.memo( } return ( - <div className={`${containerHeightClass} w-full overflow-hidden`} onMouseMove={handleMouseMove}> + <div + className={`${containerHeightClass} w-full overflow-hidden`} + onMouseMove={handleMouseMove} + > <div id={ effectiveWorkflowId ? `workflow-editor-overlay-root-${effectiveWorkflowId}` : undefined diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow.tsx index 103d2c9c2..1c1e7e51e 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow.tsx @@ -2,49 +2,59 @@ import React, { useMemo } from 'react' import { ReactFlowProvider } from '@xyflow/react' +import { LoadingAgent } from '@/components/ui/loading-agent' +import { useWorkflowSession } from '@/lib/yjs/workflow-session-host' +import { WidgetStateMessage } from '@/widgets/widgets/editor_indicator/components/widget-state-message' import { ErrorBoundary } from '@/widgets/widgets/editor_workflow/components/error' import WorkflowCanvas, { type WorkflowCanvasUIConfig, } from '@/widgets/widgets/editor_workflow/components/workflow-editor/workflow-canvas' import { useWorkflowUIConfig } from '@/widgets/widgets/editor_workflow/context/workflow-ui-context' +import { useWorkflowEditorCopy } from '@/widgets/widgets/editor_workflow/copy' export type WorkflowUIConfig = WorkflowCanvasUIConfig interface WorkflowProps { ui?: WorkflowCanvasUIConfig - disableNavigation?: boolean channelId?: string toolbarScopeId?: string viewportBounds?: { x: number; y: number; width: number; height: number } } -export const WorkflowEditorProvider = ({ children }: { children: React.ReactNode }) => ( - <ReactFlowProvider> - <ErrorBoundary>{children}</ErrorBoundary> - </ReactFlowProvider> -) - -const Workflow = React.memo( - ({ ui, disableNavigation, channelId, toolbarScopeId, viewportBounds }: WorkflowProps) => { - const layoutUI = useWorkflowUIConfig() - const mergedUI = useMemo<WorkflowCanvasUIConfig | undefined>(() => { - if (!ui && !layoutUI) return ui - return { ...layoutUI, ...ui } - }, [layoutUI, ui]) +const Workflow = React.memo(({ ui, channelId, toolbarScopeId, viewportBounds }: WorkflowProps) => { + const layoutUI = useWorkflowUIConfig() + const copy = useWorkflowEditorCopy() + const workflowSession = useWorkflowSession() + const mergedUI = useMemo<WorkflowCanvasUIConfig | undefined>(() => { + if (!ui && !layoutUI) return ui + return { ...layoutUI, ...ui } + }, [layoutUI, ui]) + + if (workflowSession.error) { + return <WidgetStateMessage message={workflowSession.error} /> + } + if (workflowSession.isLoading || !workflowSession.doc) { return ( - <WorkflowEditorProvider> + <div className='flex h-full w-full items-center justify-center'> + <LoadingAgent size='md' /> + </div> + ) + } + + return ( + <ReactFlowProvider> + <ErrorBoundary fallback={<WidgetStateMessage message={copy.unableToLoadWorkflows} />}> <WorkflowCanvas channelId={channelId} toolbarScopeId={toolbarScopeId} ui={mergedUI} - disableNavigation={disableNavigation} viewportBounds={viewportBounds} /> - </WorkflowEditorProvider> - ) - } -) + </ErrorBoundary> + </ReactFlowProvider> + ) +}) Workflow.displayName = 'Workflow' diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/index.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/index.tsx index cd5f431dd..efd916f8c 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/index.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/index.tsx @@ -3,15 +3,20 @@ import { useCallback, useEffect, useState } from 'react' import { Workflow } from 'lucide-react' import { LoadingAgent } from '@/components/ui/loading-agent' -import { useWorkflowEditorMessages } from '@/i18n/workspace-widget-hooks' +import { + useWorkflowDropdownMessages, + useWorkflowEditorMessages, +} from '@/i18n/workspace-widget-hooks' import { useWorkflowWidgetState } from '@/widgets/hooks/use-workflow-widget-state' import type { WidgetInstance } from '@/widgets/layout' import type { DashboardWidgetDefinition, WidgetComponentProps } from '@/widgets/types' +import { usePersistResolvedEntityId } from '@/widgets/utils/entity-selection' import { emitWorkflowSelectionChange, useWorkflowSelectionPersistence, } from '@/widgets/utils/workflow-selection' import { WorkflowDropdown } from '@/widgets/widgets/components/workflow-dropdown' +import { WidgetStateMessage } from '@/widgets/widgets/editor_indicator/components/widget-state-message' import { WorkflowWidgetControlBar } from '@/widgets/widgets/editor_workflow/components/workflow-controlbar' import type { WorkflowCanvasUIConfig } from '@/widgets/widgets/editor_workflow/components/workflow-editor/workflow-canvas' import WorkflowEditorApp from '@/widgets/widgets/editor_workflow/components/workflow-editor-app' @@ -37,6 +42,7 @@ const WorkflowEditorWidgetBody = ({ }: WidgetComponentProps) => { const workspaceId = context?.workspaceId const copy = useWorkflowEditorMessages() + const dropdownCopy = useWorkflowDropdownMessages() const widgetKey = widget?.key ?? 'editor_workflow' const toolbarScopeId = readWorkflowToolbarScopeId(widgetKey, panelId) const { @@ -53,14 +59,19 @@ const WorkflowEditorWidgetBody = ({ widget, panelId, params, - onWidgetParamsChange, fallbackWidgetKey: 'editor_workflow', - loggerScope: 'workflow editor widget', }) useWorkflowSelectionPersistence({ onWidgetParamsChange, panelId, - widget, + pairColor: resolvedPairColor, + params, + scopeKey: widgetKey, + }) + usePersistResolvedEntityId({ + entityId: resolvedWorkflowId, + entityIdKey: 'workflowId', + onWidgetParamsChange, pairColor: resolvedPairColor, params, }) @@ -152,11 +163,7 @@ const WorkflowEditorWidgetBody = ({ return <WidgetStateMessage message={copy.noSharedWorkflowSelected} /> } - return ( - <div className='flex h-full w-full items-center justify-center '> - <LoadingAgent size='md' /> - </div> - ) + return <WidgetStateMessage message={dropdownCopy.selectWorkflow} /> } return ( @@ -169,19 +176,12 @@ const WorkflowEditorWidgetBody = ({ toolbarScopeId={toolbarScopeId} ui={WORKFLOW_WIDGET_UI_CONFIG} viewportBounds={widgetBounds ?? undefined} - disableNavigation={true} /> </WorkflowUIConfigProvider> </div> ) } -const WidgetStateMessage = ({ message }: { message: string }) => ( - <div className='flex h-full w-full items-center justify-center px-4 text-center text-muted-foreground text-xs'> - {message} - </div> -) - type WorkflowEditorHeaderSelectorProps = { workspaceId?: string widget?: WidgetInstance | null @@ -200,8 +200,6 @@ const WorkflowEditorHeaderSelector = ({ panelId, params: widget?.params ?? null, fallbackWidgetKey: 'editor_workflow', - loggerScope: 'workflow editor header', - activateWorkflow: false, }) const handleWorkflowChange = (workflowId: string) => { @@ -211,8 +209,8 @@ const WorkflowEditorHeaderSelector = ({ emitWorkflowSelectionChange({ panelId, - widgetKey: widget?.key ?? undefined, workflowId, + widgetKey: widget?.key ?? 'editor_workflow', }) } diff --git a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx index a3e960989..366fba8a9 100644 --- a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx @@ -1,8 +1,8 @@ 'use client' -import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type ChangeEvent, useCallback, useMemo, useRef, useState } from 'react' import { Plus, Upload, Wrench } from 'lucide-react' -import { useLocale, useMessages } from 'next-intl' +import { useMessages } from 'next-intl' import { DropdownMenu, DropdownMenuContent, @@ -20,20 +20,18 @@ import { widgetHeaderMenuTextClassName, } from '@/components/widget-header-control' import { parseImportedCustomToolsFile } from '@/lib/custom-tools/import-export' +import { generateAvailableName } from '@/lib/naming' import { cn } from '@/lib/utils' +import { saveSavedEntityField, useEntityList } from '@/lib/yjs/use-entity-fields' import { useUserPermissionsContext, WorkspacePermissionsProvider, } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useCreateCustomTool, - useCustomTools, useDeleteCustomTool, 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' import type { PairColor } from '@/widgets/pair-colors' @@ -42,9 +40,13 @@ import { emitCustomToolSelectionChange, useCustomToolSelectionPersistence, } from '@/widgets/utils/custom-tool-selection' +import { + resolveEntityIdFromList, + usePersistResolvedEntityId, +} from '@/widgets/utils/entity-selection' +import { usePendingEntitySelection } from '@/widgets/utils/use-pending-entity-selection' import { CustomToolListItem } from '@/widgets/widgets/_shared/custom_tool/components/custom-tool-list-item' import { - CUSTOM_TOOL_EDITOR_WIDGET_KEY, CUSTOM_TOOL_LIST_WIDGET_KEY, resolveCustomToolId, } from '@/widgets/widgets/_shared/custom_tool/utils' @@ -55,32 +57,22 @@ const DEFAULT_CUSTOM_TOOL_NAME = 'newCustomTool' const sortCustomTools = (tools: CustomToolDefinition[]) => [...tools].sort((a, b) => a.title.localeCompare(b.title)) -const buildNewCustomToolDraft = (tools: CustomToolDefinition[]) => { - const existingTitles = new Set( - tools.map((tool) => tool.title.trim()).filter((title): title is string => Boolean(title)) - ) - - let nextTitle = DEFAULT_CUSTOM_TOOL_NAME - let suffix = 2 - - while (existingTitles.has(nextTitle)) { - nextTitle = `${DEFAULT_CUSTOM_TOOL_NAME}${suffix}` - suffix += 1 - } +const DEFAULT_CUSTOM_TOOL_SCHEMA = { + type: 'function', + function: { + description: '', + parameters: { + type: 'object', + properties: {}, + required: [], + }, + }, +} +const buildNewCustomToolDraft = (title = DEFAULT_CUSTOM_TOOL_NAME) => { return { - title: nextTitle, - schema: { - type: 'function', - function: { - description: '', - parameters: { - type: 'object', - properties: {}, - required: [], - }, - }, - }, + title, + schema: DEFAULT_CUSTOM_TOOL_SCHEMA, code: '', } } @@ -100,7 +92,6 @@ function CustomToolCreateMenu({ onCreateCustomTool?: () => void onImportCustomTools?: (content: string, filename?: string) => Promise<void> | void }) { - const locale = useLocale() as LocaleCode const copy = useMessages().workspace.widgets.customToolList.createMenu const fileInputRef = useRef<HTMLInputElement>(null) @@ -204,13 +195,28 @@ function CustomToolListHeaderRight({ const permissions = useUserPermissionsContext() const createToolMutation = useCreateCustomTool() const importMutation = useImportCustomTools() - const storedTools = useCustomToolsStore((state) => - workspaceId ? state.getAllTools(workspaceId) : [] - ) const resolvedPairColor = (pairColor ?? 'gray') as PairColor const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) const setPairContext = useSetPairColorContext() + const { members } = useEntityList('custom_tool', workspaceId) + + const selectTool = useCallback( + (createdToolId: string) => { + if (isLinkedToColorPair) { + setPairContext(resolvedPairColor, { customToolId: createdToolId }) + return + } + + emitCustomToolSelectionChange({ + customToolId: createdToolId, + panelId, + widgetKey: CUSTOM_TOOL_LIST_WIDGET_KEY, + }) + }, + [isLinkedToColorPair, panelId, resolvedPairColor, setPairContext] + ) + const selectToolWhenListed = usePendingEntitySelection(members, selectTool) const handleCreateTool = useCallback(() => { if (!workspaceId || !permissions.canEdit) return @@ -218,7 +224,12 @@ function CustomToolListHeaderRight({ void createToolMutation .mutateAsync({ workspaceId, - tool: buildNewCustomToolDraft(storedTools), + tool: buildNewCustomToolDraft( + generateAvailableName( + members.map((member) => member.entityName), + DEFAULT_CUSTOM_TOOL_NAME + ) + ), }) .then((createdTools) => { const createdTool = createdTools[0] @@ -229,35 +240,12 @@ function CustomToolListHeaderRight({ throw new Error('Created custom tool is missing an id') } - if (isLinkedToColorPair) { - setPairContext(resolvedPairColor, { customToolId: createdToolId }) - return - } - - emitCustomToolSelectionChange({ - customToolId: createdToolId, - panelId, - widgetKey: CUSTOM_TOOL_LIST_WIDGET_KEY, - }) - emitCustomToolSelectionChange({ - customToolId: createdToolId, - panelId, - widgetKey: CUSTOM_TOOL_EDITOR_WIDGET_KEY, - }) + selectToolWhenListed(createdToolId) }) .catch((error) => { console.error('Failed to create custom tool from list widget', error) }) - }, [ - createToolMutation, - isLinkedToColorPair, - panelId, - permissions.canEdit, - resolvedPairColor, - setPairContext, - storedTools, - workspaceId, - ]) + }, [createToolMutation, members, permissions.canEdit, selectToolWhenListed, workspaceId]) const handleImportCustomTools = useCallback( async (content: string) => { @@ -297,7 +285,6 @@ const ListCustomToolHeaderRight = ({ panelId?: string pairColor?: PairColor }) => { - const locale = useLocale() as LocaleCode const copy = useMessages().workspace.widgets.customToolList.header if (!workspaceId) { return <span className='text-muted-foreground text-xs'>{copy.explorer}</span> @@ -324,15 +311,10 @@ function ListCustomToolWidgetBodyInner({ panelId, }: WidgetComponentProps) { const workspaceId = context?.workspaceId ?? null - const locale = useLocale() as LocaleCode const copy = useMessages().workspace.widgets.customToolList.body const permissions = useUserPermissionsContext() - const { data: queryTools = [], isLoading, error } = useCustomTools(workspaceId ?? '') - const storedTools = useCustomToolsStore((state) => - workspaceId ? state.getAllTools(workspaceId) : [] - ) + const { members, isLoading, error } = useEntityList('custom_tool', workspaceId) const deleteToolMutation = useDeleteCustomTool() - const updateToolMutation = useUpdateCustomTool() const resolvedPairColor = (pairColor ?? 'gray') as PairColor const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) @@ -340,17 +322,39 @@ function ListCustomToolWidgetBodyInner({ const [deletingToolIds, setDeletingToolIds] = useState<Set<string>>(new Set()) const tools = useMemo( - () => sortCustomTools(queryTools.length > 0 ? queryTools : storedTools), - [queryTools, storedTools] + () => + sortCustomTools( + workspaceId + ? members.map((member) => ({ + id: member.entityId, + workspaceId, + userId: null, + title: member.entityName, + schema: DEFAULT_CUSTOM_TOOL_SCHEMA, + code: '', + })) + : [] + ), + [members, workspaceId] ) - const selectedToolId = useMemo(() => { - if (isLinkedToColorPair) { - return resolveCustomToolId({ pairContext, params }) - } + const requestedToolId = resolveCustomToolId({ + params, + pairContext: isLinkedToColorPair ? pairContext : null, + }) + const selectedToolId = resolveEntityIdFromList({ + requestedEntityId: requestedToolId, + entityIds: tools.map((tool) => tool.id), + useDefaultEntity: !isLinkedToColorPair, + }) - return resolveCustomToolId({ params }) - }, [isLinkedToColorPair, pairContext, params]) + usePersistResolvedEntityId({ + entityId: selectedToolId, + entityIdKey: 'customToolId', + onWidgetParamsChange, + pairColor: resolvedPairColor, + params, + }) useCustomToolSelectionPersistence({ onWidgetParamsChange, @@ -381,11 +385,6 @@ function ListCustomToolWidgetBodyInner({ ...currentParams, customToolId, }) - emitCustomToolSelectionChange({ - customToolId, - panelId, - widgetKey: CUSTOM_TOOL_EDITOR_WIDGET_KEY, - }) }, [ isLinkedToColorPair, @@ -398,18 +397,6 @@ function ListCustomToolWidgetBodyInner({ ] ) - useEffect(() => { - if (!selectedToolId) { - return - } - - if (tools.some((tool) => tool.id === selectedToolId)) { - return - } - - syncSelection(null) - }, [selectedToolId, syncSelection, tools]) - const handleDeleteTool = useCallback( async (customToolId: string) => { if (!workspaceId || !permissions.canEdit) return @@ -419,9 +406,7 @@ function ListCustomToolWidgetBodyInner({ try { await deleteToolMutation.mutateAsync({ workspaceId, toolId: customToolId }) - if (selectedToolId === customToolId) { - syncSelection(null) - } + if (selectedToolId === customToolId) syncSelection(null) } finally { setDeletingToolIds((prev) => { const next = new Set(prev) @@ -437,15 +422,9 @@ function ListCustomToolWidgetBodyInner({ async (customToolId: string, title: string) => { if (!workspaceId || !permissions.canEdit) return - await updateToolMutation.mutateAsync({ - workspaceId, - toolId: customToolId, - updates: { - title, - }, - }) + await saveSavedEntityField('custom_tool', customToolId, workspaceId, 'title', title) }, - [permissions.canEdit, updateToolMutation, workspaceId] + [permissions.canEdit, workspaceId] ) if (isLoading && tools.length === 0) { @@ -457,11 +436,7 @@ function ListCustomToolWidgetBodyInner({ } if (error && tools.length === 0) { - return ( - <WidgetStateMessage - message={error instanceof Error ? error.message : copy.failedToLoadCustomTools} - /> - ) + return <WidgetStateMessage message={error || copy.failedToLoadCustomTools} /> } return ( @@ -490,7 +465,6 @@ function ListCustomToolWidgetBodyInner({ const ListCustomToolWidgetBody = (props: WidgetComponentProps) => { const workspaceId = props.context?.workspaceId ?? null - const locale = useLocale() as LocaleCode const copy = useMessages().workspace.widgets.customToolList.body if (!workspaceId) { return <WidgetStateMessage message={copy.selectWorkspace} /> 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 3bcd48007..0a84d8bb3 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,24 +1,24 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { useLocale, useMessages } from 'next-intl' +import { useMessages } from 'next-intl' import { LoadingAgent } from '@/components/ui/loading-agent' +import { buildSavedEntityDescriptor } from '@/lib/copilot/review-sessions/identity' +import { getEntityFields } from '@/lib/yjs/entity-session' +import { bootstrapYjsProvider } from '@/lib/yjs/provider' +import { saveSavedEntityField, useEntityList } from '@/lib/yjs/use-entity-fields' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { - useCreateIndicator, - useDeleteIndicator, - useIndicators, - useUpdateIndicator, -} from '@/hooks/queries/indicators' +import { useCreateIndicator, useDeleteIndicator } from '@/hooks/queries/indicators' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' -import { useIndicatorsStore } from '@/stores/indicators/store' import type { IndicatorDefinition } from '@/stores/indicators/types' import type { PairColor } from '@/widgets/pair-colors' import type { WidgetComponentProps } from '@/widgets/types' import { - emitIndicatorSelectionChange, - useIndicatorSelectionPersistence, -} from '@/widgets/utils/indicator-selection' + resolveEntityIdFromList, + usePersistResolvedEntityId, +} from '@/widgets/utils/entity-selection' +import { useIndicatorSelectionPersistence } from '@/widgets/utils/indicator-selection' +import { usePendingEntitySelection } from '@/widgets/utils/use-pending-entity-selection' import { getIndicatorIdFromParams } from '@/widgets/widgets/editor_indicator/utils' import { IndicatorListItem } from './components/indicator-list-item' @@ -35,16 +35,14 @@ export function IndicatorList({ panelId, pairColor = 'gray', }: WidgetComponentProps) { - const locale = useLocale() const copy = useMessages().workspace.widgets.indicatorList const workspaceId = context?.workspaceId ?? null const permissions = useUserPermissionsContext() const [copyingIds, setCopyingIds] = useState<Set<string>>(new Set()) const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set()) - const { data: indicators = [], isLoading, error } = useIndicators(workspaceId ?? '') + const { members, isLoading, error } = useEntityList('indicator', workspaceId) const createMutation = useCreateIndicator() const deleteMutation = useDeleteIndicator() - const updateMutation = useUpdateIndicator() const resolvedPairColor = (pairColor ?? 'gray') as PairColor const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) @@ -55,6 +53,7 @@ export function IndicatorList({ panelId, params, pairColor: resolvedPairColor, + scopeKey: 'list_indicator', onIndicatorSelect: (indicatorId) => { if (!isLinkedToColorPair) return if (pairContext?.indicatorId === indicatorId) return @@ -62,18 +61,37 @@ export function IndicatorList({ }, }) - const storedIndicators = useIndicatorsStore((state) => - state.getAllIndicators(workspaceId ?? undefined) + const listIndicators = useMemo<IndicatorDefinition[]>( + () => + workspaceId + ? members.map((member) => ({ + id: member.entityId, + workspaceId, + userId: null, + name: member.entityName, + color: member.color, + pineCode: '', + })) + : [], + [members, workspaceId] ) - const listIndicators = indicators.length > 0 ? indicators : storedIndicators + const requestedIndicatorId = isLinkedToColorPair + ? (pairContext?.indicatorId ?? null) + : getIndicatorIdFromParams(params) + const selectedIndicatorId = resolveEntityIdFromList({ + requestedEntityId: requestedIndicatorId, + entityIds: listIndicators.map((indicator) => indicator.id), + useDefaultEntity: !isLinkedToColorPair, + }) - const selectedIndicatorId = useMemo(() => { - if (isLinkedToColorPair) { - return pairContext?.indicatorId ?? null - } - return getIndicatorIdFromParams(params) - }, [isLinkedToColorPair, pairContext?.indicatorId, params]) + usePersistResolvedEntityId({ + entityId: selectedIndicatorId, + entityIdKey: 'indicatorId', + onWidgetParamsChange, + pairColor: resolvedPairColor, + params, + }) const handleSelect = useCallback( (indicatorId: string | null) => { @@ -92,12 +110,6 @@ export function IndicatorList({ indicatorId, }) } - - emitIndicatorSelectionChange({ - indicatorId, - panelId, - widgetKey: 'editor_indicator', - }) }, [ isLinkedToColorPair, @@ -110,6 +122,8 @@ export function IndicatorList({ ] ) + const selectIndicatorWhenListed = usePendingEntitySelection(members, handleSelect) + const handleDelete = useCallback( async (indicatorId: string) => { if (!workspaceId || !permissions.canEdit) return @@ -119,9 +133,7 @@ export function IndicatorList({ try { await deleteMutation.mutateAsync({ workspaceId, indicatorId }) - if (selectedIndicatorId === indicatorId) { - handleSelect(null) - } + if (selectedIndicatorId === indicatorId) handleSelect(null) } finally { setDeletingIds((prev) => { const next = new Set(prev) @@ -136,13 +148,9 @@ export function IndicatorList({ const handleRename = useCallback( async (indicatorId: string, name: string) => { if (!workspaceId || !permissions.canEdit) return - await updateMutation.mutateAsync({ - workspaceId, - indicatorId, - updates: { name }, - }) + await saveSavedEntityField('indicator', indicatorId, workspaceId, 'name', name) }, - [permissions.canEdit, updateMutation, workspaceId] + [permissions.canEdit, workspaceId] ) const handleCopy = useCallback( @@ -154,11 +162,25 @@ export function IndicatorList({ try { const copiedName = `${indicator.name || copy.listItem.untitledIndicator} (Copy)` + const sourceSession = await bootstrapYjsProvider( + buildSavedEntityDescriptor('indicator', indicator.id, workspaceId), + undefined, + 'read' + ) + let pineCode = '' + try { + pineCode = getEntityFields(sourceSession.doc, 'indicator').pineCode ?? '' + } finally { + sourceSession.provider.disconnect() + sourceSession.provider.destroy() + sourceSession.doc.destroy() + } + const createdIndicators = await createMutation.mutateAsync({ workspaceId, indicator: { name: copiedName, - pineCode: indicator.pineCode ?? '', + pineCode, }, }) const copiedIndicatorId = @@ -170,7 +192,7 @@ export function IndicatorList({ throw new Error('Created indicator copy is missing an id') } - handleSelect(copiedIndicatorId) + selectIndicatorWhenListed(copiedIndicatorId) } finally { setCopyingIds((prev) => { const next = new Set(prev) @@ -179,7 +201,13 @@ export function IndicatorList({ }) } }, - [createMutation, handleSelect, permissions.canEdit, workspaceId] + [ + copy.listItem.untitledIndicator, + createMutation, + selectIndicatorWhenListed, + permissions.canEdit, + workspaceId, + ] ) if (isLoading) { @@ -190,11 +218,8 @@ export function IndicatorList({ ) } - const errorMessage = - error instanceof Error ? error.message : error ? copy.body.failedToLoadIndicators : null - - if (errorMessage) { - return <IndicatorListMessage message={errorMessage} /> + if (error) { + return <IndicatorListMessage message={error} /> } return ( diff --git a/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx b/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx index f4720724c..c1d32d1ed 100644 --- a/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx @@ -2,44 +2,31 @@ import { useCallback } from 'react' import { ListChecks } from 'lucide-react' -import { useLocale, useMessages } from 'next-intl' +import { useMessages } from 'next-intl' import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' import { parseImportedIndicatorsFile } from '@/lib/indicators/import-export' +import { generateAvailableName } from '@/lib/naming' +import { useEntityList } from '@/lib/yjs/use-entity-fields' import { useUserPermissionsContext, WorkspacePermissionsProvider, } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useCreateIndicator, useImportIndicators } from '@/hooks/queries/indicators' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' -import { useIndicatorsStore } from '@/stores/indicators/store' -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 { usePendingEntitySelection } from '@/widgets/utils/use-pending-entity-selection' 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 existingNames = new Set( - indicators.map((indicator) => indicator.name.trim()).filter((name) => name.length > 0) - ) - - let nextName = defaults.name - let suffix = 2 - - while (existingNames.has(nextName)) { - nextName = `${defaults.name} ${suffix}` - suffix += 1 - } - +const buildNewIndicator = (defaults: { name: string }) => { return { - name: nextName, - color: '', + name: defaults.name, pineCode: '', - inputMeta: undefined, } } @@ -52,18 +39,32 @@ const IndicatorListHeaderRight = ({ panelId?: string pairColor?: PairColor }) => { - const locale = useLocale() const copy = useMessages().workspace.widgets const permissions = useUserPermissionsContext() const createIndicatorMutation = useCreateIndicator() const importMutation = useImportIndicators() - const storedIndicators = useIndicatorsStore((state) => - workspaceId ? state.getAllIndicators(workspaceId) : [] - ) const resolvedPairColor = (pairColor ?? 'gray') as PairColor const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) const setPairContext = useSetPairColorContext() + const { members } = useEntityList('indicator', workspaceId) + + const selectIndicator = useCallback( + (createdIndicatorId: string) => { + if (isLinkedToColorPair) { + setPairContext(resolvedPairColor, { indicatorId: createdIndicatorId }) + return + } + + emitIndicatorSelectionChange({ + indicatorId: createdIndicatorId, + panelId, + widgetKey: 'list_indicator', + }) + }, + [isLinkedToColorPair, panelId, resolvedPairColor, setPairContext] + ) + const selectIndicatorWhenListed = usePendingEntitySelection(members, selectIndicator) const handleCreateIndicator = useCallback(() => { if (!workspaceId || !permissions.canEdit) return @@ -71,8 +72,11 @@ const IndicatorListHeaderRight = ({ void createIndicatorMutation .mutateAsync({ workspaceId, - indicator: buildNewIndicator(storedIndicators, { - name: copy.indicatorList.createMenu.newIndicator, + indicator: buildNewIndicator({ + name: generateAvailableName( + members.map((member) => member.entityName), + copy.indicatorList.createMenu.newIndicator + ), }), }) .then((createdIndicators) => { @@ -84,33 +88,17 @@ const IndicatorListHeaderRight = ({ throw new Error('Created indicator is missing an id') } - if (isLinkedToColorPair) { - setPairContext(resolvedPairColor, { indicatorId: createdIndicatorId }) - return - } - - emitIndicatorSelectionChange({ - indicatorId: createdIndicatorId, - panelId, - widgetKey: 'list_indicator', - }) - emitIndicatorSelectionChange({ - indicatorId: createdIndicatorId, - panelId, - widgetKey: 'editor_indicator', - }) + selectIndicatorWhenListed(createdIndicatorId) }) .catch((error) => { console.error('Failed to create indicator from list widget', error) }) }, [ createIndicatorMutation, - isLinkedToColorPair, - panelId, + copy.indicatorList.createMenu.newIndicator, + members, permissions.canEdit, - resolvedPairColor, - setPairContext, - storedIndicators, + selectIndicatorWhenListed, workspaceId, ]) @@ -152,7 +140,6 @@ const ListIndicatorHeaderRight = ({ panelId?: string pairColor?: PairColor }) => { - const locale = useLocale() const copy = useMessages().workspace.widgets.indicatorList if (!workspaceId) { return <span className='text-muted-foreground text-xs'>{copy.header.explorer}</span> @@ -172,7 +159,6 @@ const ListIndicatorHeaderRight = ({ } const ListIndicatorWidgetBody = (props: WidgetComponentProps) => { - const locale = useLocale() const copy = useMessages().workspace.widgets.indicatorList const workspaceId = props.context?.workspaceId ?? null if (!workspaceId) { diff --git a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx index 2cfc1d003..a64eb7db4 100644 --- a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx @@ -3,7 +3,6 @@ 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, AlertDialogCancel, @@ -31,18 +30,22 @@ import { widgetHeaderMenuTextClassName, } from '@/components/widget-header-control' import { cn } from '@/lib/utils' +import { saveSavedEntityField, useEntityList } from '@/lib/yjs/use-entity-fields' import { useUserPermissionsContext, WorkspacePermissionsProvider, } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useMcpTools } from '@/hooks/use-mcp-tools' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' -import { useMcpServersStore } from '@/stores/mcp-servers/store' -import type { McpServerWithStatus } from '@/stores/mcp-servers/types' import type { PairColor } from '@/widgets/pair-colors' import type { DashboardWidgetDefinition, WidgetComponentProps } from '@/widgets/types' +import { + resolveEntityIdFromList, + usePersistResolvedEntityId, +} from '@/widgets/utils/entity-selection' import { MCP_SERVER_DEFAULTS } from '@/widgets/utils/mcp-defaults' import { emitMcpSelectionChange, useMcpSelectionPersistence } from '@/widgets/utils/mcp-selection' +import { usePendingEntitySelection } from '@/widgets/utils/use-pending-entity-selection' import { resolveMcpServerId } from '@/widgets/widgets/_shared/mcp/utils' const buildDefaultMcpServer = (name: string) => ({ @@ -52,23 +55,50 @@ const buildDefaultMcpServer = (name: string) => ({ transport: 'streamable-http' as const, }) +async function createMcpServer( + workspaceId: string, + config: ReturnType<typeof buildDefaultMcpServer> +) { + const response = await fetch('/api/mcp/servers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...config, workspaceId }), + }) + const data = await response.json() + if (!response.ok) throw new Error(data.error || 'Failed to create MCP server') + const serverId = typeof data?.data?.serverId === 'string' ? data.data.serverId : null + if (!serverId) throw new Error('Created MCP server is missing an id') + return serverId +} + +async function deleteMcpServer(workspaceId: string, serverId: string) { + const response = await fetch( + `/api/mcp/servers?serverId=${encodeURIComponent(serverId)}&workspaceId=${encodeURIComponent(workspaceId)}`, + { method: 'DELETE' } + ) + const data = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(data.error || 'Failed to delete MCP server') +} + const WidgetMessage = ({ message }: { message: string }) => ( <div className='flex h-full w-full items-center justify-center px-4 text-center text-muted-foreground text-xs'> {message} </div> ) -const getServerName = (server: McpServerWithStatus, fallback: string) => server.name || fallback - -const getServerIconColor = (status?: McpServerWithStatus['connectionStatus']) => { - if (status === 'connected') { - return '#10b981' - } +type McpServerListEntry = { + id: string + workspaceId: string + name: string + enabled: boolean + connectionStatus?: string +} - if (status === 'error') { - return '#ef4444' - } +const getServerName = (server: McpServerListEntry, fallback: string) => server.name || fallback +const getServerIconColor = (status?: string) => { + if (status === 'connected') return '#10b981' + if (status === 'error') return '#ef4444' return '#64748b' } @@ -123,49 +153,34 @@ const ListMcpHeaderRightContent = ({ const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) const setPairContext = useSetPairColorContext() - const createServer = useMcpServersStore((state) => state.createServer) - - const handleCreateServer = useCallback(() => { - if (!workspaceId || !permissions.canEdit) return + const { members } = useEntityList('mcp_server', workspaceId) - void createServer(workspaceId, buildDefaultMcpServer(copy.defaults.newMcpServerName)) - .then((createdServer) => { - const createdServerId = - createdServer && typeof createdServer.id === 'string' ? createdServer.id : null + const selectServer = useCallback( + (serverId: string) => { + if (isLinkedToColorPair) { + setPairContext(resolvedPairColor, { mcpServerId: serverId }) + return + } - if (!createdServerId) { - throw new Error('Created MCP server is missing an id') - } + emitMcpSelectionChange({ + serverId, + panelId, + widgetKey: 'list_mcp', + }) + }, + [isLinkedToColorPair, panelId, resolvedPairColor, setPairContext] + ) + const selectServerWhenListed = usePendingEntitySelection(members, selectServer) - if (isLinkedToColorPair) { - setPairContext(resolvedPairColor, { mcpServerId: createdServerId }) - return - } + const handleCreateServer = useCallback(() => { + if (!workspaceId || !permissions.canEdit) return - emitMcpSelectionChange({ - serverId: createdServerId, - panelId, - widgetKey: 'list_mcp', - }) - emitMcpSelectionChange({ - serverId: createdServerId, - panelId, - widgetKey: 'editor_mcp', - }) - }) + void createMcpServer(workspaceId, buildDefaultMcpServer(copy.defaults.newMcpServerName)) + .then(selectServerWhenListed) .catch((error) => { console.error('Failed to create MCP server from list widget', error) }) - }, [ - createServer, - copy.defaults.newMcpServerName, - isLinkedToColorPair, - panelId, - permissions.canEdit, - resolvedPairColor, - setPairContext, - workspaceId, - ]) + }, [copy.defaults.newMcpServerName, permissions.canEdit, selectServerWhenListed, workspaceId]) return ( <McpCreateMenu @@ -213,54 +228,47 @@ const ListMcpWidgetContent = ({ const workspaceId = context?.workspaceId ?? null const copy = useMessages().workspace.widgets.mcpList const permissions = useUserPermissionsContext() - const [hasRequestedLoad, setHasRequestedLoad] = useState(false) const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set()) - const { servers, isLoading, error, fetchServers, deleteServer, renameServer } = - useMcpServersStore( - (state) => ({ - servers: state.servers, - isLoading: state.isLoading, - error: state.error, - fetchServers: state.fetchServers, - deleteServer: state.deleteServer, - renameServer: state.renameServer, - }), - shallow - ) + const { members, isLoading, error } = useEntityList('mcp_server', workspaceId) const { refreshTools } = useMcpTools(workspaceId ?? '') const resolvedPairColor = (pairColor ?? 'gray') as PairColor const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) const setPairContext = useSetPairColorContext() - const workspaceServers = useMemo( + const workspaceServers = useMemo<McpServerListEntry[]>( () => workspaceId - ? servers - .filter((server) => server.workspaceId === workspaceId && !server.deletedAt) + ? members + .map((member) => ({ + id: member.entityId, + workspaceId, + name: member.entityName, + enabled: member.enabled !== false, + connectionStatus: member.connectionStatus, + })) .sort((a, b) => getServerName(a, '').localeCompare(getServerName(b, ''))) : [], - [servers, workspaceId] + [members, workspaceId] ) - const selectedServerId = resolveMcpServerId({ + const requestedServerId = resolveMcpServerId({ params, pairContext: isLinkedToColorPair ? pairContext : null, }) - const selectedServer = selectedServerId - ? (workspaceServers.find((server) => server.id === selectedServerId) ?? null) - : null - - useEffect(() => { - if (!workspaceId || workspaceServers.length > 0) { - return - } + const selectedServerId = resolveEntityIdFromList({ + requestedEntityId: requestedServerId, + entityIds: workspaceServers.map((server) => server.id), + useDefaultEntity: !isLinkedToColorPair, + }) - setHasRequestedLoad(true) - fetchServers(workspaceId).catch((fetchError) => { - console.error('Failed to load MCP servers for list widget', fetchError) - }) - }, [fetchServers, workspaceId, workspaceServers.length]) + usePersistResolvedEntityId({ + entityId: selectedServerId, + entityIdKey: 'mcpServerId', + onWidgetParamsChange, + pairColor: resolvedPairColor, + params, + }) useMcpSelectionPersistence({ onWidgetParamsChange, @@ -275,43 +283,6 @@ const ListMcpWidgetContent = ({ }, }) - useEffect(() => { - if (!selectedServerId || selectedServer || !hasRequestedLoad) { - return - } - - const currentParams = - params && typeof params === 'object' ? (params as Record<string, unknown>) : {} - - if (isLinkedToColorPair) { - if (pairContext?.mcpServerId !== null) { - setPairContext(resolvedPairColor, { mcpServerId: null }) - } - return - } - - onWidgetParamsChange?.({ - ...currentParams, - mcpServerId: null, - }) - emitMcpSelectionChange({ - serverId: null, - panelId, - widgetKey: 'editor_mcp', - }) - }, [ - hasRequestedLoad, - isLinkedToColorPair, - onWidgetParamsChange, - panelId, - pairContext?.mcpServerId, - params, - resolvedPairColor, - selectedServer, - selectedServerId, - setPairContext, - ]) - const handleSelectServer = useCallback( (serverId: string | null) => { if (isLinkedToColorPair) { @@ -328,12 +299,6 @@ const ListMcpWidgetContent = ({ ...currentParams, mcpServerId: serverId, }) - - emitMcpSelectionChange({ - serverId, - panelId, - widgetKey: 'editor_mcp', - }) }, [ isLinkedToColorPair, @@ -351,10 +316,10 @@ const ListMcpWidgetContent = ({ async (serverId: string, name: string) => { if (!workspaceId || !permissions.canEdit) return - await renameServer(workspaceId, serverId, name) + await saveSavedEntityField('mcp_server', serverId, workspaceId, 'name', name) await refreshTools() }, - [permissions.canEdit, refreshTools, renameServer, workspaceId] + [permissions.canEdit, refreshTools, workspaceId] ) const handleDeleteServer = useCallback( @@ -364,11 +329,9 @@ const ListMcpWidgetContent = ({ setDeletingIds((prev) => new Set(prev).add(serverId)) try { - await deleteServer(workspaceId, serverId) + await deleteMcpServer(workspaceId, serverId) + if (selectedServerId === serverId) handleSelectServer(null) await refreshTools() - if (selectedServerId === serverId) { - handleSelectServer(null) - } } catch (deleteError) { console.error('Failed to delete MCP server', deleteError) } finally { @@ -380,7 +343,6 @@ const ListMcpWidgetContent = ({ } }, [ - deleteServer, deletingIds, handleSelectServer, permissions.canEdit, @@ -390,10 +352,6 @@ const ListMcpWidgetContent = ({ ] ) - useEffect(() => { - setHasRequestedLoad(false) - }, [workspaceId]) - if (!workspaceId) { return <WidgetMessage message={copy.body.selectWorkspace} /> } @@ -402,7 +360,7 @@ const ListMcpWidgetContent = ({ return <WidgetMessage message={error} /> } - if ((isLoading || !hasRequestedLoad) && workspaceServers.length === 0) { + if (isLoading && workspaceServers.length === 0) { return ( <div className='flex h-full w-full items-center justify-center'> <LoadingAgent size='md' /> @@ -443,7 +401,7 @@ const McpServerListItem = ({ canEdit, isDeleting, }: { - server: McpServerWithStatus + server: McpServerListEntry isSelected: boolean onSelect: (serverId: string | null) => void onRename: (serverId: string, name: string) => Promise<void> 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 623c770c4..30400ee3e 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,25 +1,26 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' -import { useLocale, useMessages } from 'next-intl' +import { useCallback, useMemo, useState } from 'react' +import { useMessages } from 'next-intl' import { LoadingAgent } from '@/components/ui/loading-agent' import { SKILL_NAME_MAX_LENGTH } from '@/lib/skills/import-export' +import type { SkillDefinition } from '@/lib/skills/types' +import { saveSavedEntityField, useEntityList } from '@/lib/yjs/use-entity-fields' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { useDeleteSkill, useSkills, useUpdateSkill } from '@/hooks/queries/skills' +import { useDeleteSkill } from '@/hooks/queries/skills' import { formatTemplate } from '@/i18n/utils' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' -import { useSkillsStore } from '@/stores/skills/store' import type { PairColor } from '@/widgets/pair-colors' import type { WidgetComponentProps } from '@/widgets/types' import { - emitSkillSelectionChange, - useSkillSelectionPersistence, -} from '@/widgets/utils/skill-selection' + resolveEntityIdFromList, + usePersistResolvedEntityId, +} from '@/widgets/utils/entity-selection' +import { useSkillSelectionPersistence } from '@/widgets/utils/skill-selection' import { SkillListItem } from '@/widgets/widgets/_shared/skill/components/skill-list-item' import { normalizeSkillName, resolveSkillId, - SKILL_EDITOR_WIDGET_KEY, SKILL_LIST_WIDGET_KEY, } from '@/widgets/widgets/_shared/skill/utils' import { WidgetStateMessage } from '@/widgets/widgets/editor_indicator/components/widget-state-message' @@ -33,18 +34,13 @@ export function SkillList({ panelId, pairColor = 'gray', }: WidgetComponentProps) { - const locale = useLocale() const copy = useMessages().workspace.widgets.skillList const skillValidationCopy = useMessages().workspace.widgets.skillEditor.validation const workspaceId = context?.workspaceId ?? null const permissions = useUserPermissionsContext() const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set()) - const { data: querySkills = [], isLoading, error } = useSkills(workspaceId ?? '') + const { members, isLoading, error } = useEntityList('skill', workspaceId) const deleteMutation = useDeleteSkill() - const updateMutation = useUpdateSkill() - const storedSkills = useSkillsStore((state) => - workspaceId ? state.getAllSkills(workspaceId) : [] - ) const resolvedPairColor = (pairColor ?? 'gray') as PairColor const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) @@ -63,13 +59,39 @@ export function SkillList({ }, }) - const listSkills = querySkills.length > 0 ? querySkills : storedSkills - - const selectedSkillId = useMemo( - () => resolveSkillId({ params, pairContext: isLinkedToColorPair ? pairContext : null }), - [isLinkedToColorPair, pairContext, params] + const listSkills = useMemo<SkillDefinition[]>( + () => + workspaceId + ? members.map((member) => ({ + id: member.entityId, + workspaceId, + userId: null, + name: member.entityName, + description: '', + content: '', + })) + : [], + [members, workspaceId] ) + const requestedSkillId = resolveSkillId({ + params, + pairContext: isLinkedToColorPair ? pairContext : null, + }) + const selectedSkillId = resolveEntityIdFromList({ + requestedEntityId: requestedSkillId, + entityIds: listSkills.map((skill) => skill.id), + useDefaultEntity: !isLinkedToColorPair, + }) + + usePersistResolvedEntityId({ + entityId: selectedSkillId, + entityIdKey: 'skillId', + onWidgetParamsChange, + pairColor: resolvedPairColor, + params, + }) + const handleSelect = useCallback( (skillId: string | null) => { if (isLinkedToColorPair) { @@ -86,12 +108,6 @@ export function SkillList({ ...currentParams, skillId, }) - - emitSkillSelectionChange({ - skillId, - panelId, - widgetKey: SKILL_EDITOR_WIDGET_KEY, - }) }, [ isLinkedToColorPair, @@ -104,14 +120,6 @@ export function SkillList({ ] ) - useEffect(() => { - if (!selectedSkillId || listSkills.some((skill) => skill.id === selectedSkillId)) { - return - } - - handleSelect(null) - }, [handleSelect, listSkills, selectedSkillId]) - const handleDelete = useCallback( async (skillId: string) => { if (!workspaceId || !permissions.canEdit) return @@ -121,9 +129,7 @@ export function SkillList({ try { await deleteMutation.mutateAsync({ workspaceId, skillId }) - if (selectedSkillId === skillId) { - handleSelect(null) - } + if (selectedSkillId === skillId) handleSelect(null) } finally { setDeletingIds((prev) => { const next = new Set(prev) @@ -150,15 +156,9 @@ export function SkillList({ ) } - await updateMutation.mutateAsync({ - workspaceId, - skillId, - updates: { - name: normalizedName, - }, - }) + await saveSavedEntityField('skill', skillId, workspaceId, 'name', normalizedName) }, - [permissions.canEdit, updateMutation, workspaceId] + [permissions.canEdit, workspaceId] ) if (isLoading && listSkills.length === 0) { @@ -170,11 +170,7 @@ export function SkillList({ } if (error && listSkills.length === 0) { - return ( - <SkillListMessage - message={error instanceof Error ? error.message : copy.body.failedToLoadSkills} - /> - ) + return <SkillListMessage message={error || copy.body.failedToLoadSkills} /> } return ( diff --git a/apps/tradinggoose/widgets/widgets/list_skill/index.test.tsx b/apps/tradinggoose/widgets/widgets/list_skill/index.test.tsx index 183c6922c..b792c0707 100644 --- a/apps/tradinggoose/widgets/widgets/list_skill/index.test.tsx +++ b/apps/tradinggoose/widgets/widgets/list_skill/index.test.tsx @@ -6,7 +6,6 @@ import type { ButtonHTMLAttributes, 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 { listSkillWidget } from '@/widgets/widgets/list_skill' const mockCreateSkillMutation = vi.fn() @@ -84,7 +83,6 @@ describe('Skill List header controls', () => { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) - useSkillsStore.getState().resetAll() mockCreateSkillMutation.mockReturnValue(createMutationState()) mockImportSkillsMutation.mockReturnValue(createMutationState()) @@ -95,7 +93,6 @@ describe('Skill List header controls', () => { root.unmount() }) container.remove() - useSkillsStore.getState().resetAll() }) it('renders import inside Manage skills and removes export', async () => { diff --git a/apps/tradinggoose/widgets/widgets/list_skill/index.tsx b/apps/tradinggoose/widgets/widgets/list_skill/index.tsx index 4609f2141..abe81e218 100644 --- a/apps/tradinggoose/widgets/widgets/list_skill/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_skill/index.tsx @@ -2,53 +2,28 @@ import { useCallback } from 'react' import { ToolCase } from 'lucide-react' -import { useLocale, useMessages } from 'next-intl' +import { useMessages } from 'next-intl' import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' +import { generateAvailableName } from '@/lib/naming' import { parseImportedSkillsFile } from '@/lib/skills/import-export' +import { useEntityList } from '@/lib/yjs/use-entity-fields' import { useUserPermissionsContext, WorkspacePermissionsProvider, } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useCreateSkill, useImportSkills } from '@/hooks/queries/skills' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' -import { useSkillsStore } from '@/stores/skills/store' -import type { SkillDefinition } from '@/stores/skills/types' import type { PairColor } from '@/widgets/pair-colors' import type { DashboardWidgetDefinition, WidgetComponentProps } from '@/widgets/types' import { emitSkillSelectionChange } from '@/widgets/utils/skill-selection' -import { - SKILL_EDITOR_WIDGET_KEY, - SKILL_LIST_WIDGET_KEY, -} from '@/widgets/widgets/_shared/skill/utils' +import { usePendingEntitySelection } from '@/widgets/utils/use-pending-entity-selection' +import { SKILL_LIST_WIDGET_KEY } from '@/widgets/widgets/_shared/skill/utils' import { SkillCreateMenu } from '@/widgets/widgets/list_skill/components/skill-create-menu' import { SkillList, SkillListMessage, } from '@/widgets/widgets/list_skill/components/skill-list/skill-list' -const buildNewSkillDraft = ( - skills: SkillDefinition[], - defaults: { name: string; description: string; content: string } -) => { - const existingNames = new Set( - skills.map((skill) => skill.name.trim()).filter((name) => name.length > 0) - ) - - let nextName = defaults.name - let suffix = 2 - - while (existingNames.has(nextName)) { - nextName = `${defaults.name}-${suffix}` - suffix += 1 - } - - return { - name: nextName, - description: defaults.description, - content: defaults.content, - } -} - const SkillListHeaderRight = ({ workspaceId, panelId, @@ -58,18 +33,32 @@ const SkillListHeaderRight = ({ panelId?: string pairColor?: PairColor }) => { - const locale = useLocale() const copy = useMessages().workspace.widgets const permissions = useUserPermissionsContext() const createSkillMutation = useCreateSkill() const importMutation = useImportSkills() - const storedSkills = useSkillsStore((state) => - workspaceId ? state.getAllSkills(workspaceId) : [] - ) const resolvedPairColor = (pairColor ?? 'gray') as PairColor const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) const setPairContext = useSetPairColorContext() + const { members } = useEntityList('skill', workspaceId) + + const selectSkill = useCallback( + (createdSkillId: string) => { + if (isLinkedToColorPair) { + setPairContext(resolvedPairColor, { skillId: createdSkillId }) + return + } + + emitSkillSelectionChange({ + skillId: createdSkillId, + panelId, + widgetKey: SKILL_LIST_WIDGET_KEY, + }) + }, + [isLinkedToColorPair, panelId, resolvedPairColor, setPairContext] + ) + const selectSkillWhenListed = usePendingEntitySelection(members, selectSkill) const handleCreateSkill = useCallback(() => { if (!workspaceId || !permissions.canEdit) return @@ -77,7 +66,14 @@ const SkillListHeaderRight = ({ void createSkillMutation .mutateAsync({ workspaceId, - skill: buildNewSkillDraft(storedSkills, copy.skillEditor.defaults), + skill: { + name: generateAvailableName( + members.map((member) => member.entityName), + copy.skillEditor.defaults.name + ), + description: copy.skillEditor.defaults.description, + content: copy.skillEditor.defaults.content, + }, }) .then((createdSkills) => { const createdSkill = createdSkills[0] @@ -88,33 +84,19 @@ const SkillListHeaderRight = ({ throw new Error('Created skill is missing an id') } - if (isLinkedToColorPair) { - setPairContext(resolvedPairColor, { skillId: createdSkillId }) - return - } - - emitSkillSelectionChange({ - skillId: createdSkillId, - panelId, - widgetKey: SKILL_LIST_WIDGET_KEY, - }) - emitSkillSelectionChange({ - skillId: createdSkillId, - panelId, - widgetKey: SKILL_EDITOR_WIDGET_KEY, - }) + selectSkillWhenListed(createdSkillId) }) .catch((error) => { console.error('Failed to create skill from list widget', error) }) }, [ createSkillMutation, - isLinkedToColorPair, - panelId, + copy.skillEditor.defaults.content, + copy.skillEditor.defaults.description, + copy.skillEditor.defaults.name, + members, permissions.canEdit, - resolvedPairColor, - setPairContext, - storedSkills, + selectSkillWhenListed, workspaceId, ]) @@ -157,7 +139,6 @@ const ListSkillHeaderRight = ({ panelId?: string pairColor?: PairColor }) => { - const locale = useLocale() const copy = useMessages().workspace.widgets.skillList if (!workspaceId) { return <span className='text-muted-foreground text-xs'>{copy.header.explorer}</span> @@ -173,7 +154,6 @@ const ListSkillHeaderRight = ({ } const ListSkillWidgetBody = (props: WidgetComponentProps) => { - const locale = useLocale() const copy = useMessages().workspace.widgets.skillList const workspaceId = props.context?.workspaceId ?? null if (!workspaceId) { diff --git a/apps/tradinggoose/widgets/widgets/list_workflow/components/folder-tree/components/folder-item.tsx b/apps/tradinggoose/widgets/widgets/list_workflow/components/folder-tree/components/folder-item.tsx index c97aafb0f..9309c6f93 100644 --- a/apps/tradinggoose/widgets/widgets/list_workflow/components/folder-tree/components/folder-item.tsx +++ b/apps/tradinggoose/widgets/widgets/list_workflow/components/folder-tree/components/folder-item.tsx @@ -17,8 +17,8 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { createLogger } from '@/lib/logs/console/logger' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { useWorkspaceId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store' +import { useWorkspaceId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' const logger = createLogger('FolderItem') @@ -87,7 +87,7 @@ export function FolderItem({ // Set global drag state for validation in other components if (typeof window !== 'undefined') { - ; (window as any).currentDragFolderId = folder.id + ;(window as any).currentDragFolderId = folder.id } } @@ -99,7 +99,7 @@ export function FolderItem({ // Clear global drag state if (typeof window !== 'undefined') { - ; (window as any).currentDragFolderId = null + ;(window as any).currentDragFolderId = null } } @@ -199,7 +199,7 @@ export function FolderItem({ className={clsx( 'relative flex h-[14px] w-[14px] items-center justify-center rounded transition-colors hover:bg-card', dragOver && - 'before:pointer-events-none before:absolute before:inset-0 before:rounded before:bg-muted/20 before:ring-2 before:ring-muted-foreground/60' + 'before:pointer-events-none before:absolute before:inset-0 before:rounded before:bg-muted/20 before:ring-2 before:ring-muted-foreground/60' )} > {isExpanded ? ( @@ -221,11 +221,7 @@ export function FolderItem({ <AlertDialogHeader> <AlertDialogTitle>Delete folder?</AlertDialogTitle> <AlertDialogDescription> - Deleting this folder will permanently remove all associated workflows, logs, and - knowledge bases.{' '} - <span className='text-red-500 dark:text-red-500'> - This action cannot be undone. - </span> + Deleting this folder moves its workflows and child folders up one level. </AlertDialogDescription> </AlertDialogHeader> @@ -340,9 +336,7 @@ export function FolderItem({ <AlertDialogHeader> <AlertDialogTitle>Delete folder?</AlertDialogTitle> <AlertDialogDescription> - Deleting this folder will permanently remove all associated workflows, logs, and - knowledge bases.{' '} - <span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span> + Deleting this folder moves its workflows and child folders up one level. </AlertDialogDescription> </AlertDialogHeader> diff --git a/apps/tradinggoose/widgets/widgets/list_workflow/components/folder-tree/components/workflow-item.tsx b/apps/tradinggoose/widgets/widgets/list_workflow/components/folder-tree/components/workflow-item.tsx index d93b16351..06de41ca1 100644 --- a/apps/tradinggoose/widgets/widgets/list_workflow/components/folder-tree/components/workflow-item.tsx +++ b/apps/tradinggoose/widgets/widgets/list_workflow/components/folder-tree/components/workflow-item.tsx @@ -17,38 +17,19 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { createLogger } from '@/lib/logs/console/logger' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { Link, useRouter } from '@/i18n/navigation' import { useFolderStore, useIsWorkflowSelected } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import type { WorkflowMetadata } from '@/stores/workflows/registry/types' -import { Link, useRouter } from '@/i18n/navigation' +import type { WorkflowMetadataSeed } from '@/stores/workflows/registry/types' import { useWorkspaceId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' const logger = createLogger('WorkflowItem') -// Helper function to lighten a hex color -function lightenColor(hex: string, percent = 30): string { - // Remove # if present - const color = hex.replace('#', '') - - // Parse RGB values - const num = Number.parseInt(color, 16) - const r = Math.min(255, Math.floor((num >> 16) + ((255 - (num >> 16)) * percent) / 100)) - const g = Math.min( - 255, - Math.floor(((num >> 8) & 0x00ff) + ((255 - ((num >> 8) & 0x00ff)) * percent) / 100) - ) - const b = Math.min(255, Math.floor((num & 0x0000ff) + ((255 - (num & 0x0000ff)) * percent) / 100)) - - return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}` -} - interface WorkflowItemProps { - workflow: WorkflowMetadata + workflow: WorkflowMetadataSeed active: boolean - isMarketplace?: boolean - level: number isDragOver?: boolean - onSelect?: (workflow: WorkflowMetadata) => void + onSelect?: (workflow: WorkflowMetadataSeed) => void disableNavigation?: boolean canDelete?: boolean } @@ -56,8 +37,6 @@ interface WorkflowItemProps { export function WorkflowItem({ workflow, active, - isMarketplace, - level, isDragOver = false, onSelect, disableNavigation = false, @@ -72,13 +51,9 @@ export function WorkflowItem({ const [deleteState, setDeleteState] = useState<{ showDialog: boolean isDeleting: boolean - showTemplateChoice: boolean - publishedTemplates: { id: string; name: string }[] }>({ showDialog: false, isDeleting: false, - showTemplateChoice: false, - publishedTemplates: [], }) const dragStartedRef = useRef(false) const inputRef = useRef<HTMLInputElement>(null) @@ -110,7 +85,6 @@ export function WorkflowItem({ }, [isEditing]) const handleStartEdit = () => { - if (isMarketplace) return setIsEditing(true) setEditValue(workflow.name) } @@ -124,7 +98,7 @@ export function WorkflowItem({ setIsRenaming(true) try { - await updateWorkflow(workflow.id, { name: editValue.trim() }) + await updateWorkflow(workflow.id, { name: editValue.trim() }, workflow) logger.info(`Successfully renamed workflow from "${workflow.name}" to "${editValue.trim()}"`) setIsEditing(false) } catch (error) { @@ -164,87 +138,29 @@ export function WorkflowItem({ setDeleteState({ showDialog: false, isDeleting: false, - showTemplateChoice: false, - publishedTemplates: [], - }) - }, []) - - const checkPublishedTemplates = useCallback(async (workflowId: string) => { - const checkResponse = await fetch(`/api/workflows/${workflowId}?check-templates=true`, { - method: 'DELETE', }) - - if (!checkResponse.ok) { - throw new Error(`Failed to check templates: ${checkResponse.statusText}`) - } - - return checkResponse.json() }, []) const handleDeleteWorkflow = useCallback(async () => { - if (!userPermissions.canEdit || isMarketplace || !canDelete) return + if (!userPermissions.canEdit || !canDelete) return setDeleteState((prev) => ({ ...prev, isDeleting: true })) try { - const checkData = await checkPublishedTemplates(workflow.id) - - if (checkData?.hasPublishedTemplates) { - setDeleteState((prev) => ({ - ...prev, - isDeleting: false, - showTemplateChoice: true, - publishedTemplates: checkData.publishedTemplates || [], - })) - return - } - await removeWorkflow(workflow.id) resetDeleteState() } catch (error) { logger.error('Error deleting workflow:', error) setDeleteState((prev) => ({ ...prev, isDeleting: false })) } - }, [ - canDelete, - checkPublishedTemplates, - isMarketplace, - removeWorkflow, - resetDeleteState, - userPermissions.canEdit, - workflow.id, - ]) - - const handleTemplateAction = useCallback( - async (action: 'keep' | 'delete') => { - if (!userPermissions.canEdit || isMarketplace || !canDelete) return - - setDeleteState((prev) => ({ ...prev, isDeleting: true })) - - try { - await removeWorkflow(workflow.id, { templateAction: action }) - resetDeleteState() - } catch (error) { - logger.error('Error deleting workflow with template action:', error) - setDeleteState((prev) => ({ ...prev, isDeleting: false })) - } - }, - [ - canDelete, - isMarketplace, - removeWorkflow, - resetDeleteState, - userPermissions.canEdit, - workflow.id, - ] - ) + }, [canDelete, removeWorkflow, resetDeleteState, userPermissions.canEdit, workflow.id]) const handleDuplicateWorkflow = useCallback(async () => { - if (!userPermissions.canEdit || isMarketplace || isDuplicating) return + if (!userPermissions.canEdit || isDuplicating) return setIsDuplicating(true) try { - const duplicatedWorkflowId = await duplicateWorkflow(workflow.id) + const duplicatedWorkflowId = await duplicateWorkflow(workflow.id, workflow) if (!duplicatedWorkflowId) return const duplicatedWorkflow = useWorkflowRegistry.getState().workflows[duplicatedWorkflowId] @@ -270,10 +186,10 @@ export function WorkflowItem({ disableNavigation, duplicateWorkflow, isDuplicating, - isMarketplace, onSelect, router, userPermissions.canEdit, + workflow, workflow.id, workspaceId, ]) @@ -306,7 +222,7 @@ export function WorkflowItem({ } const handleDragStart = (e: React.DragEvent) => { - if (isMarketplace || isEditing) return + if (isEditing) return dragStartedRef.current = true setIsDragging(true) @@ -364,14 +280,10 @@ export function WorkflowItem({ )} > {workflow.name} - {isMarketplace && ' (Preview)'} </span> </TooltipTrigger> <TooltipContent side='top' align='start' sideOffset={10}> - <p> - {workflow.name} - {isMarketplace && ' (Preview)'} - </p> + <p>{workflow.name}</p> </TooltipContent> </Tooltip> ) : ( @@ -384,7 +296,6 @@ export function WorkflowItem({ )} > {workflow.name} - {isMarketplace && ' (Preview)'} </span> )} </> @@ -399,7 +310,7 @@ export function WorkflowItem({ isSelected && selectedWorkflows.size > 1 && !active && !isDragOver ? 'bg-muted' : '', isDragging ? 'opacity-50' : '' )} - draggable={!isMarketplace && !isEditing} + draggable={!isEditing} onDragStart={handleDragStart} onDragEnd={handleDragEnd} onMouseEnter={() => setIsHovered(true)} @@ -445,7 +356,7 @@ export function WorkflowItem({ </Link> )} - {!isMarketplace && !isEditing && isHovered && userPermissions.canEdit && ( + {!isEditing && isHovered && userPermissions.canEdit && ( <div className='flex items-center justify-center gap-1' onClick={(e) => e.stopPropagation()} @@ -485,8 +396,6 @@ export function WorkflowItem({ setDeleteState({ showDialog: true, isDeleting: false, - showTemplateChoice: false, - publishedTemplates: [], }) }} > @@ -508,77 +417,28 @@ export function WorkflowItem({ > <AlertDialogContent> <AlertDialogHeader> - <AlertDialogTitle> - {deleteState.showTemplateChoice ? 'Published Templates Found' : 'Delete workflow?'} - </AlertDialogTitle> - {deleteState.showTemplateChoice ? ( - <div className='space-y-3'> - <AlertDialogDescription> - This workflow has {deleteState.publishedTemplates.length} published template - {deleteState.publishedTemplates.length === 1 ? '' : 's'}: - </AlertDialogDescription> - {deleteState.publishedTemplates.length > 0 && ( - <ul className='list-disc space-y-1 pl-6'> - {deleteState.publishedTemplates.map((template) => ( - <li key={template.id}>{template.name}</li> - ))} - </ul> - )} - <AlertDialogDescription> - What would you like to do with the published template - {deleteState.publishedTemplates.length === 1 ? '' : 's'}? - </AlertDialogDescription> - </div> - ) : ( - <AlertDialogDescription> - Deleting this workflow will permanently remove all associated blocks, executions, - and configuration.{' '} - <span className='text-red-500 dark:text-red-500'> - This action cannot be undone. - </span> - </AlertDialogDescription> - )} + <AlertDialogTitle>Delete workflow?</AlertDialogTitle> + <AlertDialogDescription> + Deleting this workflow will permanently remove all associated blocks, executions, and + configuration.{' '} + <span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span> + </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter className='flex'> - {deleteState.showTemplateChoice ? ( - <div className='flex w-full gap-2'> - <Button - variant='outline' - onClick={() => handleTemplateAction('keep')} - disabled={deleteState.isDeleting} - className='h-9 flex-1 rounded-sm' - > - Keep templates - </Button> - <Button - onClick={() => handleTemplateAction('delete')} - disabled={deleteState.isDeleting} - className='h-9 flex-1 rounded-sm bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600' - > - {deleteState.isDeleting ? 'Deleting...' : 'Delete templates'} - </Button> - </div> - ) : ( - <> - <AlertDialogCancel - className='h-9 w-full rounded-sm' - disabled={deleteState.isDeleting} - > - Cancel - </AlertDialogCancel> - <Button - onClick={(e) => { - e.preventDefault() - handleDeleteWorkflow() - }} - disabled={deleteState.isDeleting} - className='h-9 w-full rounded-sm bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600' - > - {deleteState.isDeleting ? 'Deleting...' : 'Delete'} - </Button> - </> - )} + <AlertDialogCancel className='h-9 w-full rounded-sm' disabled={deleteState.isDeleting}> + Cancel + </AlertDialogCancel> + <Button + onClick={(e) => { + e.preventDefault() + handleDeleteWorkflow() + }} + disabled={deleteState.isDeleting} + className='h-9 w-full rounded-sm bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600' + > + {deleteState.isDeleting ? 'Deleting...' : 'Delete'} + </Button> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> diff --git a/apps/tradinggoose/widgets/widgets/list_workflow/components/folder-tree/folder-tree.tsx b/apps/tradinggoose/widgets/widgets/list_workflow/components/folder-tree/folder-tree.tsx index 4ac9e4fe9..2e2afcb31 100644 --- a/apps/tradinggoose/widgets/widgets/list_workflow/components/folder-tree/folder-tree.tsx +++ b/apps/tradinggoose/widgets/widgets/list_workflow/components/folder-tree/folder-tree.tsx @@ -4,26 +4,30 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import clsx from 'clsx' import { Skeleton } from '@/components/ui/skeleton' import { createLogger } from '@/lib/logs/console/logger' +import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import type { WorkflowMetadataSeed } from '@/stores/workflows/registry/types' import { useOptionalWorkflowRoute } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' import { FolderItem } from './components/folder-item' import { WorkflowItem } from './components/workflow-item' -import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import type { WorkflowMetadata } from '@/stores/workflows/registry/types' const logger = createLogger('FolderTree') +export type WorkflowListEntry = WorkflowMetadataSeed + interface FolderSectionProps { folder: FolderTreeNode level: number onCreateWorkflow: (folderId?: string) => void - workspaceId: string | null - onWorkflowSelect?: (workflow: WorkflowMetadata) => void + onWorkflowSelect?: (workflow: WorkflowListEntry) => void disableNavigation?: boolean - workflowsByFolder: Record<string, WorkflowMetadata[]> + workflowsByFolder: Record<string, WorkflowListEntry[]> expandedFolders: Set<string> activeWorkflowId: string | null - updateWorkflow: (id: string, updates: Partial<WorkflowMetadata>) => Promise<void> + updateWorkflow: ( + id: string, + updates: Partial<Pick<WorkflowListEntry, 'folderId'>> + ) => Promise<void> updateFolder: (id: string, updates: any) => Promise<any> canDeleteWorkflow: boolean renderFolderTree: ( @@ -37,7 +41,7 @@ interface FolderSectionProps { // Helper function to count visible items, excluding content of the last expanded folder const countVisibleItemsForLine = ( folder: FolderTreeNode, - workflowsByFolder: Record<string, WorkflowMetadata[]>, + workflowsByFolder: Record<string, WorkflowListEntry[]>, expandedFolders: Set<string> ): number => { if (!expandedFolders.has(folder.id)) { @@ -74,7 +78,6 @@ function FolderSection({ folder, level, onCreateWorkflow, - workspaceId, onWorkflowSelect, disableNavigation = false, workflowsByFolder, @@ -108,9 +111,9 @@ function FolderSection({ className={clsx( 'relative', isDragOver && - (isInvalidDrop - ? 'before:pointer-events-none before:absolute before:inset-0 before:rounded-sm before:border before:border-destructive/50 before:bg-destructive/15' - : 'before:pointer-events-none before:absolute before:inset-0 before:rounded-sm before:border before:border-muted-foreground/50 before:bg-muted/20') + (isInvalidDrop + ? 'before:pointer-events-none before:absolute before:inset-0 before:rounded-sm before:border before:border-destructive/50 before:bg-destructive/15' + : 'before:pointer-events-none before:absolute before:inset-0 before:rounded-sm before:border before:border-muted-foreground/50 before:bg-muted/20') )} > {/* Render folder */} @@ -147,7 +150,7 @@ function FolderSection({ {/* Render workflows in this folder */} {workflowsInFolder.length > 0 && ( <div> - {workflowsInFolder.map((workflow, index) => ( + {workflowsInFolder.map((workflow) => ( <div key={workflow.id} className='relative'> {/* Curved corner */} <div @@ -180,7 +183,6 @@ function FolderSection({ <WorkflowItem workflow={workflow} active={activeWorkflowId === workflow.id} - level={level} isDragOver={isAnyDragOver} onSelect={onWorkflowSelect} disableNavigation={disableNavigation} @@ -195,7 +197,7 @@ function FolderSection({ {/* Render child folders */} {folder.children.length > 0 && ( <div> - {folder.children.map((childFolder, index) => ( + {folder.children.map((childFolder) => ( <div key={childFolder.id} className='relative'> {/* Curved corner */} <div @@ -229,7 +231,6 @@ function FolderSection({ folder={childFolder} level={level + 1} onCreateWorkflow={onCreateWorkflow} - workspaceId={workspaceId} onWorkflowSelect={onWorkflowSelect} disableNavigation={disableNavigation} workflowsByFolder={workflowsByFolder} @@ -254,7 +255,10 @@ function FolderSection({ // Custom hook for drag and drop handling function useDragHandlers( - updateWorkflow: (id: string, updates: Partial<WorkflowMetadata>) => Promise<void>, + updateWorkflow: ( + id: string, + updates: Partial<Pick<WorkflowListEntry, 'folderId'>> + ) => Promise<void>, updateFolder: (id: string, updates: any) => Promise<any>, targetFolderId: string | null, // null for root logMessage?: string @@ -376,19 +380,17 @@ function useDragHandlers( } interface FolderTreeProps { - regularWorkflows: WorkflowMetadata[] - marketplaceWorkflows: WorkflowMetadata[] + regularWorkflows: WorkflowListEntry[] isLoading?: boolean onCreateWorkflow: (folderId?: string) => Promise<string | undefined> | undefined workspaceIdOverride?: string | null workflowIdOverride?: string | null - onWorkflowSelect?: (workflow: WorkflowMetadata) => void + onWorkflowSelect?: (workflow: WorkflowListEntry) => void disableNavigation?: boolean } export function FolderTree({ regularWorkflows, - marketplaceWorkflows, isLoading = false, onCreateWorkflow, workspaceIdOverride = null, @@ -407,10 +409,7 @@ export function FolderTree({ const getFolderPath = useFolderStore((state) => state.getFolderPath) const setExpanded = useFolderStore((state) => state.setExpanded) const folderTree = useFolderStore( - useCallback( - (state) => (workspaceId ? state.getFolderTree(workspaceId) : []), - [workspaceId] - ) + useCallback((state) => (workspaceId ? state.getFolderTree(workspaceId) : []), [workspaceId]) ) const hasLoadedFolders = useFolderStore((state) => workspaceId ? Boolean(state.loadedWorkspaces[workspaceId]) : false @@ -515,7 +514,7 @@ export function FolderTree({ acc[folderId].push(workflow) return acc }, - {} as Record<string, WorkflowMetadata[]> + {} as Record<string, WorkflowListEntry[]> ) const canDeleteWorkflow = regularWorkflows.length > 1 @@ -532,13 +531,12 @@ export function FolderTree({ level = 0, parentDragOver = false ): React.ReactNode[] => { - return nodes.map((folder, index) => ( + return nodes.map((folder) => ( <FolderSection key={folder.id} folder={folder} level={level} onCreateWorkflow={onCreateWorkflow} - workspaceId={workspaceId} onWorkflowSelect={onWorkflowSelect} disableNavigation={disableNavigation} workflowsByFolder={workflowsByFolder} @@ -584,9 +582,9 @@ export function FolderTree({ className={clsx( 'relative flex-1 ', rootDragOver && - (rootInvalidDrop - ? 'before:pointer-events-none before:absolute before:inset-0 before:rounded-sm before:border before:border-destructive/50 before:bg-destructive/15' - : 'before:pointer-events-none before:absolute before:inset-0 before:rounded-sm before:border before:border-muted-foreground/50 before:bg-muted/20'), + (rootInvalidDrop + ? 'before:pointer-events-none before:absolute before:inset-0 before:rounded-sm before:border before:border-destructive/50 before:bg-destructive/15' + : 'before:pointer-events-none before:absolute before:inset-0 before:rounded-sm before:border before:border-muted-foreground/50 before:bg-muted/20'), // Ensure minimum height for drag target when empty rootWorkflows.length === 0 ? 'min-h-8' : '' )} @@ -595,12 +593,11 @@ export function FolderTree({ onDrop={handleRootDrop} > <div className='space-y-1'> - {rootWorkflows.map((workflow, index) => ( + {rootWorkflows.map((workflow) => ( <WorkflowItem key={workflow.id} workflow={workflow} active={workflowId === workflow.id} - level={-1} isDragOver={rootDragOver} onSelect={onWorkflowSelect} disableNavigation={disableNavigation} @@ -609,15 +606,12 @@ export function FolderTree({ ))} {/* Empty state */} - {!showLoading && - regularWorkflows.length === 0 && - marketplaceWorkflows.length === 0 && - folderTree.length === 0 && ( - <div className='break-words px-2 py-1.5 pr-12 text-muted-foreground text-xs'> - No workflows or folders in {workspaceId ? 'this workspace' : 'your account'}. Create - one to get started. - </div> - )} + {!showLoading && regularWorkflows.length === 0 && folderTree.length === 0 && ( + <div className='break-words px-2 py-1.5 pr-12 text-muted-foreground text-xs'> + No workflows or folders in {workspaceId ? 'this workspace' : 'your account'}. Create + one to get started. + </div> + )} </div> </div> </div> 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 d4b3b0626..78d7bb818 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 @@ -31,11 +31,13 @@ const logger = createLogger('DashboardWorkflowCreateMenu') export interface DashboardWorkflowCreateMenuProps { workspaceId?: string | null + existingWorkflowNames: string[] onWorkflowCreated?: (workflowId: string) => void } export function DashboardWorkflowCreateMenu({ workspaceId, + existingWorkflowNames, onWorkflowCreated, }: DashboardWorkflowCreateMenuProps) { const [isCreatingFolder, setIsCreatingFolder] = useState(false) @@ -110,9 +112,6 @@ export function DashboardWorkflowCreateMenu({ } const parsedFile = JSON.parse(content) as unknown - const existingWorkflowNames = Object.values(useWorkflowRegistry.getState().workflows) - .filter((workflow) => workflow.workspaceId === workspaceId) - .map((workflow) => workflow.name) let importedSkillsBySourceName: | ReturnType<typeof buildImportedWorkflowSkillsLookup> @@ -149,7 +148,7 @@ export function DashboardWorkflowCreateMenu({ setIsImporting(false) } }, - [workspaceId, createWorkflow, importSkillsMutation, onWorkflowCreated] + [workspaceId, existingWorkflowNames, createWorkflow, importSkillsMutation, onWorkflowCreated] ) const handleImportWorkflow = useCallback(() => { diff --git a/apps/tradinggoose/widgets/widgets/list_workflow/index.tsx b/apps/tradinggoose/widgets/widgets/list_workflow/index.tsx index 2a3f8f8d8..974283927 100644 --- a/apps/tradinggoose/widgets/widgets/list_workflow/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_workflow/index.tsx @@ -1,30 +1,27 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { LayoutList } from 'lucide-react' -import { useLocale } from 'next-intl' -import { shallow } from 'zustand/shallow' +import { useMessages } from 'next-intl' import { LoadingAgent } from '@/components/ui/loading-agent' import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' +import { getStableVibrantColor } from '@/lib/colors' +import { useEntityList } from '@/lib/yjs/use-entity-fields' import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { useMessages } from 'next-intl' -import type { LocaleCode } from '@/i18n/utils' import { useSetPairColorContext } from '@/stores/dashboard/pair-store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import type { WorkflowMetadata } from '@/stores/workflows/registry/types' -import { WORKSPACE_BOOTSTRAP_CHANNEL } from '@/stores/workflows/registry/types' +import { useWorkflowWidgetState } from '@/widgets/hooks/use-workflow-widget-state' import type { PairColor } from '@/widgets/pair-colors' import type { DashboardWidgetDefinition, WidgetComponentProps } from '@/widgets/types' +import { usePersistResolvedEntityId } from '@/widgets/utils/entity-selection' +import { usePendingEntitySelection } from '@/widgets/utils/use-pending-entity-selection' +import { + emitWorkflowSelectionChange, + useWorkflowSelectionPersistence, +} from '@/widgets/utils/workflow-selection' import { WorkflowRouteProvider } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' import { DashboardWorkflowCreateMenu } from '@/widgets/widgets/list_workflow/components/workflow-create-menu' -import { FolderTree } from './components/folder-tree/folder-tree' - -const WORKFLOW_LIST_WORKFLOW_CREATED_EVENT = 'dashboard-workflow-list:workflow-created' - -type WorkflowListWorkflowCreatedDetail = { - workspaceId: string - workflowId: string -} +import { FolderTree, type WorkflowListEntry } from './components/folder-tree/folder-tree' const WidgetMessage = ({ message }: { message: string }) => ( <div className='flex h-full w-full items-center justify-center px-4 text-center text-muted-foreground text-xs'> @@ -34,185 +31,81 @@ const WidgetMessage = ({ message }: { message: string }) => ( const WorkflowListWidgetBody = ({ context, + params, + panelId, pairColor = 'gray', widget, onWidgetParamsChange, }: WidgetComponentProps) => { const workspaceId = context?.workspaceId ?? null - const locale = useLocale() as LocaleCode const copy = useMessages().workspace.widgets.workflowList - const resolvedPairColor = (pairColor ?? 'gray') as PairColor + const widgetParams = params ?? widget?.params ?? null + const widgetKey = widget?.key ?? 'workflow_list' + const { + channelId, + resolvedPairColor, + resolvedWorkflowId: selectedWorkflowId, + } = useWorkflowWidgetState({ + workspaceId: workspaceId ?? undefined, + pairColor, + widget, + panelId, + params: widgetParams, + fallbackWidgetKey: 'workflow_list', + }) const isLinkedToColorPair = resolvedPairColor !== 'gray' - const metadataChannelId = WORKSPACE_BOOTSTRAP_CHANNEL - const selectionChannelId = isLinkedToColorPair - ? `pair-${resolvedPairColor}` - : WORKSPACE_BOOTSTRAP_CHANNEL - const { workflows, metadataHydrationPhase, loadWorkflows, createWorkflow, activeWorkflowId } = - useWorkflowRegistry( - (state) => ({ - workflows: state.workflows, - metadataHydrationPhase: state.getHydration(metadataChannelId).phase, - loadWorkflows: state.loadWorkflows, - createWorkflow: state.createWorkflow, - activeWorkflowId: state.getActiveWorkflowId(selectionChannelId), - }), - shallow - ) - const [hasRequestedLoad, setHasRequestedLoad] = useState(false) - const [loadError, setLoadError] = useState<string | null>(null) + const { members, isLoading, error } = useEntityList('workflow', workspaceId) + const createWorkflow = useWorkflowRegistry((state) => state.createWorkflow) const [isCreatingWorkflow, setIsCreatingWorkflow] = useState(false) - const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null) const setPairContext = useSetPairColorContext() - const isLoading = metadataHydrationPhase === 'metadata-loading' - const paramsWorkflowId = useMemo(() => { - if (isLinkedToColorPair) return null - if (!widget || !widget.params || typeof widget.params !== 'object') return null - if (!('workflowId' in widget.params)) return null - const value = widget.params.workflowId - return typeof value === 'string' && value.trim().length > 0 ? value : null - }, [isLinkedToColorPair, widget?.params]) - - useEffect(() => { - if (!paramsWorkflowId) return - if (paramsWorkflowId === selectedWorkflowId) return - setSelectedWorkflowId(paramsWorkflowId) - }, [paramsWorkflowId, selectedWorkflowId]) - - const workspaceHasWorkflows = useMemo(() => { - if (!workspaceId) { - return false - } - return Object.values(workflows ?? {}).some((workflow) => workflow?.workspaceId === workspaceId) - }, [workflows, workspaceId]) - - useEffect(() => { - if (!workspaceId) { - setLoadError(null) - setHasRequestedLoad(false) - return - } - - if (workspaceHasWorkflows) { - setLoadError(null) - return - } - - let cancelled = false - setLoadError(null) - setHasRequestedLoad(true) - - loadWorkflows({ workspaceId, channelId: metadataChannelId }).catch((error) => { - if (!cancelled) { - console.error('Failed to load workflows for dashboard workflow list widget', error) - setLoadError(copy.body.unableToLoadWorkflows) - } - }) - - return () => { - cancelled = true - } - }, [workspaceId, workspaceHasWorkflows, loadWorkflows, metadataChannelId]) - - const hasInitialized = useMemo(() => { - if (!workspaceId) { - return true - } - if (workspaceHasWorkflows || Boolean(loadError)) { - return true - } - return hasRequestedLoad && !isLoading - }, [workspaceId, workspaceHasWorkflows, loadError, hasRequestedLoad, isLoading]) - - const { regularWorkflows, marketplaceWorkflows } = useMemo(() => { - const regular: WorkflowMetadata[] = [] - const marketplace: WorkflowMetadata[] = [] - - if (!workspaceId) { - return { regularWorkflows: regular, marketplaceWorkflows: marketplace } - } - - const sortByCreatedAt = (a: WorkflowMetadata, b: WorkflowMetadata) => - b.createdAt.getTime() - a.createdAt.getTime() - - Object.values(workflows ?? {}).forEach((workflow) => { - if (!workflow || workflow.workspaceId !== workspaceId) { - return - } - - if (workflow.marketplaceData?.status === 'temp') { - marketplace.push(workflow) - } else { - regular.push(workflow) - } - }) - - regular.sort(sortByCreatedAt) - marketplace.sort(sortByCreatedAt) - - return { regularWorkflows: regular, marketplaceWorkflows: marketplace } - }, [workflows, workspaceId]) - - useEffect(() => { - if (!selectedWorkflowId) { - return - } - - if (paramsWorkflowId && selectedWorkflowId === paramsWorkflowId) { - return - } - - if (!regularWorkflows.some((w) => w.id === selectedWorkflowId)) { - setSelectedWorkflowId(null) - } - }, [selectedWorkflowId, regularWorkflows, paramsWorkflowId]) - - useEffect(() => { - if (!workspaceId) { - return - } + useWorkflowSelectionPersistence({ + onWidgetParamsChange, + panelId, + pairColor: resolvedPairColor, + params: widgetParams, + scopeKey: widgetKey, + }) + + usePersistResolvedEntityId({ + entityId: selectedWorkflowId, + entityIdKey: 'workflowId', + onWidgetParamsChange, + pairColor: resolvedPairColor, + params: widgetParams, + }) + + // Workflows list newest-first; the projection's canonical name order is a + // deterministic base, not the workflow list's presentation order. + const regularWorkflows = useMemo<WorkflowListEntry[]>( + () => + workspaceId + ? [...members] + .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')) + .map((member) => { + return { + id: member.entityId, + name: member.entityName, + description: member.entityDescription ?? '', + color: member.color ?? getStableVibrantColor(member.entityId), + workspaceId, + folderId: member.folderId ?? null, + } + }) + : [], + [members, workspaceId] + ) - const handler = (event: Event) => { - const customEvent = event as CustomEvent<WorkflowListWorkflowCreatedDetail> - const detail = customEvent.detail - if (!detail || detail.workspaceId !== workspaceId || !detail.workflowId) { - return - } - setSelectedWorkflowId(detail.workflowId) + const selectWorkflowId = useCallback( + (workflowId: string) => { if (isLinkedToColorPair) { - setPairContext(resolvedPairColor, { workflowId: detail.workflowId }) + setPairContext(resolvedPairColor, { workflowId }) } else { - onWidgetParamsChange?.({ workflowId: detail.workflowId }) + emitWorkflowSelectionChange({ panelId, workflowId, widgetKey }) } - } - - window.addEventListener(WORKFLOW_LIST_WORKFLOW_CREATED_EVENT, handler as EventListener) - return () => { - window.removeEventListener(WORKFLOW_LIST_WORKFLOW_CREATED_EVENT, handler as EventListener) - } - }, [ - workspaceId, - resolvedPairColor, - isLinkedToColorPair, - setPairContext, - setSelectedWorkflowId, - onWidgetParamsChange, - ]) - - const effectiveActiveWorkflowId = useMemo(() => { - if (selectedWorkflowId) { - return selectedWorkflowId - } - - if (!workspaceId) { - return null - } - - if (activeWorkflowId && workflows?.[activeWorkflowId]?.workspaceId === workspaceId) { - return activeWorkflowId - } - - return regularWorkflows[0]?.id ?? null - }, [selectedWorkflowId, activeWorkflowId, regularWorkflows, workspaceId, workflows]) + }, + [resolvedPairColor, isLinkedToColorPair, setPairContext, panelId, widgetKey] + ) + const selectWorkflowIdWhenListed = usePendingEntitySelection(members, selectWorkflowId) const handleCreateWorkflow = useCallback( async (folderId?: string) => { @@ -231,49 +124,33 @@ const WorkflowListWidgetBody = ({ folderId: folderId ?? undefined, }) const createdId = newWorkflowId ?? null - setSelectedWorkflowId(createdId) - if (createdId && isLinkedToColorPair) { - setPairContext(resolvedPairColor, { workflowId: createdId }) - } else if (createdId) { - onWidgetParamsChange?.({ workflowId: createdId }) + if (createdId) { + selectWorkflowIdWhenListed(createdId) } return createdId } finally { setIsCreatingWorkflow(false) } }, - [ - workspaceId, - createWorkflow, - isCreatingWorkflow, - resolvedPairColor, - isLinkedToColorPair, - setPairContext, - onWidgetParamsChange, - ] + [workspaceId, createWorkflow, isCreatingWorkflow, selectWorkflowIdWhenListed] ) const handleWorkflowSelect = useCallback( - (workflow: WorkflowMetadata) => { - setSelectedWorkflowId(workflow.id) - if (isLinkedToColorPair) { - setPairContext(resolvedPairColor, { workflowId: workflow.id }) - } else { - onWidgetParamsChange?.({ workflowId: workflow.id }) - } + (workflow: WorkflowListEntry) => { + selectWorkflowIdWhenListed(workflow.id) }, - [resolvedPairColor, isLinkedToColorPair, setPairContext, onWidgetParamsChange] + [selectWorkflowIdWhenListed] ) if (!workspaceId) { return <WidgetMessage message={copy.body.selectWorkspace} /> } - if (loadError) { - return <WidgetMessage message={loadError} /> + if (error && regularWorkflows.length === 0) { + return <WidgetMessage message={error} /> } - if (!hasInitialized) { + if (isLoading && regularWorkflows.length === 0) { return ( <div className='flex h-full items-center justify-center'> <LoadingAgent size='md' /> @@ -285,17 +162,16 @@ const WorkflowListWidgetBody = ({ <WorkspacePermissionsProvider workspaceId={workspaceId} inheritUser> <WorkflowRouteProvider workspaceId={workspaceId} - workflowId={effectiveActiveWorkflowId ?? 'dashboard-workflow-list'} - channelId='dashboard-workflow-list' + workflowId={selectedWorkflowId ?? 'dashboard-workflow-list'} + channelId={channelId} > <div className='h-full w-full overflow-hidden p-2'> <FolderTree regularWorkflows={regularWorkflows} - marketplaceWorkflows={marketplaceWorkflows} - isLoading={isLoading || !hasInitialized} + isLoading={isLoading} onCreateWorkflow={handleCreateWorkflow} workspaceIdOverride={workspaceId} - workflowIdOverride={effectiveActiveWorkflowId} + workflowIdOverride={selectedWorkflowId} onWorkflowSelect={handleWorkflowSelect} disableNavigation /> @@ -312,27 +188,48 @@ export const workflowListWidget: DashboardWidgetDefinition = { category: 'list', description: 'Full folder tree with drag-and-drop, identical to the workspace sidebar.', component: (props) => <WorkflowListWidgetBody {...props} />, - renderHeader: ({ context }) => ({ - right: <WorkflowListHeaderRight workspaceId={context?.workspaceId} />, + renderHeader: ({ widget, context, panelId }) => ({ + right: ( + <WorkflowListHeaderRight + workspaceId={context?.workspaceId} + panelId={panelId} + pairColor={widget?.pairColor} + widgetKey={widget?.key ?? 'workflow_list'} + /> + ), }), } -const WorkflowListHeaderRight = ({ workspaceId }: { workspaceId?: string }) => { - const locale = useLocale() as LocaleCode +const WorkflowListHeaderRight = ({ + workspaceId, + panelId, + pairColor = 'gray', + widgetKey, +}: { + workspaceId?: string + panelId?: string + pairColor?: PairColor + widgetKey: string +}) => { const copy = useMessages().workspace.widgets.workflowList - const handleWorkflowCreated = useCallback( + const { members } = useEntityList('workflow', workspaceId) + const resolvedPairColor = (pairColor ?? 'gray') as PairColor + const isLinkedToColorPair = resolvedPairColor !== 'gray' + const setPairContext = useSetPairColorContext() + const selectWorkflowId = useCallback( (workflowId: string) => { - if (!workspaceId || !workflowId) { + if (!workflowId) { return } - window.dispatchEvent( - new CustomEvent<WorkflowListWorkflowCreatedDetail>(WORKFLOW_LIST_WORKFLOW_CREATED_EVENT, { - detail: { workspaceId, workflowId }, - }) - ) + if (isLinkedToColorPair) { + setPairContext(resolvedPairColor, { workflowId }) + return + } + emitWorkflowSelectionChange({ panelId, workflowId, widgetKey }) }, - [workspaceId] + [isLinkedToColorPair, panelId, resolvedPairColor, setPairContext, widgetKey] ) + const selectWorkflowIdWhenListed = usePendingEntitySelection(members, selectWorkflowId) if (!workspaceId) { return <span className='text-muted-foreground text-xs'>{copy.header.explorer}</span> @@ -343,7 +240,8 @@ const WorkflowListHeaderRight = ({ workspaceId }: { workspaceId?: string }) => { <div className={widgetHeaderButtonGroupClassName()}> <DashboardWorkflowCreateMenu workspaceId={workspaceId} - onWorkflowCreated={handleWorkflowCreated} + existingWorkflowNames={members.map((member) => member.entityName)} + onWorkflowCreated={selectWorkflowIdWhenListed} /> </div> </WorkspacePermissionsProvider> diff --git a/apps/tradinggoose/widgets/widgets/workflow_chat/index.test.tsx b/apps/tradinggoose/widgets/widgets/workflow_chat/index.test.tsx index 0da39cc38..2a3adc2e7 100644 --- a/apps/tradinggoose/widgets/widgets/workflow_chat/index.test.tsx +++ b/apps/tradinggoose/widgets/widgets/workflow_chat/index.test.tsx @@ -19,7 +19,6 @@ let mockWorkflowWidgetState: any = { loadError: null, isLoading: false, workflowIds: ['wf-1'], - activeWorkflowIdForChannel: 'wf-1', } const mockChatStore = { @@ -130,7 +129,6 @@ describe('chatWidget header', () => { loadError: null, isLoading: false, workflowIds: ['wf-1'], - activeWorkflowIdForChannel: 'wf-1', } mockChatStore.setSelectedWorkflowOutput.mockClear() mockChatStore.clearChat.mockClear() diff --git a/apps/tradinggoose/widgets/widgets/workflow_chat/index.tsx b/apps/tradinggoose/widgets/widgets/workflow_chat/index.tsx index 85a1f36a1..51a95c1b4 100644 --- a/apps/tradinggoose/widgets/widgets/workflow_chat/index.tsx +++ b/apps/tradinggoose/widgets/widgets/workflow_chat/index.tsx @@ -9,18 +9,17 @@ import { widgetHeaderControlClassName, widgetHeaderIconButtonClassName, } from '@/components/widget-header-control' +import { useWorkflowChatMessages, useWorkflowDropdownMessages } from '@/i18n/workspace-widget-hooks' import { useChatStore } from '@/stores/chat/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { resolveWidgetChannel } from '@/widgets/hooks/use-widget-channel' import { useWorkflowWidgetState } from '@/widgets/hooks/use-workflow-widget-state' import type { WidgetInstance } from '@/widgets/layout' import type { DashboardWidgetDefinition, WidgetComponentProps } from '@/widgets/types' +import { usePersistResolvedEntityId } from '@/widgets/utils/entity-selection' import { emitWorkflowSelectionChange, useWorkflowSelectionPersistence, } from '@/widgets/utils/workflow-selection' import { WorkflowDropdown } from '@/widgets/widgets/components/workflow-dropdown' -import { useWorkflowChatMessages } from '@/i18n/workspace-widget-hooks' import { OutputSelect } from './components' import WorkflowChatApp, { WorkflowChatSessionProviders } from './components/workflow-chat-app' @@ -33,7 +32,9 @@ const ChatWidgetBody = ({ onWidgetParamsChange, }: WidgetComponentProps) => { const copy = useWorkflowChatMessages() + const dropdownCopy = useWorkflowDropdownMessages() const workspaceId = context?.workspaceId + const widgetKey = widget?.key ?? 'workflow_chat' const { channelId, resolvedPairColor, @@ -42,24 +43,29 @@ const ChatWidgetBody = ({ loadError, isLoading, workflowIds, - activeWorkflowIdForChannel, } = useWorkflowWidgetState({ workspaceId, pairColor, widget, panelId, params, - onWidgetParamsChange, fallbackWidgetKey: 'workflow-chat', - loggerScope: 'workflow chat widget', }) useWorkflowSelectionPersistence({ onWidgetParamsChange, panelId, - widget, + pairColor: resolvedPairColor, + params, + scopeKey: widgetKey, + }) + usePersistResolvedEntityId({ + entityId: resolvedWorkflowId, + entityIdKey: 'workflowId', + onWidgetParamsChange, pairColor: resolvedPairColor, params, }) + if (!workspaceId) { return <WidgetStateMessage message={copy.selectWorkspace} /> } @@ -81,11 +87,7 @@ const ChatWidgetBody = ({ } if (!resolvedWorkflowId) { - return ( - <div className='flex h-full w-full items-center justify-center bg-background'> - <LoadingAgent size='md' /> - </div> - ) + return <WidgetStateMessage message={dropdownCopy.selectWorkflow} /> } return ( @@ -105,35 +107,27 @@ const WidgetStateMessage = ({ message }: { message: string }) => ( </div> ) -function useChannelWorkflowId(channelId: string, fallbackWorkflowId?: string | null) { - return useWorkflowRegistry( - useCallback( - (state) => { - try { - return state.getActiveWorkflowId(channelId) ?? fallbackWorkflowId ?? null - } catch { - return fallbackWorkflowId ?? null - } - }, - [channelId, fallbackWorkflowId] - ) - ) -} - function ChatOutputsHeader({ workspaceId, - channelId, - fallbackWorkflowId, + widget, + panelId, triggerClassName, }: { workspaceId?: string - channelId: string - fallbackWorkflowId?: string | null + widget?: WidgetInstance | null + panelId?: string triggerClassName?: string }) { const copy = useWorkflowChatMessages() const { selectedWorkflowOutputs, setSelectedWorkflowOutput } = useChatStore() - const workflowId = useChannelWorkflowId(channelId, fallbackWorkflowId) + const { channelId, resolvedWorkflowId: workflowId } = useWorkflowWidgetState({ + workspaceId, + pairColor: widget?.pairColor ?? 'gray', + widget, + panelId, + params: widget?.params ?? null, + fallbackWidgetKey: 'workflow-chat', + }) const selectedOutputs = useMemo(() => { if (!workflowId) return [] @@ -204,8 +198,6 @@ const ChatWorkflowHeaderSelector = ({ panelId, params: widget?.params ?? null, fallbackWidgetKey: 'workflow-chat', - loggerScope: 'workflow chat header', - activateWorkflow: false, }) const handleWorkflowChange = useCallback( @@ -216,8 +208,8 @@ const ChatWorkflowHeaderSelector = ({ emitWorkflowSelectionChange({ panelId, - widgetKey: widget?.key ?? undefined, workflowId, + widgetKey: widget?.key ?? 'workflow_chat', }) }, [panelId, resolvedPairColor, widget?.key] @@ -235,14 +227,23 @@ const ChatWorkflowHeaderSelector = ({ } function ClearChatButton({ - channelId, - fallbackWorkflowId, + workspaceId, + widget, + panelId, }: { - channelId: string - fallbackWorkflowId?: string | null + workspaceId?: string + widget?: WidgetInstance | null + panelId?: string }) { const copy = useWorkflowChatMessages() - const workflowId = useChannelWorkflowId(channelId, fallbackWorkflowId) + const { resolvedWorkflowId: workflowId } = useWorkflowWidgetState({ + workspaceId, + pairColor: widget?.pairColor ?? 'gray', + widget, + panelId, + params: widget?.params ?? null, + fallbackWidgetKey: 'workflow-chat', + }) const clearChat = useChatStore((state) => state.clearChat) const hasMessages = useChatStore( useCallback( @@ -287,24 +288,13 @@ export const chatWidget: DashboardWidgetDefinition = { description: 'Chat interface to interact with workflow blocks.', component: (props) => <ChatWidgetBody {...props} />, renderHeader: ({ widget, context, panelId }) => { - const { channelId } = resolveWidgetChannel({ - pairColor: widget?.pairColor ?? 'gray', - widget, - panelId, - fallbackWidgetKey: 'workflow-chat', - }) - const workflowIdParam = - widget?.params && typeof widget.params === 'object' && 'workflowId' in widget.params - ? (widget.params.workflowId as string) - : null - return { left: ( <div className={widgetHeaderButtonGroupClassName()}> <ChatOutputsHeader workspaceId={context?.workspaceId} - channelId={channelId} - fallbackWorkflowId={workflowIdParam} + widget={widget} + panelId={panelId} triggerClassName={widgetHeaderControlClassName('flex items-center gap-1 min-w-[240px]')} /> </div> @@ -316,7 +306,9 @@ export const chatWidget: DashboardWidgetDefinition = { panelId={panelId} /> ), - right: <ClearChatButton channelId={channelId} fallbackWorkflowId={workflowIdParam} />, + right: ( + <ClearChatButton workspaceId={context?.workspaceId} widget={widget} panelId={panelId} /> + ), } }, } diff --git a/apps/tradinggoose/widgets/widgets/workflow_console/index.tsx b/apps/tradinggoose/widgets/widgets/workflow_console/index.tsx index 9d1434698..05f8983fd 100644 --- a/apps/tradinggoose/widgets/widgets/workflow_console/index.tsx +++ b/apps/tradinggoose/widgets/widgets/workflow_console/index.tsx @@ -8,10 +8,15 @@ import { widgetHeaderIconButtonClassName, } from '@/components/widget-header-control' import { cn } from '@/lib/utils' +import { + useWorkflowConsoleMessages, + useWorkflowDropdownMessages, +} from '@/i18n/workspace-widget-hooks' import { useConsoleStore } from '@/stores/console/store' import { useWorkflowWidgetState } from '@/widgets/hooks/use-workflow-widget-state' import type { WidgetInstance } from '@/widgets/layout' import type { DashboardWidgetDefinition, WidgetComponentProps } from '@/widgets/types' +import { usePersistResolvedEntityId } from '@/widgets/utils/entity-selection' import { emitWorkflowSelectionChange, useWorkflowSelectionPersistence, @@ -22,7 +27,6 @@ import { useWorkflowConsoleUiState } from './components/terminal/terminal-ui-sto import type { BlockInfo } from './components/terminal/types' import { filterEntries } from './components/terminal/utils' import WorkflowConsoleApp from './components/workflow-console-app' -import { useWorkflowConsoleMessages } from '@/i18n/workspace-widget-hooks' const WorkflowConsoleWidgetBody = ({ params, @@ -33,7 +37,9 @@ const WorkflowConsoleWidgetBody = ({ onWidgetParamsChange, }: WidgetComponentProps) => { const copy = useWorkflowConsoleMessages() + const dropdownCopy = useWorkflowDropdownMessages() const workspaceId = context?.workspaceId + const widgetKey = widget?.key ?? 'workflow_console' const { channelId, resolvedPairColor, @@ -42,24 +48,29 @@ const WorkflowConsoleWidgetBody = ({ loadError, isLoading, workflowIds, - activeWorkflowIdForChannel, } = useWorkflowWidgetState({ workspaceId, pairColor, widget, panelId, params, - onWidgetParamsChange, fallbackWidgetKey: 'workflow-console', - loggerScope: 'workflow logs widget', }) useWorkflowSelectionPersistence({ onWidgetParamsChange, panelId, - widget, + pairColor: resolvedPairColor, + params, + scopeKey: widgetKey, + }) + usePersistResolvedEntityId({ + entityId: resolvedWorkflowId, + entityIdKey: 'workflowId', + onWidgetParamsChange, pairColor: resolvedPairColor, params, }) + const containerRef = useRef<HTMLDivElement | null>(null) const [panelWidth, setPanelWidth] = useState(0) const fallbackPanelWidth = typeof window !== 'undefined' ? window.innerWidth : 1200 @@ -101,11 +112,7 @@ const WorkflowConsoleWidgetBody = ({ } if (!resolvedWorkflowId) { - return ( - <div className='flex h-full w-full items-center justify-center '> - <LoadingAgent size='md' /> - </div> - ) + return <WidgetStateMessage message={dropdownCopy.selectWorkflow} /> } return ( @@ -146,8 +153,6 @@ const WorkflowConsoleHeaderControls = ({ panelId, params: widget?.params ?? null, fallbackWidgetKey: 'workflow-console', - loggerScope: 'workflow logs header controls', - activateWorkflow: false, }) const entries = useConsoleStore((state) => state.entries) @@ -318,8 +323,6 @@ const WorkflowConsoleHeaderSelector = ({ panelId, params: widget?.params ?? null, fallbackWidgetKey: 'workflow-console', - loggerScope: 'workflow logs header', - activateWorkflow: false, }) const handleWorkflowChange = (workflowId: string) => { @@ -329,8 +332,8 @@ const WorkflowConsoleHeaderSelector = ({ emitWorkflowSelectionChange({ panelId, - widgetKey: widget?.key ?? undefined, workflowId, + widgetKey: widget?.key ?? 'workflow_console', }) } diff --git a/apps/tradinggoose/widgets/widgets/workflow_variables/components/variables/variables.tsx b/apps/tradinggoose/widgets/widgets/workflow_variables/components/variables/variables.tsx index 56830900d..70e59382e 100644 --- a/apps/tradinggoose/widgets/widgets/workflow_variables/components/variables/variables.tsx +++ b/apps/tradinggoose/widgets/widgets/workflow_variables/components/variables/variables.tsx @@ -29,9 +29,8 @@ import { createLogger } from '@/lib/logs/console/logger' import { validateName } from '@/lib/utils' import { useWorkflowVariables } from '@/lib/yjs/use-workflow-doc' import { useWorkflowEditorActions } from '@/hooks/workflow/use-workflow-editor-actions' -import type { Variable, VariableType } from '@/stores/variables/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowVariablesMessages } from '@/i18n/workspace-widget-hooks' +import type { Variable, VariableType } from '@/stores/variables/types' const logger = createLogger('Variables') @@ -45,8 +44,7 @@ export function Variables({ hideAddButtons = false, }: VariablesProps = {}) { const copy = useWorkflowVariablesMessages() - const activeWorkflowId = useWorkflowRegistry((state) => state.getActiveWorkflowId()) - const workflowId = workflowIdProp ?? activeWorkflowId + const workflowId = workflowIdProp ?? null const yjsVariables = useWorkflowVariables() const { collaborativeUpdateVariable, diff --git a/apps/tradinggoose/widgets/widgets/workflow_variables/index.tsx b/apps/tradinggoose/widgets/widgets/workflow_variables/index.tsx index 821f8cf49..2b6e4adc9 100644 --- a/apps/tradinggoose/widgets/widgets/workflow_variables/index.tsx +++ b/apps/tradinggoose/widgets/widgets/workflow_variables/index.tsx @@ -1,24 +1,26 @@ -import { useCallback, useMemo } from 'react' +import { useCallback } from 'react' import { Braces, Plus } from 'lucide-react' import { LoadingAgent } from '@/components/ui/loading-agent' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { widgetHeaderIconButtonClassName } from '@/components/widget-header-control' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { + useWorkflowDropdownMessages, + useWorkflowVariablesMessages, +} from '@/i18n/workspace-widget-hooks' import { WORKFLOW_VARIABLES_ADD_EVENT } from '@/widgets/events' -import { resolveWidgetChannel } from '@/widgets/hooks/use-widget-channel' import { useWorkflowWidgetState } from '@/widgets/hooks/use-workflow-widget-state' import type { WidgetInstance } from '@/widgets/layout' import type { DashboardWidgetDefinition, WidgetComponentProps } from '@/widgets/types' +import { usePersistResolvedEntityId } from '@/widgets/utils/entity-selection' import { emitWorkflowSelectionChange, useWorkflowSelectionPersistence, } from '@/widgets/utils/workflow-selection' import { WorkflowDropdown } from '@/widgets/widgets/components/workflow-dropdown' -import { useWorkflowVariablesMessages } from '@/i18n/workspace-widget-hooks' import WorkflowVariablesApp from './components/workflow-variables-app' const WidgetStateMessage = ({ message }: { message: string }) => ( - <div className='flex h-full w-full items-center justify-center px-4 text-center text-muted-foreground text-xs'> + <div className='flex h-full w-full items-center justify-center px-4 text-center text-muted-foreground text-xs'> {message} </div> ) @@ -32,7 +34,9 @@ const WorkflowVariablesWidgetBody = ({ onWidgetParamsChange, }: WidgetComponentProps) => { const copy = useWorkflowVariablesMessages() + const dropdownCopy = useWorkflowDropdownMessages() const workspaceId = context?.workspaceId + const widgetKey = widget?.key ?? 'workflow_variables' const { channelId, resolvedPairColor, @@ -47,15 +51,21 @@ const WorkflowVariablesWidgetBody = ({ widget, panelId, params, - onWidgetParamsChange, fallbackWidgetKey: 'workflow-variables', - loggerScope: 'workflow variables widget', }) useWorkflowSelectionPersistence({ onWidgetParamsChange, panelId, - widget, + pairColor: resolvedPairColor, + params, + scopeKey: widgetKey, + }) + + usePersistResolvedEntityId({ + entityId: resolvedWorkflowId, + entityIdKey: 'workflowId', + onWidgetParamsChange, pairColor: resolvedPairColor, params, }) @@ -81,11 +91,7 @@ const WorkflowVariablesWidgetBody = ({ } if (!resolvedWorkflowId) { - return ( - <div className='flex h-full w-full items-center justify-center '> - <LoadingAgent size='md' /> - </div> - ) + return <WidgetStateMessage message={dropdownCopy.selectWorkflow} /> } return ( @@ -112,24 +118,15 @@ const WorkflowVariablesHeaderActions = ({ panelId, }: WorkflowVariablesHeaderActionsProps) => { const copy = useWorkflowVariablesMessages() - const { channelId, resolvedPairColor, widgetKey } = resolveWidgetChannel({ - pairColor: widget?.pairColor, + const { channelId, resolvedWorkflowId } = useWorkflowWidgetState({ + workspaceId, + pairColor: widget?.pairColor ?? 'gray', widget, panelId, + params: widget?.params ?? null, fallbackWidgetKey: 'workflow-variables', }) - const paramsWorkflowId = useMemo(() => { - if (!widget?.params || typeof widget.params !== 'object') return null - const value = (widget.params as Record<string, unknown>).workflowId - return typeof value === 'string' && value.trim().length > 0 ? value : null - }, [widget?.params]) - - const activeWorkflowId = useWorkflowRegistry((state) => state.getActiveWorkflowId(channelId)) - - const resolvedWorkflowId = - resolvedPairColor === 'gray' ? (paramsWorkflowId ?? activeWorkflowId) : activeWorkflowId - const isDisabled = !workspaceId || !resolvedWorkflowId const handleAddVariable = useCallback(() => { @@ -137,10 +134,10 @@ const WorkflowVariablesHeaderActions = ({ window.dispatchEvent( new CustomEvent(WORKFLOW_VARIABLES_ADD_EVENT, { - detail: { panelId, channelId, workflowId: resolvedWorkflowId, widgetKey }, + detail: { panelId, channelId }, }) ) - }, [isDisabled, resolvedWorkflowId, panelId, channelId, widgetKey]) + }, [isDisabled, panelId, channelId, resolvedWorkflowId]) return ( <Tooltip> @@ -180,8 +177,6 @@ const WorkflowVariablesHeaderWorkflowSelector = ({ panelId, params: widget?.params ?? null, fallbackWidgetKey: 'workflow-variables', - loggerScope: 'workflow variables header', - activateWorkflow: false, }) const handleWorkflowChange = useCallback( @@ -192,8 +187,8 @@ const WorkflowVariablesHeaderWorkflowSelector = ({ emitWorkflowSelectionChange({ panelId, - widgetKey: widget?.key, workflowId, + widgetKey: widget?.key ?? 'workflow_variables', }) }, [panelId, resolvedPairColor, widget?.key] @@ -218,11 +213,6 @@ export const workflowVariablesWidget: DashboardWidgetDefinition = { description: 'Inspect and edit variables for a selected workflow.', component: (props) => <WorkflowVariablesWidgetBody {...props} />, renderHeader: ({ widget, context, panelId }) => { - const workflowId = - widget?.params && typeof widget.params === 'object' && 'workflowId' in widget.params - ? (widget.params.workflowId as string) - : null - return { center: ( <WorkflowVariablesHeaderWorkflowSelector diff --git a/changelog/July-01-2026.md b/changelog/July-01-2026.md new file mode 100644 index 000000000..9795bbd1a --- /dev/null +++ b/changelog/July-01-2026.md @@ -0,0 +1,97 @@ +# July-01-2026 + +## fix/list-Yjs @ 10dc79c2 vs origin/staging + +### Summary +- Moves workflow, MCP server, skill, custom tool, indicator, and knowledge-base list surfaces onto read-only Yjs entity-list sessions backed by canonical DB reseeds. +- Keeps editable saved-entity and workflow documents as separate write-mode Yjs sessions, while deployed execution paths read DB state through explicit `isDeployedContext` handling. +- Adds live skill listing support and shared list-selection gates so create/import flows wait for list projections before selecting new entities. +- Removes the template subsystem, redundant skills and MCP server stores, public workflow marketplace publishing surfaces, and old incremental list mutation helpers. + +### Branch Scope +- Compared `550ab035bd55379f1749eb313180af82b7962a41..10dc79c2`; `550ab035bd55379f1749eb313180af82b7962a41` is the merge base with refreshed `origin/staging`. +- `git status --short --branch --untracked-files=all`, `git diff --stat HEAD`, `git diff --name-status --find-renames HEAD`, and `git diff --shortstat HEAD` showed no staged, unstaged, or untracked feature edits before this changelog update, so no dirty-tree feature changes were included despite the requested dirty-tree comparison. +- Main areas touched: Yjs entity/session identity, socket-server Yjs HTTP and websocket handlers, workflow metadata APIs, saved entity operations for MCP/custom tools/skills/indicators/knowledge bases, dashboard list/editor widgets, workflow registry hydration, MCP tool discovery/execution, agent skill resolution, SDK workflow status types, i18n cleanup, and template removal. +- Branch delta: 210 files changed, 3459 insertions, 8964 deletions. No `*/migration/*` files were edited in the reviewed branch diff. + +### Key Changes +- `apps/tradinggoose/lib/copilot/review-sessions/identity.ts` makes entity-list target identity canonical through `buildEntityListDescriptor()`, `isEntityListSessionId()`, and `entity_list` envelopes. List session IDs are `list:{entityKind}:{workspaceId}`, and `ReviewEntityKind` now allows workflow list sessions instead of limiting list targets to saved-entity kinds. +- `apps/tradinggoose/lib/yjs/entity-session.ts` defines the current entity-list document contract. Saved entity docs own `fields` and `metadata`; list docs own `members`. `EntityListMember` now carries discovery metadata including `entityDescription`, `enabled`, `folderId`, `color`, `createdAt`, `updatedAt`, and `connectionStatus`. +- `seedEntityListSession()` and per-member list mutation helpers were replaced by `replaceEntityListSessionMembers()` and full list reseeds. `getEntityListMembers()` remains the list reader and filters deleted markers before returning name-sorted members. +- `apps/tradinggoose/lib/yjs/server/entity-loaders.ts` centralizes DB-backed list reads through `readEntityListMembersFromDb()`. Workflows expose `description`, `folderId`, `color`, and `createdAt`; MCP servers expose `enabled`, `updatedAt`, and `connectionStatus`; skills and custom tools expose descriptions where available; indicators expose colors. +- `apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts` owns server-side live and execution readers: `requireEntityRealtimeListMembers()`, `readSavedEntityFieldsForExecution()`, `readSavedEntityListFieldsForExecution()`, `createEntityListBootstrapUpdate()`, and `reseedEntityListSessionFromDb()`. Reseeds are serialized per Y.Doc so destructive full-replace operations cannot race each other. +- `apps/tradinggoose/lib/yjs/use-entity-fields.ts` introduces shared client session ownership for both `useSavedEntityYjsSession()` and `useEntityList()`. Read-mode list sessions keep old members visible while reconnecting, surface reopen errors, and retry every second until live or unmounted. +- `apps/tradinggoose/lib/yjs/provider.ts`, `apps/tradinggoose/socket-server/yjs/ws-handler.ts`, and `apps/tradinggoose/socket-server/yjs/upstream-utils.ts` add `accessMode` as a first-class Yjs transport setting. Entity-list websockets are read-only, read sessions skip write-session sync waiting, and socket sync handling rejects write sync messages from read-mode clients. +- `apps/tradinggoose/socket-server/routes/http.ts` adds `/internal/yjs/sessions/:sessionId/members`, reseeds entity-list snapshots from DB before returning them, and refreshes any live list doc after saved entity Yjs apply/save. `applyThroughStaging()` persists server-authored writes on a detached doc before applying the delta to the live collaborative doc. +- `apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts` consolidates list projection refresh into `refreshEntityListSession()`. It posts a reseed request to the socket server and, on refresh failure, discards the stale projection so subscribers rebootstrap from canonical DB state instead of seeing permanent stale members. +- Saved entity operations refresh list projections after canonical create/import/delete and save through Yjs materialization: `apps/tradinggoose/lib/custom-tools/operations.ts`, `apps/tradinggoose/lib/indicators/custom/operations.ts`, `apps/tradinggoose/lib/skills/operations.ts`, `apps/tradinggoose/lib/knowledge/service.ts`, and `apps/tradinggoose/lib/mcp/service.ts`. +- `apps/tradinggoose/app/api/skills/route.ts` and `apps/tradinggoose/lib/skills/operations.ts` add `GET /api/skills?state=live`, using `readSavedEntityListFieldsForExecution('skill', workspaceId, false)` for live editing reads while leaving persisted reads as the default. `apps/tradinggoose/lib/skills/types.ts` is the canonical `SkillDefinition` type after deleting the old skills store. +- Dashboard list widgets consume `useEntityList()` directly: `apps/tradinggoose/widgets/widgets/list_workflow/index.tsx`, `list_mcp/index.tsx`, `list_custom_tool/index.tsx`, `list_indicator/index.tsx`, and `list_skill/index.tsx`. Create/import paths use `generateAvailableName()` against current list members and `usePendingEntitySelection()` before selecting created entities. +- Editor widgets validate requested IDs against live membership before opening saved entity Yjs sessions in `apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx`, `editor_custom_tool/index.tsx`, `editor_indicator/editor-indicator-body.tsx`, and `editor_skill/editor-skill-body.tsx`. +- `apps/tradinggoose/widgets/hooks/use-workflow-widget-state.ts` now resolves workflow selection from `useEntityList('workflow')` plus pair/widget params. It no longer loads workflow metadata through registry side effects, auto-activates workflows, or auto-selects the first workflow. +- `apps/tradinggoose/widgets/utils/entity-selection.ts` provides `resolveEntityId()` and `resolveEntityIdFromList()` as the shared selection contract. Explicit requested IDs resolve exactly or to null, never to an arbitrary fallback entity. +- `apps/tradinggoose/widgets/utils/use-pending-entity-selection.ts` gates post-create selection until the list projection includes the new entity ID. The latest pending request wins and unlisted IDs never fire after unmount. +- Workflow metadata moved back to workflow rows and workflow list projections. `apps/tradinggoose/app/api/workflows/route.ts`, `apps/tradinggoose/app/api/workflows/[id]/route.ts`, and `apps/tradinggoose/lib/workflows/db-helpers.ts` update row metadata directly and refresh list sessions through `refreshWorkflowList()` or `refreshWorkflowListForWorkflow()` instead of writing workflow name/description/folder metadata into the workflow Yjs doc. +- `apps/tradinggoose/stores/workflows/index.ts` removes `getBlockWithValues()`, `getAllWorkflowsWithValues()`, active-workflow coupling in `readWorkflowWithValues()`, and marketplace metadata returns. Workflow state reads now require the specific workflow's live Yjs snapshot. +- `apps/tradinggoose/hooks/use-mcp-tools.ts`, `apps/tradinggoose/app/api/mcp/tools/discover/route.ts`, `apps/tradinggoose/app/api/mcp/tools/execute/route.ts`, `apps/tradinggoose/lib/mcp/service.ts`, `apps/tradinggoose/tools/index.ts`, and `apps/tradinggoose/tools/utils.ts` carry `isDeployedContext` through custom tool, MCP, skill, and function execution so Studio editing reads require realtime Yjs state while deployed runs use DB rows. +- `apps/tradinggoose/executor/handlers/agent/agent-handler.ts` and `apps/tradinggoose/executor/handlers/agent/skills-resolver.ts` fail tool/skill hydration honestly when selected saved entities cannot be resolved instead of silently dropping tools or shrinking agent capability. +- The template subsystem was removed from active app code: `apps/tradinggoose/app/api/templates/*`, `apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/*`, `apps/tradinggoose/app/workspace/[workspaceId]/templates/*`, `apps/tradinggoose/hooks/queries/templates.ts`, and `apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/template-modal/template-modal.tsx` are deleted. +- `packages/db/schema/workflows.ts`, `apps/tradinggoose/app/api/workflows/public/[id]/route.ts`, `packages/ts-sdk/src/index.ts`, `packages/python-sdk/tradinggoose/__init__.py`, and their README/test updates remove `isPublished`, `marketplaceData`, public workflow API support, and SDK `WorkflowStatus.isPublished`. + +### Design Decisions +- Entity-list sessions are disposable read-only projections. Server mutation paths mutate canonical DB rows or saved entity Yjs docs first, then call `refreshEntityListSession()` or workflow list refresh helpers to converge the projection. +- Projection refresh failure must not fail a committed create/update/import/delete. `refreshEntityListSession()` logs and discards stale list docs on bridge failure, causing subscribed clients to rebootstrap from DB on the next snapshot/reopen. +- List refreshes use full DB reseeds instead of incremental upsert/remove patches. This avoids reviving stale members after missed events and makes `readEntityListMembersFromDb()` the canonical source of list membership shape. +- Saved entity editor docs remain separate from list membership. Editors open `useSavedEntityYjsSession(entityKind, entityId, workspaceId)` for editable fields and call `saveSavedEntityField()` or save the whole doc; list docs only carry discovery/search/selection metadata. +- Workflow graph state and workflow metadata have different owners. The workflow Yjs doc owns blocks, edges, loops, parallels, variables, and direction; the workflow row owns `name`, `description`, `folderId`, `color`, and deployment metadata; the workflow list projection exposes row metadata to widgets. +- Realtime failures are intentionally visible in Studio editing contexts. `SavedEntityRealtimeRequiredError` and `WorkflowRealtimeRequiredError` prevent stale DB fallbacks from overwriting collaborative state. +- Deployed execution is intentionally DB-backed. `readSavedEntityFieldsForExecution()` and `readSavedEntityListFieldsForExecution()` select DB rows only when `isDeployedContext` is true. +- Widget selection is explicit and validated. Requested IDs that disappear render empty/not-found states; fallback/default-first behavior applies only when no ID was requested, and workflow widgets set `useDefaultEntity: false`. +- Template and public marketplace behavior was removed instead of shimmed. The branch follows the no-legacy rule by deleting active UI, API, hooks, schema exports, SDK status fields, and stale copy rather than keeping compatibility paths. + +### Shared Contracts and Helpers to Reuse +- Use `buildEntityListDescriptor()`, `isEntityListSessionId()`, `buildYjsTransportEnvelope()`, and `buildReviewTargetDescriptorFromEnvelope()` from `apps/tradinggoose/lib/copilot/review-sessions/identity.ts` for entity-list target identity. +- Use `EntityListMember`, `replaceEntityListSessionMembers()`, `getEntityListMembers()`, `getFieldsMap()`, `setEntityField()`, and `replaceEntityTextField()` from `apps/tradinggoose/lib/yjs/entity-session.ts` for Yjs entity docs and list projections. +- Use `useEntityList()`, `useSavedEntityYjsSession()`, and `saveSavedEntityField()` from `apps/tradinggoose/lib/yjs/use-entity-fields.ts` in widgets and editors; do not recreate per-entity list hooks or direct store mirrors. +- Use `readEntityListMembersFromDb()`, `resolveEntityWorkspaceId()`, and `readSavedEntityFieldsFromDb()` from `apps/tradinggoose/lib/yjs/server/entity-loaders.ts` when server code needs canonical saved-entity or list seeding data. +- Use `requireEntityRealtimeListMembers()`, `readSavedEntityFieldsForExecution()`, `readSavedEntityListFieldsForExecution()`, `createEntityListBootstrapUpdate()`, and `reseedEntityListSessionFromDb()` from `apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts`. +- Use `refreshEntityListSession()`, `applyEntityStateInSocketServer()`, `applyYjsUpdateInSocketServer()`, and `deleteYjsSessionInSocketServer()` from `apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts` instead of calling socket-server internal endpoints directly. +- Use `applySavedEntityState()` and `SavedEntityPersistenceError` from `apps/tradinggoose/lib/yjs/server/apply-entity-state.ts` for saved entity materialization and error mapping. +- Use `refreshWorkflowList()`, `refreshWorkflowListForWorkflow()`, `requireWorkflowRealtimeState()`, and `WorkflowRealtimeRequiredError` from `apps/tradinggoose/lib/workflows/db-helpers.ts` for workflow list and editable-state behavior. +- Use `resolveEntityId()` and `resolveEntityIdFromList()` from `apps/tradinggoose/widgets/utils/entity-selection.ts` for widget params and pair-context selection validation. +- Use `usePendingEntitySelection()` from `apps/tradinggoose/widgets/utils/use-pending-entity-selection.ts` after create/import APIs return IDs that must become visible through realtime list projection before selection. +- Use `SkillDefinition` from `apps/tradinggoose/lib/skills/types.ts`; `apps/tradinggoose/stores/skills/types.ts` no longer exists. +- Use `MCP_TOOLS_CHANGED_EVENT`, `createMcpToolId()`, and `parseMcpToolId()` from `apps/tradinggoose/lib/mcp/utils.ts`; the event no longer lives in `stores/mcp-servers/store.ts`. +- Use `generateAvailableName()` from `apps/tradinggoose/lib/naming.ts` for list-widget create flows that need collision-free default names. + +### Removed or Replaced Items +- Deleted `apps/tradinggoose/stores/mcp-servers/store.ts` and `apps/tradinggoose/stores/mcp-servers/types.ts`. Do not reintroduce `useMcpServersStore()`; use `useEntityList('mcp_server')`, `useMcpTools()`, and collection-level MCP APIs. +- Deleted `apps/tradinggoose/stores/skills/store.ts` and `apps/tradinggoose/stores/skills/types.ts`. Do not rebuild a Zustand skills cache; use `useEntityList('skill')`, `/api/skills?state=live` for live listing, and `apps/tradinggoose/lib/skills/types.ts` for the shared type. +- Deleted `apps/tradinggoose/app/api/mcp/servers/[id]/route.ts` and removed `RenameMcpServerSchema` from `apps/tradinggoose/app/api/mcp/servers/schema.ts`. Do not restore per-ID MCP PATCH routes for rename/config saves; use saved entity Yjs field/session saves and `applySavedEntityState()`. +- Removed `seedEntityListSession()`, `EntityListMemberMutation`, `applyEntityListMutations()`, `getEntityListMemberFromFields()`, `notifyEntityListMembersUpserted()`, and `notifyEntityListMemberRemoved()`. Use full DB reseeds through `replaceEntityListSessionMembers()`, `reseedEntityListSessionFromDb()`, and `refreshEntityListSession()`. +- Removed workflow Yjs metadata patching helpers and behavior from workflow save/apply paths. Do not add workflow name/description/folder metadata back into workflow Yjs docs; update workflow rows and refresh workflow list projections instead. +- Deleted `apps/tradinggoose/lib/workflows/state-builder.ts` and `state-builder.test.ts`. Do not revive template-specific workflow state builders; use `buildDefaultWorkflowArtifacts()`, `regenerateWorkflowStateIds()`, and regular workflow duplicate/import paths. +- Deleted active template pages, hooks, APIs, and modal code under `apps/tradinggoose/app/api/templates/*`, `apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/*`, `apps/tradinggoose/app/workspace/[workspaceId]/templates/*`, `apps/tradinggoose/hooks/queries/templates.ts`, and `apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/template-modal/template-modal.tsx`. +- Deleted `apps/tradinggoose/app/api/workflows/public/[id]/route.ts`. Do not recreate the marketplace-backed public workflow API. +- Removed `workflow.isPublished`, `workflow.marketplaceData`, and marketplace schema exports from `packages/db/schema/workflows.ts`. Do not add legacy marketplace backfills or new code that depends on those exports. +- Removed `WorkflowStatus.isPublished` from `packages/ts-sdk/src/index.ts`, `packages/python-sdk/tradinggoose/__init__.py`, SDK tests, and README examples. Future SDK status handling should use `isDeployed`, `deployedAt`, and `needsRedeployment`. +- Removed stale `authenticationRequiredToLoadWorkflows` i18n keys from English, Spanish, and Chinese bundles. Use the generic workflow load error path. + +### Future Branch Guardrails +- Do not add widget-local stores for entity membership. Workflow, MCP server, skill, custom tool, indicator, and knowledge-base lists should consume `useEntityList()` and rely on server mutation paths to refresh list sessions. +- Do not write entity-list `members` from client UI code. List membership changes flow through canonical create/update/delete/import operations plus `refreshEntityListSession()` or workflow list refresh helpers. +- Do not add incremental list upsert/remove helpers. Full DB reseeds are the durable projection contract. +- Do not add DB fallback reads for Studio editing state. If Yjs bridge/snapshot access fails in non-deployed context, surface realtime-required errors instead of reading stale rows. +- Do not skip `isDeployedContext` in new execution paths. Deployed workflows should read DB-backed saved entity state; Studio/runtime editing should use Yjs-backed state. +- Do not restore `stores/mcp-servers/*`, `stores/skills/*`, `/api/mcp/servers/[id]`, template routes/pages/hooks/modal, public workflow marketplace routes, `WorkflowStatus.isPublished`, or marketplace workflow schema exports. +- Do not silently drop agent tools or saved-entity execution-read failures. Hydration should fail with the actual missing/realtime error so the user sees that selected tool or skill state is invalid. +- Keep entity-list members as discovery metadata. Add detailed fields to saved entity docs and detail readers, not to every list result, unless the field is needed for selection/search/list display across widgets. +- Keep workflow folder/name/color changes tied to workflow row updates and `refreshWorkflowList()` or `refreshWorkflowListForWorkflow()` so folder trees and dropdowns stay aligned with list projections. + +### Validation Notes +- Followed the requested `staging-changelog` workflow, refreshed `origin/staging` with `git fetch origin staging`, and followed `changelog/TEMPLATE.md`. +- Reviewed `git status --short --branch --untracked-files=all`, `git merge-base origin/staging fix/list-Yjs`, `git log --oneline 550ab035bd55379f1749eb313180af82b7962a41..HEAD`, `git diff --stat`, `git diff --name-status --find-renames`, `git diff --summary`, `git diff --dirstat`, `git diff --shortstat`, `git diff --stat HEAD`, `git diff --name-status --find-renames HEAD`, and the incremental `ad9da06b..HEAD` range because the previous section documented `ad9da06b`. +- Confirmed no dirty feature changes were visible before this changelog update; this file is expected to be the dirty working-tree change afterward. +- Inspected `AGENTS.md`, `changelog/TEMPLATE.md`, recent changelog entries, `package.json` scripts, Yjs entity/session/identity/provider/bootstrap/snapshot bridge files, socket-server HTTP/websocket handlers, workflow APIs and DB helpers, saved entity operations, MCP service/hooks/widgets, workflow registry and selection helpers, dropdown/list/editor components, SDK status files, i18n bundles, and deleted paths from the merge base. +- Inspected representative tests including `apps/tradinggoose/lib/yjs/use-entity-fields.test.tsx`, `apps/tradinggoose/lib/yjs/server/bootstrap-review-target.test.ts`, `apps/tradinggoose/lib/yjs/server/snapshot-bridge.test.ts`, `apps/tradinggoose/socket-server/yjs/ws-handler.test.ts`, `apps/tradinggoose/widgets/utils/use-pending-entity-selection.test.tsx`, and `apps/tradinggoose/app/api/skills/route.test.ts`. +- 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, deleted-path review, and template conformance. diff --git a/packages/db/migrations/0037_familiar_killraven.sql b/packages/db/migrations/0037_familiar_killraven.sql new file mode 100644 index 000000000..f0a413066 --- /dev/null +++ b/packages/db/migrations/0037_familiar_killraven.sql @@ -0,0 +1,6 @@ +DROP TABLE "marketplace" CASCADE;--> statement-breakpoint +DROP TABLE "template_stars" CASCADE;--> statement-breakpoint +DROP TABLE "templates" CASCADE;--> statement-breakpoint +ALTER TABLE "workflow" DROP COLUMN "is_published";--> statement-breakpoint +ALTER TABLE "workflow" DROP COLUMN "marketplace_data";--> statement-breakpoint +ALTER TABLE "custom_indicators" DROP COLUMN "input_meta"; \ No newline at end of file diff --git a/packages/db/migrations/meta/0037_snapshot.json b/packages/db/migrations/meta/0037_snapshot.json new file mode 100644 index 000000000..dc79ef696 --- /dev/null +++ b/packages/db/migrations/meta/0037_snapshot.json @@ -0,0 +1,10952 @@ +{ + "id": "1b529759-d8a9-4ac5-9566-46b1dbbb6461", + "prevId": "36d68c52-849f-4828-91e1-ac10af16f3b0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": [ + "env_owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": [ + "active_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "preferred_locale": { + "name": "preferred_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_billing_ledger": { + "name": "organization_billing_ledger", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_billing_ledger_organization_id_idx": { + "name": "organization_billing_ledger_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_billing_ledger_organization_id_organization_id_fk": { + "name": "organization_billing_ledger_organization_id_organization_id_fk", + "tableFrom": "organization_billing_ledger", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_billing_ledger": { + "name": "organization_member_billing_ledger", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_member_billing_ledger_organization_id_idx": { + "name": "organization_member_billing_ledger_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_member_billing_ledger_user_id_idx": { + "name": "organization_member_billing_ledger_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_billing_ledger_organization_id_organization_id_fk": { + "name": "organization_member_billing_ledger_organization_id_organization_id_fk", + "tableFrom": "organization_member_billing_ledger", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_billing_ledger_user_id_user_id_fk": { + "name": "organization_member_billing_ledger_user_id_user_id_fk", + "tableFrom": "organization_member_billing_ledger", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_member_billing_ledger_pkey": { + "name": "organization_member_billing_ledger_pkey", + "columns": [ + "organization_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_tier_id": { + "name": "billing_tier_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_type": { + "name": "reference_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_billing_tier_id_idx": { + "name": "subscription_billing_tier_id_idx", + "columns": [ + { + "expression": "billing_tier_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_stripe_subscription_id_unique": { + "name": "subscription_stripe_subscription_id_unique", + "columns": [ + { + "expression": "stripe_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_billing_tier_id_system_billing_tier_id_fk": { + "name": "subscription_billing_tier_id_system_billing_tier_id_fk", + "tableFrom": "subscription", + "tableTo": "system_billing_tier", + "columnsFrom": [ + "billing_tier_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_rate_limits": { + "name": "user_rate_limits", + "schema": "", + "columns": { + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sync_api_requests": { + "name": "sync_api_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "async_api_requests": { + "name": "async_api_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "api_endpoint_requests": { + "name": "api_endpoint_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "window_start": { + "name": "window_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_request_at": { + "name": "last_request_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_rate_limited": { + "name": "is_rate_limited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rate_limit_reset_at": { + "name": "rate_limit_reset_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "granted_onboarding_allowance_usd": { + "name": "granted_onboarding_allowance_usd", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "custom_usage_limit": { + "name": "custom_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "custom_usage_limit_updated_at": { + "name": "custom_usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_review_items": { + "name": "copilot_review_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'message'" + }, + "message_role": { + "name": "message_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "timestamp": { + "name": "timestamp", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_blocks": { + "name": "content_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "file_attachments": { + "name": "file_attachments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "citations": { + "name": "citations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_review_items_session_id_idx": { + "name": "copilot_review_items_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_items_turn_id_idx": { + "name": "copilot_review_items_turn_id_idx", + "columns": [ + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_items_kind_idx": { + "name": "copilot_review_items_kind_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_items_session_sequence_unique": { + "name": "copilot_review_items_session_sequence_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_items_session_item_unique": { + "name": "copilot_review_items_session_item_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_review_items_session_id_copilot_review_sessions_id_fk": { + "name": "copilot_review_items_session_id_copilot_review_sessions_id_fk", + "tableFrom": "copilot_review_items", + "tableTo": "copilot_review_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_review_items_turn_id_copilot_review_turns_id_fk": { + "name": "copilot_review_items_turn_id_copilot_review_turns_id_fk", + "tableFrom": "copilot_review_items", + "tableTo": "copilot_review_turns", + "columnsFrom": [ + "turn_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_review_sessions": { + "name": "copilot_review_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_kind": { + "name": "entity_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "draft_session_id": { + "name": "draft_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_review_sessions_user_id_idx": { + "name": "copilot_review_sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_sessions_user_entity_idx": { + "name": "copilot_review_sessions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_sessions_workspace_entity_idx": { + "name": "copilot_review_sessions_workspace_entity_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_sessions_workspace_draft_idx": { + "name": "copilot_review_sessions_workspace_draft_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "draft_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_sessions_user_workspace_channel_idx": { + "name": "copilot_review_sessions_user_workspace_channel_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"workspace_id\", 'global')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_review_sessions\".\"channel_id\" IS NOT NULL AND \"copilot_review_sessions\".\"entity_kind\" = 'copilot'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_sessions_saved_entity_unique": { + "name": "copilot_review_sessions_saved_entity_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"copilot_review_sessions\".\"channel_id\" IS NULL AND \"copilot_review_sessions\".\"entity_kind\" <> 'workflow' AND \"copilot_review_sessions\".\"entity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_sessions_draft_entity_unique": { + "name": "copilot_review_sessions_draft_entity_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "draft_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"copilot_review_sessions\".\"channel_id\" IS NULL AND \"copilot_review_sessions\".\"entity_kind\" <> 'workflow' AND \"copilot_review_sessions\".\"entity_id\" IS NULL AND \"copilot_review_sessions\".\"draft_session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_sessions_created_at_idx": { + "name": "copilot_review_sessions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_sessions_updated_at_idx": { + "name": "copilot_review_sessions_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_review_sessions_user_id_user_id_fk": { + "name": "copilot_review_sessions_user_id_user_id_fk", + "tableFrom": "copilot_review_sessions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_review_turns": { + "name": "copilot_review_turns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'completed'" + }, + "user_message_item_id": { + "name": "user_message_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_review_turns_session_id_idx": { + "name": "copilot_review_turns_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_turns_session_status_idx": { + "name": "copilot_review_turns_session_status_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_review_turns_session_sequence_unique": { + "name": "copilot_review_turns_session_sequence_unique", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_review_turns_session_id_copilot_review_sessions_id_fk": { + "name": "copilot_review_turns_session_id_copilot_review_sessions_id_fk", + "tableFrom": "copilot_review_turns", + "tableTo": "copilot_review_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_owner_type": { + "name": "billing_owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "billing_owner_user_id": { + "name": "billing_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_owner_organization_id": { + "name": "billing_owner_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_billing_owner_user_id_user_id_fk": { + "name": "workspace_billing_owner_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": [ + "billing_owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_billing_owner_organization_id_organization_id_fk": { + "name": "workspace_billing_owner_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": [ + "billing_owner_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_billing_owner_check": { + "name": "workspace_billing_owner_check", + "value": "(\n \"workspace\".\"billing_owner_type\" = 'user'\n AND \"workspace\".\"billing_owner_user_id\" IS NOT NULL\n AND \"workspace\".\"billing_owner_organization_id\" IS NULL\n ) OR (\n \"workspace\".\"billing_owner_type\" = 'organization'\n AND \"workspace\".\"billing_owner_user_id\" IS NULL\n AND \"workspace\".\"billing_owner_organization_id\" IS NOT NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_kb_uploaded_at_idx": { + "name": "doc_kb_uploaded_at_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uploaded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": [ + "knowledge_base_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": [ + "knowledge_base_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": [ + "knowledge_base_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_admin": { + "name": "system_admin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "system_admin_user_id_unique": { + "name": "system_admin_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "system_admin_user_id_user_id_fk": { + "name": "system_admin_user_id_user_id_fk", + "tableFrom": "system_admin", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_billing_settings": { + "name": "system_billing_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "onboarding_allowance_usd": { + "name": "onboarding_allowance_usd", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "overage_threshold_dollars": { + "name": "overage_threshold_dollars", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'50'" + }, + "workflow_execution_charge_usd": { + "name": "workflow_execution_charge_usd", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "function_execution_charge_usd": { + "name": "function_execution_charge_usd", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "usage_warning_threshold_percent": { + "name": "usage_warning_threshold_percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 80 + }, + "free_tier_upgrade_threshold_percent": { + "name": "free_tier_upgrade_threshold_percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 90 + }, + "enterprise_contact_url": { + "name": "enterprise_contact_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "system_billing_settings_updated_by_user_id_idx": { + "name": "system_billing_settings_updated_by_user_id_idx", + "columns": [ + { + "expression": "updated_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "system_billing_settings_updated_by_user_id_user_id_fk": { + "name": "system_billing_settings_updated_by_user_id_user_id_fk", + "tableFrom": "system_billing_settings", + "tableTo": "user", + "columnsFrom": [ + "updated_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "system_billing_settings_usage_warning_threshold_check": { + "name": "system_billing_settings_usage_warning_threshold_check", + "value": "\"system_billing_settings\".\"usage_warning_threshold_percent\" between 1 and 100" + }, + "system_billing_settings_onboarding_allowance_check": { + "name": "system_billing_settings_onboarding_allowance_check", + "value": "\"system_billing_settings\".\"onboarding_allowance_usd\" >= 0" + }, + "system_billing_settings_workflow_execution_charge_check": { + "name": "system_billing_settings_workflow_execution_charge_check", + "value": "\"system_billing_settings\".\"workflow_execution_charge_usd\" >= 0" + }, + "system_billing_settings_function_execution_charge_check": { + "name": "system_billing_settings_function_execution_charge_check", + "value": "\"system_billing_settings\".\"function_execution_charge_usd\" >= 0" + }, + "system_billing_settings_free_tier_upgrade_threshold_check": { + "name": "system_billing_settings_free_tier_upgrade_threshold_check", + "value": "\"system_billing_settings\".\"free_tier_upgrade_threshold_percent\" between 1 and 100" + } + }, + "isRLSEnabled": false + }, + "public.system_billing_tier": { + "name": "system_billing_tier", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_scope": { + "name": "usage_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seat_mode": { + "name": "seat_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "monthly_price_usd": { + "name": "monthly_price_usd", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "yearly_price_usd": { + "name": "yearly_price_usd", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "included_usage_limit_usd": { + "name": "included_usage_limit_usd", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_limit_gb": { + "name": "storage_limit_gb", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "concurrency_limit": { + "name": "concurrency_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seat_count": { + "name": "seat_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seat_maximum": { + "name": "seat_maximum", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stripe_monthly_price_id": { + "name": "stripe_monthly_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_yearly_price_id": { + "name": "stripe_yearly_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_rate_limit_per_minute": { + "name": "sync_rate_limit_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "async_rate_limit_per_minute": { + "name": "async_rate_limit_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_endpoint_rate_limit_per_minute": { + "name": "api_endpoint_rate_limit_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_pending_age_seconds": { + "name": "max_pending_age_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_pending_count": { + "name": "max_pending_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "can_edit_usage_limit": { + "name": "can_edit_usage_limit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_configure_sso": { + "name": "can_configure_sso", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "log_retention_days": { + "name": "log_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "workflow_execution_multiplier": { + "name": "workflow_execution_multiplier", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "workflow_model_cost_multiplier": { + "name": "workflow_model_cost_multiplier", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "function_execution_multiplier": { + "name": "function_execution_multiplier", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "copilot_cost_multiplier": { + "name": "copilot_cost_multiplier", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "pricing_features": { + "name": "pricing_features", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "system_billing_tier_status_idx": { + "name": "system_billing_tier_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "system_billing_tier_display_order_idx": { + "name": "system_billing_tier_display_order_idx", + "columns": [ + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "system_billing_tier_updated_by_user_id_idx": { + "name": "system_billing_tier_updated_by_user_id_idx", + "columns": [ + { + "expression": "updated_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "system_billing_tier_updated_by_user_id_user_id_fk": { + "name": "system_billing_tier_updated_by_user_id_user_id_fk", + "tableFrom": "system_billing_tier", + "tableTo": "user", + "columnsFrom": [ + "updated_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "system_billing_tier_status_check": { + "name": "system_billing_tier_status_check", + "value": "\"system_billing_tier\".\"status\" in ('active', 'draft', 'archived')" + }, + "system_billing_tier_owner_type_check": { + "name": "system_billing_tier_owner_type_check", + "value": "\"system_billing_tier\".\"owner_type\" in ('user', 'organization')" + }, + "system_billing_tier_usage_scope_check": { + "name": "system_billing_tier_usage_scope_check", + "value": "\"system_billing_tier\".\"usage_scope\" in ('individual', 'pooled')" + }, + "system_billing_tier_seat_mode_check": { + "name": "system_billing_tier_seat_mode_check", + "value": "\"system_billing_tier\".\"seat_mode\" in ('fixed', 'adjustable')" + }, + "system_billing_tier_seat_count_check": { + "name": "system_billing_tier_seat_count_check", + "value": "\"system_billing_tier\".\"seat_count\" is null or \"system_billing_tier\".\"seat_count\" >= 1" + }, + "system_billing_tier_sync_rate_limit_check": { + "name": "system_billing_tier_sync_rate_limit_check", + "value": "\"system_billing_tier\".\"sync_rate_limit_per_minute\" is null or \"system_billing_tier\".\"sync_rate_limit_per_minute\" >= 0" + }, + "system_billing_tier_async_rate_limit_check": { + "name": "system_billing_tier_async_rate_limit_check", + "value": "\"system_billing_tier\".\"async_rate_limit_per_minute\" is null or \"system_billing_tier\".\"async_rate_limit_per_minute\" >= 0" + }, + "system_billing_tier_api_endpoint_rate_limit_check": { + "name": "system_billing_tier_api_endpoint_rate_limit_check", + "value": "\"system_billing_tier\".\"api_endpoint_rate_limit_per_minute\" is null or \"system_billing_tier\".\"api_endpoint_rate_limit_per_minute\" >= 0" + }, + "system_billing_tier_max_pending_age_check": { + "name": "system_billing_tier_max_pending_age_check", + "value": "\"system_billing_tier\".\"max_pending_age_seconds\" is null or \"system_billing_tier\".\"max_pending_age_seconds\" >= 0" + }, + "system_billing_tier_max_pending_count_check": { + "name": "system_billing_tier_max_pending_count_check", + "value": "\"system_billing_tier\".\"max_pending_count\" is null or \"system_billing_tier\".\"max_pending_count\" >= 0" + }, + "system_billing_tier_log_retention_days_check": { + "name": "system_billing_tier_log_retention_days_check", + "value": "\"system_billing_tier\".\"log_retention_days\" is null or \"system_billing_tier\".\"log_retention_days\" >= 0" + }, + "system_billing_tier_workflow_execution_multiplier_check": { + "name": "system_billing_tier_workflow_execution_multiplier_check", + "value": "\"system_billing_tier\".\"workflow_execution_multiplier\" >= 0" + }, + "system_billing_tier_workflow_model_cost_multiplier_check": { + "name": "system_billing_tier_workflow_model_cost_multiplier_check", + "value": "\"system_billing_tier\".\"workflow_model_cost_multiplier\" >= 0" + }, + "system_billing_tier_function_execution_multiplier_check": { + "name": "system_billing_tier_function_execution_multiplier_check", + "value": "\"system_billing_tier\".\"function_execution_multiplier\" >= 0" + }, + "system_billing_tier_copilot_cost_multiplier_check": { + "name": "system_billing_tier_copilot_cost_multiplier_check", + "value": "\"system_billing_tier\".\"copilot_cost_multiplier\" >= 0" + }, + "system_billing_tier_seat_range_check": { + "name": "system_billing_tier_seat_range_check", + "value": "\"system_billing_tier\".\"seat_maximum\" is null or \"system_billing_tier\".\"seat_count\" is null or \"system_billing_tier\".\"seat_maximum\" >= \"system_billing_tier\".\"seat_count\"" + }, + "system_billing_tier_user_owner_shape_check": { + "name": "system_billing_tier_user_owner_shape_check", + "value": "\"system_billing_tier\".\"owner_type\" = 'organization' or (\"system_billing_tier\".\"usage_scope\" = 'individual' and \"system_billing_tier\".\"seat_mode\" = 'fixed' and \"system_billing_tier\".\"seat_count\" is null and \"system_billing_tier\".\"seat_maximum\" is null)" + }, + "system_billing_tier_org_seat_count_check": { + "name": "system_billing_tier_org_seat_count_check", + "value": "\"system_billing_tier\".\"owner_type\" = 'user' or \"system_billing_tier\".\"seat_count\" is not null" + }, + "system_billing_tier_fixed_seat_maximum_check": { + "name": "system_billing_tier_fixed_seat_maximum_check", + "value": "\"system_billing_tier\".\"seat_mode\" = 'adjustable' or \"system_billing_tier\".\"seat_maximum\" is null" + }, + "system_billing_tier_sso_owner_type_check": { + "name": "system_billing_tier_sso_owner_type_check", + "value": "\"system_billing_tier\".\"owner_type\" = 'organization' or \"system_billing_tier\".\"can_configure_sso\" = false" + } + }, + "isRLSEnabled": false + }, + "public.system_integration_definition": { + "name": "system_integration_definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "system_integration_definition_parent_id_idx": { + "name": "system_integration_definition_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "system_integration_definition_parent_id_system_integration_definition_id_fk": { + "name": "system_integration_definition_parent_id_system_integration_definition_id_fk", + "tableFrom": "system_integration_definition", + "tableTo": "system_integration_definition", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "system_integration_definition_parent_check": { + "name": "system_integration_definition_parent_check", + "value": "\"system_integration_definition\".\"parent_id\" is null or \"system_integration_definition\".\"parent_id\" <> \"system_integration_definition\".\"id\"" + }, + "system_integration_definition_availability_check": { + "name": "system_integration_definition_availability_check", + "value": "(\"system_integration_definition\".\"parent_id\" is null and \"system_integration_definition\".\"is_enabled\" is null) or (\"system_integration_definition\".\"parent_id\" is not null and \"system_integration_definition\".\"is_enabled\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.system_integration_secret": { + "name": "system_integration_secret", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "system_integration_secret_definition_id_idx": { + "name": "system_integration_secret_definition_id_idx", + "columns": [ + { + "expression": "definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "system_integration_secret_definition_key_unique": { + "name": "system_integration_secret_definition_key_unique", + "columns": [ + { + "expression": "definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "system_integration_secret_definition_id_system_integration_definition_id_fk": { + "name": "system_integration_secret_definition_id_system_integration_definition_id_fk", + "tableFrom": "system_integration_secret", + "tableTo": "system_integration_definition", + "columnsFrom": [ + "definition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_service_values": { + "name": "system_service_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "system_service_value_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "system_service_values_service_idx": { + "name": "system_service_values_service_idx", + "columns": [ + { + "expression": "service", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "system_service_values_service_kind_idx": { + "name": "system_service_values_service_kind_idx", + "columns": [ + { + "expression": "service", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "system_service_values_service_kind_key_unique": { + "name": "system_service_values_service_kind_key_unique", + "columns": [ + { + "expression": "service", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "registration_mode": { + "name": "registration_mode", + "type": "registration_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "billing_enabled": { + "name": "billing_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_dev_enabled": { + "name": "trigger_dev_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_promotion_codes": { + "name": "allow_promotion_codes", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_domain": { + "name": "email_domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'tradinggoose.ai'" + }, + "from_email_address": { + "name": "from_email_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "waitlist_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rejected_by_user_id": { + "name": "rejected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signed_up_at": { + "name": "signed_up_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferred_locale": { + "name": "preferred_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "waitlist_email_idx": { + "name": "waitlist_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_status_idx": { + "name": "waitlist_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_user_id_idx": { + "name": "waitlist_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "waitlist_approved_by_user_id_user_id_fk": { + "name": "waitlist_approved_by_user_id_user_id_fk", + "tableFrom": "waitlist", + "tableTo": "user", + "columnsFrom": [ + "approved_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "waitlist_rejected_by_user_id_user_id_fk": { + "name": "waitlist_rejected_by_user_id_user_id_fk", + "tableFrom": "waitlist", + "tableTo": "user", + "columnsFrom": [ + "rejected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "waitlist_user_id_user_id_fk": { + "name": "waitlist_user_id_user_id_fk", + "tableFrom": "waitlist", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger_block_id": { + "name": "trigger_block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_workflow_trigger_unique": { + "name": "chat_workflow_trigger_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trigger_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_deployment_version_idx": { + "name": "chat_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "chat_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "chat", + "tableTo": "workflow_deployment_version", + "columnsFrom": [ + "deployment_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_namespace_unique": { + "name": "idempotency_key_namespace_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idempotency_key_namespace_idx": { + "name": "idempotency_key_namespace_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workflow_idx": { + "name": "memory_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workflow_key_idx": { + "name": "memory_workflow_key_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workflow_id_workflow_id_fk": { + "name": "memory_workflow_id_workflow_id_fk", + "tableFrom": "memory", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orderHistoryTable": { + "name": "orderHistoryTable", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recorded_at": { + "name": "recorded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "submission_source": { + "name": "submission_source", + "type": "order_submission_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "log_id": { + "name": "log_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "listing_identity": { + "name": "listing_identity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "normalized_order": { + "name": "normalized_order", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "order_history_provider_idx": { + "name": "order_history_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "order_history_workspace_idx": { + "name": "order_history_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "order_history_log_idx": { + "name": "order_history_log_idx", + "columns": [ + { + "expression": "log_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "order_history_recorded_at_idx": { + "name": "order_history_recorded_at_idx", + "columns": [ + { + "expression": "recorded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "order_history_workspace_recorded_idx": { + "name": "order_history_workspace_recorded_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recorded_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orderHistoryTable_workspace_id_workspace_id_fk": { + "name": "orderHistoryTable_workspace_id_workspace_id_fk", + "tableFrom": "orderHistoryTable", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "orderHistoryTable_log_id_workflow_execution_logs_id_fk": { + "name": "orderHistoryTable_log_id_workflow_execution_logs_id_fk", + "tableFrom": "orderHistoryTable", + "tableTo": "workflow_execution_logs", + "columnsFrom": [ + "log_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_execution": { + "name": "pending_execution", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "billing_scope_id": { + "name": "billing_scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_scope_type": { + "name": "billing_scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_type": { + "name": "execution_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ordering_key": { + "name": "ordering_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "pending_execution_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_execution_billing_scope_idx": { + "name": "pending_execution_billing_scope_idx", + "columns": [ + { + "expression": "billing_scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_execution_workflow_idx": { + "name": "pending_execution_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_execution_ordering_key_idx": { + "name": "pending_execution_ordering_key_idx", + "columns": [ + { + "expression": "billing_scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordering_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_execution_source_idx": { + "name": "pending_execution_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pending_execution_status_idx": { + "name": "pending_execution_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_execution_user_id_user_id_fk": { + "name": "pending_execution_user_id_user_id_fk", + "tableFrom": "pending_execution", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_execution_workflow_id_workflow_id_fk": { + "name": "pending_execution_workflow_id_workflow_id_fk", + "tableFrom": "pending_execution", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_execution_workspace_id_workspace_id_fk": { + "name": "pending_execution_workspace_id_workspace_id_fk", + "tableFrom": "pending_execution", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_idx": { + "name": "path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_block_id_workflow_blocks_id_fk": { + "name": "webhook_block_id_workflow_blocks_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_blocks", + "columnsFrom": [ + "block_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_state": { + "name": "deployed_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned_api_key_id": { + "name": "pinned_api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "collaborators": { + "name": "collaborators", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_pinned_api_key_id_api_key_id_fk": { + "name": "workflow_pinned_api_key_id_api_key_id_fk", + "tableFrom": "workflow", + "tableTo": "api_key", + "columnsFrom": [ + "pinned_api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_workflow_type_idx": { + "name": "workflow_blocks_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_id_idx": { + "name": "workflow_deployment_version_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": [ + "source_block_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": [ + "target_block_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_summary": { + "name": "workflow_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_id_idx": { + "name": "workflow_execution_logs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_idx": { + "name": "workflow_execution_logs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": [ + "state_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workspace_id_idx": { + "name": "workflow_snapshots_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_snapshots_workspace_id_workspace_id_fk": { + "name": "workflow_execution_snapshots_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_log_webhook": { + "name": "workflow_log_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_log_webhook_workflow_id_idx": { + "name": "workflow_log_webhook_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_log_webhook_active_idx": { + "name": "workflow_log_webhook_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_log_webhook_workflow_id_workflow_id_fk": { + "name": "workflow_log_webhook_workflow_id_workflow_id_fk", + "tableFrom": "workflow_log_webhook", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_log_webhook_delivery": { + "name": "workflow_log_webhook_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_summary": { + "name": "workflow_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "subscription_snapshot": { + "name": "subscription_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "webhook_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_log_webhook_delivery_subscription_id_idx": { + "name": "workflow_log_webhook_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_log_webhook_delivery_execution_id_idx": { + "name": "workflow_log_webhook_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_log_webhook_delivery_workspace_id_idx": { + "name": "workflow_log_webhook_delivery_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_log_webhook_delivery_status_idx": { + "name": "workflow_log_webhook_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_log_webhook_delivery_next_attempt_idx": { + "name": "workflow_log_webhook_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_log_webhook_delivery_subscription_id_workflow_log_webhook_id_fk": { + "name": "workflow_log_webhook_delivery_subscription_id_workflow_log_webhook_id_fk", + "tableFrom": "workflow_log_webhook_delivery", + "tableTo": "workflow_log_webhook", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_log_webhook_delivery_workflow_id_workflow_id_fk": { + "name": "workflow_log_webhook_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workflow_log_webhook_delivery", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_log_webhook_delivery_workspace_id_workspace_id_fk": { + "name": "workflow_log_webhook_delivery_workspace_id_workspace_id_fk", + "tableFrom": "workflow_log_webhook_delivery", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_unique": { + "name": "workflow_schedule_workflow_block_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_block_id_workflow_blocks_id_fk": { + "name": "workflow_schedule_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_blocks", + "columnsFrom": [ + "block_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_variables": { + "name": "environment_variables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_variables_user_id_idx": { + "name": "environment_variables_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_variables_workspace_id_idx": { + "name": "environment_variables_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_variables_user_key_unique": { + "name": "environment_variables_user_key_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_variables_workspace_key_unique": { + "name": "environment_variables_workspace_key_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_variables_user_id_user_id_fk": { + "name": "environment_variables_user_id_user_id_fk", + "tableFrom": "environment_variables", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_variables_workspace_id_workspace_id_fk": { + "name": "environment_variables_workspace_id_workspace_id_fk", + "tableFrom": "environment_variables", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "environment_variables_scope_check": { + "name": "environment_variables_scope_check", + "value": "(user_id IS NOT NULL AND workspace_id IS NULL) OR (user_id IS NULL AND workspace_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.layout_map": { + "name": "layout_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "color_pair": { + "name": "color_pair", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "layout_map_workspace_idx": { + "name": "layout_map_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "layout_map_user_idx": { + "name": "layout_map_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "layout_map_workspace_user_idx": { + "name": "layout_map_workspace_user_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "layout_map_workspace_user_active_idx": { + "name": "layout_map_workspace_user_active_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "layout_map_workspace_id_workspace_id_fk": { + "name": "layout_map_workspace_id_workspace_id_fk", + "tableFrom": "layout_map", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "layout_map_user_id_user_id_fk": { + "name": "layout_map_user_id_user_id_fk", + "tableFrom": "layout_map", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "env": { + "name": "env", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_idx": { + "name": "mcp_servers_workspace_deleted_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.monitor_view": { + "name": "monitor_view", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "monitor_view_workspace_idx": { + "name": "monitor_view_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monitor_view_user_idx": { + "name": "monitor_view_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monitor_view_workspace_user_idx": { + "name": "monitor_view_workspace_user_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monitor_view_workspace_user_active_idx": { + "name": "monitor_view_workspace_user_active_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monitor_view_workspace_user_sort_idx": { + "name": "monitor_view_workspace_user_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monitor_view_workspace_id_workspace_id_fk": { + "name": "monitor_view_workspace_id_workspace_id_fk", + "tableFrom": "monitor_view", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_view_user_id_user_id_fk": { + "name": "monitor_view_user_id_user_id_fk", + "tableFrom": "monitor_view", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_indicators": { + "name": "custom_indicators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'New Indicator'" + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "pine_code": { + "name": "pine_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_indicators_workspace_id_idx": { + "name": "custom_indicators_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_indicators_workspace_id_workspace_id_fk": { + "name": "custom_indicators_workspace_id_workspace_id_fk", + "tableFrom": "custom_indicators", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_indicators_user_id_user_id_fk": { + "name": "custom_indicators_user_id_user_id_fk", + "tableFrom": "custom_indicators", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_id_idx": { + "name": "skill_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watchlist_item": { + "name": "watchlist_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "watchlist_id": { + "name": "watchlist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "container_id": { + "name": "container_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "listing": { + "name": "listing", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watchlist_item_watchlist_idx": { + "name": "watchlist_item_watchlist_idx", + "columns": [ + { + "expression": "watchlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watchlist_item_watchlist_container_sort_idx": { + "name": "watchlist_item_watchlist_container_sort_idx", + "columns": [ + { + "expression": "watchlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "container_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watchlist_item_container_sort_idx": { + "name": "watchlist_item_container_sort_idx", + "columns": [ + { + "expression": "container_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watchlist_item_watchlist_listing_identity_unique": { + "name": "watchlist_item_watchlist_listing_identity_unique", + "columns": [ + { + "expression": "watchlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"listing\"->>'listing_type', '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"listing\"->>'listing_id', '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"listing\"->>'base_id', '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"listing\"->>'quote_id', '')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watchlist_item_watchlist_id_watchlist_table_id_fk": { + "name": "watchlist_item_watchlist_id_watchlist_table_id_fk", + "tableFrom": "watchlist_item", + "tableTo": "watchlist_table", + "columnsFrom": [ + "watchlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_item_container_id_watchlist_table_id_fk": { + "name": "watchlist_item_container_id_watchlist_table_id_fk", + "tableFrom": "watchlist_item", + "tableTo": "watchlist_table", + "columnsFrom": [ + "container_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watchlist_table": { + "name": "watchlist_table", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "watchlist_table_workspace_user_idx": { + "name": "watchlist_table_workspace_user_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watchlist_table_workspace_user_parent_idx": { + "name": "watchlist_table_workspace_user_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watchlist_table_parent_sort_idx": { + "name": "watchlist_table_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "watchlist_table_workspace_user_name_unique": { + "name": "watchlist_table_workspace_user_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"watchlist_table\".\"parent_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watchlist_table_workspace_id_workspace_id_fk": { + "name": "watchlist_table_workspace_id_workspace_id_fk", + "tableFrom": "watchlist_table", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_table_user_id_user_id_fk": { + "name": "watchlist_table_user_id_user_id_fk", + "tableFrom": "watchlist_table", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "watchlist_table_parent_id_watchlist_table_id_fk": { + "name": "watchlist_table_parent_id_watchlist_table_id_fk", + "tableFrom": "watchlist_table", + "tableTo": "watchlist_table", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitation": { + "name": "workspace_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "workspace_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "org_invitation_id": { + "name": "org_invitation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invitation_workspace_id_workspace_id_fk": { + "name": "workspace_invitation_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitation_inviter_id_user_id_fk": { + "name": "workspace_invitation_inviter_id_user_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitation_token_unique": { + "name": "workspace_invitation_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": [ + "admin", + "member" + ] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": [ + "active", + "pending", + "revoked" + ] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": [ + "oauth", + "env_workspace", + "env_personal", + "service_account" + ] + }, + "public.registration_mode": { + "name": "registration_mode", + "schema": "public", + "values": [ + "open", + "waitlist", + "disabled" + ] + }, + "public.system_service_value_kind": { + "name": "system_service_value_kind", + "schema": "public", + "values": [ + "credential", + "setting" + ] + }, + "public.waitlist_status": { + "name": "waitlist_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected", + "signed_up" + ] + }, + "public.order_submission_source": { + "name": "order_submission_source", + "schema": "public", + "values": [ + "manual", + "copilot", + "workflow" + ] + }, + "public.pending_execution_status": { + "name": "pending_execution_status", + "schema": "public", + "values": [ + "pending", + "processing" + ] + }, + "public.webhook_delivery_status": { + "name": "webhook_delivery_status", + "schema": "public", + "values": [ + "pending", + "in_progress", + "success", + "failed", + "cancelled" + ] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": [ + "admin", + "write", + "read" + ] + }, + "public.workspace_invitation_status": { + "name": "workspace_invitation_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "rejected", + "cancelled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 30b17efd0..81d54c2c1 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -260,6 +260,13 @@ "when": 1781647170895, "tag": "0036_fast_skullbuster", "breakpoints": true + }, + { + "idx": 37, + "version": "7", + "when": 1783044372927, + "tag": "0037_familiar_killraven", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/schema/workflows.ts b/packages/db/schema/workflows.ts index bb820c55c..cff52e27f 100644 --- a/packages/db/schema/workflows.ts +++ b/packages/db/schema/workflows.ts @@ -73,8 +73,6 @@ export const workflow = pgTable( runCount: integer('run_count').notNull().default(0), lastRunAt: timestamp('last_run_at'), variables: json('variables').default('{}'), - isPublished: boolean('is_published').notNull().default(false), - marketplaceData: json('marketplace_data'), }, (table) => ({ userIdIdx: index('workflow_user_id_idx').on(table.userId), @@ -437,24 +435,6 @@ export const pendingExecution = pgTable( }) ) -export const marketplace = pgTable('marketplace', { - id: text('id').primaryKey(), - workflowId: text('workflow_id') - .notNull() - .references(() => workflow.id, { onDelete: 'cascade' }), - state: json('state').notNull(), - name: text('name').notNull(), - description: text('description'), - authorId: text('author_id') - .notNull() - .references(() => user.id), - authorName: text('author_name').notNull(), - views: integer('views').notNull().default(0), - category: text('category'), - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), -}) - export const memory = pgTable( 'memory', { @@ -591,84 +571,6 @@ export const chat = pgTable( } ) -export const templates = pgTable( - 'templates', - { - id: text('id').primaryKey(), - workflowId: text('workflow_id').references(() => workflow.id), - userId: text('user_id') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - name: text('name').notNull(), - description: text('description'), - author: text('author').notNull(), - views: integer('views').notNull().default(0), - stars: integer('stars').notNull().default(0), - color: text('color').notNull().default('#3972F6'), - icon: text('icon').notNull().default('FileText'), // Lucide icon name as string - category: text('category').notNull(), - state: jsonb('state').notNull(), // Using jsonb for better performance - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), - }, - (table) => ({ - // Primary access patterns - workflowIdIdx: index('templates_workflow_id_idx').on(table.workflowId), - userIdIdx: index('templates_user_id_idx').on(table.userId), - categoryIdx: index('templates_category_idx').on(table.category), - - // Sorting indexes for popular/trending templates - viewsIdx: index('templates_views_idx').on(table.views), - starsIdx: index('templates_stars_idx').on(table.stars), - - // Composite indexes for common queries - categoryViewsIdx: index('templates_category_views_idx').on(table.category, table.views), - categoryStarsIdx: index('templates_category_stars_idx').on(table.category, table.stars), - userCategoryIdx: index('templates_user_category_idx').on(table.userId, table.category), - - // Temporal indexes - createdAtIdx: index('templates_created_at_idx').on(table.createdAt), - updatedAtIdx: index('templates_updated_at_idx').on(table.updatedAt), - }) -) - -export const templateStars = pgTable( - 'template_stars', - { - id: text('id').primaryKey(), - userId: text('user_id') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - templateId: text('template_id') - .notNull() - .references(() => templates.id, { onDelete: 'cascade' }), - starredAt: timestamp('starred_at').notNull().defaultNow(), - createdAt: timestamp('created_at').notNull().defaultNow(), - }, - (table) => ({ - // Primary access patterns - userIdIdx: index('template_stars_user_id_idx').on(table.userId), - templateIdIdx: index('template_stars_template_id_idx').on(table.templateId), - - // Composite indexes for common queries - userTemplateIdx: index('template_stars_user_template_idx').on(table.userId, table.templateId), - templateUserIdx: index('template_stars_template_user_idx').on(table.templateId, table.userId), - - // Temporal indexes for analytics - starredAtIdx: index('template_stars_starred_at_idx').on(table.starredAt), - templateStarredAtIdx: index('template_stars_template_starred_at_idx').on( - table.templateId, - table.starredAt - ), - - // Uniqueness constraint - prevent duplicate stars - uniqueUserTemplateConstraint: uniqueIndex('template_stars_user_template_unique').on( - table.userId, - table.templateId - ), - }) -) - // Idempotency keys for preventing duplicate processing across all webhooks and triggers export const idempotencyKey = pgTable( 'idempotency_key', diff --git a/packages/db/schema/workspaces.ts b/packages/db/schema/workspaces.ts index b4dec8a00..5ce941ea2 100644 --- a/packages/db/schema/workspaces.ts +++ b/packages/db/schema/workspaces.ts @@ -151,7 +151,6 @@ export const pineIndicators = pgTable( name: text('name').notNull().default('New Indicator'), color: text('color').notNull().default('#3972F6'), pineCode: text('pine_code').notNull().default(''), - inputMeta: json('input_meta'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, diff --git a/packages/python-sdk/README.md b/packages/python-sdk/README.md index ed86f70eb..698ea5e2f 100644 --- a/packages/python-sdk/README.md +++ b/packages/python-sdk/README.md @@ -139,7 +139,6 @@ class WorkflowExecutionResult: class WorkflowStatus: is_deployed: bool deployed_at: Optional[str] = None - is_published: bool = False needs_redeployment: bool = False ``` diff --git a/packages/python-sdk/examples/basic_usage.py b/packages/python-sdk/examples/basic_usage.py index 95077abc9..e354144a0 100644 --- a/packages/python-sdk/examples/basic_usage.py +++ b/packages/python-sdk/examples/basic_usage.py @@ -77,7 +77,6 @@ def status_example(): status = client.get_workflow_status("your-workflow-id") print(f"Status: {{\n" f" deployed: {status.is_deployed},\n" - f" published: {status.is_published},\n" f" needs_redeployment: {status.needs_redeployment},\n" f" deployed_at: {status.deployed_at}\n" f"}}") @@ -258,4 +257,4 @@ def error_handling_example(): print(f"\n💥 Example failed: {e}") exit(1) - print("🎉 All examples completed successfully!") \ No newline at end of file + print("🎉 All examples completed successfully!") diff --git a/packages/python-sdk/tests/test_client.py b/packages/python-sdk/tests/test_client.py index 87b693db6..460954845 100644 --- a/packages/python-sdk/tests/test_client.py +++ b/packages/python-sdk/tests/test_client.py @@ -79,12 +79,10 @@ def test_workflow_status(): status = WorkflowStatus( is_deployed=True, deployed_at="2023-01-01T00:00:00Z", - is_published=False, needs_redeployment=False ) assert status.is_deployed is True assert status.deployed_at == "2023-01-01T00:00:00Z" - assert status.is_published is False assert status.needs_redeployment is False diff --git a/packages/python-sdk/tradinggoose/__init__.py b/packages/python-sdk/tradinggoose/__init__.py index 7909a7dcb..7fa8322b9 100644 --- a/packages/python-sdk/tradinggoose/__init__.py +++ b/packages/python-sdk/tradinggoose/__init__.py @@ -41,7 +41,6 @@ class WorkflowStatus: """Status of a workflow.""" is_deployed: bool deployed_at: Optional[str] = None - is_published: bool = False needs_redeployment: bool = False @@ -269,7 +268,6 @@ def get_workflow_status(self, workflow_id: str) -> WorkflowStatus: return WorkflowStatus( is_deployed=status_data.get('isDeployed', False), deployed_at=status_data.get('deployedAt'), - is_published=status_data.get('isPublished', False), needs_redeployment=status_data.get('needsRedeployment', False) ) diff --git a/packages/ts-sdk/README.md b/packages/ts-sdk/README.md index 522acd695..4c2251a0b 100644 --- a/packages/ts-sdk/README.md +++ b/packages/ts-sdk/README.md @@ -138,7 +138,6 @@ interface WorkflowExecutionResult { interface WorkflowStatus { isDeployed: boolean; deployedAt?: string; - isPublished: boolean; needsRedeployment: boolean; } ``` diff --git a/packages/ts-sdk/examples/basic-usage.ts b/packages/ts-sdk/examples/basic-usage.ts index 3fd03539f..87decc9b6 100644 --- a/packages/ts-sdk/examples/basic-usage.ts +++ b/packages/ts-sdk/examples/basic-usage.ts @@ -82,7 +82,6 @@ async function statusExample() { const status = await client.getWorkflowStatus('your-workflow-id') console.log('Status:', { deployed: status.isDeployed, - published: status.isPublished, needsRedeployment: status.needsRedeployment, deployedAt: status.deployedAt, }) diff --git a/packages/ts-sdk/src/index.test.ts b/packages/ts-sdk/src/index.test.ts index bc2887889..ba9a21682 100644 --- a/packages/ts-sdk/src/index.test.ts +++ b/packages/ts-sdk/src/index.test.ts @@ -84,7 +84,6 @@ describe('TradingGooseClient', () => { json: vi.fn().mockResolvedValue({ isDeployed: true, deployedAt: '2023-01-01T00:00:00Z', - isPublished: false, needsRedeployment: false, }), } @@ -101,7 +100,6 @@ describe('TradingGooseClient', () => { json: vi.fn().mockResolvedValue({ isDeployed: false, deployedAt: null, - isPublished: false, needsRedeployment: true, }), } diff --git a/packages/ts-sdk/src/index.ts b/packages/ts-sdk/src/index.ts index d762de43a..1c540add6 100644 --- a/packages/ts-sdk/src/index.ts +++ b/packages/ts-sdk/src/index.ts @@ -22,7 +22,6 @@ export interface WorkflowExecutionResult { export interface WorkflowStatus { isDeployed: boolean deployedAt?: string - isPublished: boolean needsRedeployment: boolean } From 983c73e032304d29c7a3e483d9721f091386b73f Mon Sep 17 00:00:00 2001 From: agualdron <agualdro@gmail.com> Date: Mon, 6 Jul 2026 16:30:39 -0500 Subject: [PATCH 17/18] feat(i18n): add catalog scanner for localization coverage Add an *i18n:catalog* CLI that scans route or full-app entry graphs, resolves route ownership, and reports used keys, missing keys, orphaned keys, locale gaps, and hardcoded copy candidates. Cover the scanner with fixture-heavy tests for entry discovery, route resolution, namespace ownership, dynamic copy access, and hardcoded metadata detection. Strengthen *public-copy* tests to preserve landing array shapes and keep locale catalog structures aligned. Update *type-check* to run *next typegen* first and use the dedicated *tsconfig.typecheck.json* so generated Next route types are included in type validation. --- apps/tradinggoose/i18n/public-copy.test.ts | 18 + apps/tradinggoose/package.json | 3 +- .../scripts/i18n-catalog/catalog.ts | 342 ++ .../scripts/i18n-catalog/entries.ts | 487 +++ .../scripts/i18n-catalog/index.test.ts | 3425 ++++++++++++++++ .../scripts/i18n-catalog/index.ts | 147 + .../scripts/i18n-catalog/ownership.ts | 446 ++ .../tradinggoose/scripts/i18n-catalog/scan.ts | 3578 +++++++++++++++++ apps/tradinggoose/tsconfig.typecheck.json | 12 + 9 files changed, 8457 insertions(+), 1 deletion(-) create mode 100644 apps/tradinggoose/scripts/i18n-catalog/catalog.ts create mode 100644 apps/tradinggoose/scripts/i18n-catalog/entries.ts create mode 100644 apps/tradinggoose/scripts/i18n-catalog/index.test.ts create mode 100644 apps/tradinggoose/scripts/i18n-catalog/index.ts create mode 100644 apps/tradinggoose/scripts/i18n-catalog/ownership.ts create mode 100644 apps/tradinggoose/scripts/i18n-catalog/scan.ts create mode 100644 apps/tradinggoose/tsconfig.typecheck.json diff --git a/apps/tradinggoose/i18n/public-copy.test.ts b/apps/tradinggoose/i18n/public-copy.test.ts index 06ed971b1..a75415cff 100644 --- a/apps/tradinggoose/i18n/public-copy.test.ts +++ b/apps/tradinggoose/i18n/public-copy.test.ts @@ -87,6 +87,15 @@ describe('public copy', () => { ) }) + it('preserves public landing array shapes', () => { + const landingCopy = getPublicCopy('en').landing + + expect(Array.isArray(landingCopy.hero.leadWords)).toBe(true) + expect(Array.isArray(landingCopy.hero.featureBadges)).toBe(true) + expect(Array.isArray(landingCopy.features.rows)).toBe(true) + expect(Array.isArray(landingCopy.howItWorks.processes)).toBe(true) + }) + it('includes localized blog and not found copy', () => { expect(getPublicCopy('en').blog.shareTitle).toBe('Share This Article') expect(getPublicCopy('es').blog.tableOfContents).toBe('En esta página') @@ -581,6 +590,15 @@ describe('public copy', () => { } }) + it('keeps locale catalog shapes aligned after catalog additions or removals', () => { + const enCopy = getPublicCopy('en') + const esCopy = getPublicCopy('es') + const zhCopy = getPublicCopy('zh') + + expect(normalizeShape(esCopy)).toEqual(normalizeShape(enCopy)) + expect(normalizeShape(zhCopy)).toEqual(normalizeShape(enCopy)) + }) + it('formats template placeholders', () => { const copy = getPublicCopy('en') diff --git a/apps/tradinggoose/package.json b/apps/tradinggoose/package.json index 83c047aa8..363e2cc81 100644 --- a/apps/tradinggoose/package.json +++ b/apps/tradinggoose/package.json @@ -15,12 +15,13 @@ "start": "next start", "i18n": "bunx lingo.dev@latest run", "i18n:frozen": "bunx lingo.dev@latest run --frozen", + "i18n:catalog": "bun run scripts/i18n-catalog/index.ts", "prepare": "cd ../.. && bun husky", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "email:dev": "NODE_OPTIONS='--experimental-vm-modules --disable-warning=ExperimentalWarning' email dev --dir components/emails", - "type-check": "NODE_OPTIONS=--max-old-space-size=8192 tsc --noEmit", + "type-check": "next typegen && NODE_OPTIONS=--max-old-space-size=8192 tsc --noEmit -p tsconfig.typecheck.json", "test:billing:suite": "bun run scripts/test-billing-suite.ts" }, "dependencies": { diff --git a/apps/tradinggoose/scripts/i18n-catalog/catalog.ts b/apps/tradinggoose/scripts/i18n-catalog/catalog.ts new file mode 100644 index 000000000..590a41072 --- /dev/null +++ b/apps/tradinggoose/scripts/i18n-catalog/catalog.ts @@ -0,0 +1,342 @@ +import fs from 'node:fs' +import path from 'node:path' +import { defaultLocale, locales, type AppLocale } from '../../i18n/routing' +import { toRelativeProjectPath } from './entries' +import type { CatalogScanResult, CoverageRecord, HardcodedCandidate } from './scan' + +type JsonPrimitive = boolean | number | string | null +type JsonValue = JsonPrimitive | JsonObject | JsonValue[] +type JsonObject = { [key: string]: JsonValue } + +type LocaleCode = AppLocale +type TargetLocaleCode = Exclude<LocaleCode, typeof defaultLocale> + +type LocaleCatalogs = Record<LocaleCode, JsonObject> + +type CatalogIndex = { + leafMap: Map<string, string> + objectPaths: Set<string> + leafPathsByRoot: Map<string, string[]> +} + +export type MissingKey = { + filePath: string + line: number + column: number + pathKey: string +} + +export type TargetLocaleGap = { + locale: TargetLocaleCode + pathKey: string +} + +export type OrphanedKey = { + pathKey: string +} + +export type HardcodedCandidateReport = { + filePath: string + line: number + column: number + text: string + kind: HardcodedCandidate['kind'] + namespace: string + namespaceSource: HardcodedCandidate['namespaceSource'] + attributeName?: string + metadata: boolean + existingPathKey?: string + suggestedPathKey: string +} + +export type CatalogReport = { + routePath: string | null + scannedFiles: string[] + usedKeys: string[] + missingKeys: MissingKey[] + orphanedKeys?: OrphanedKey[] + dynamicProtectedRoots?: string[] + targetLocaleGaps: TargetLocaleGap[] + hardcodedCandidates: HardcodedCandidateReport[] +} + +type BuildReportOptions = { + projectRoot: string + scanResult: CatalogScanResult + globalScanResult?: CatalogScanResult +} + +function getCatalogFilePath(projectRoot: string, locale: LocaleCode) { + return path.join(projectRoot, 'i18n', 'messages', `${locale}.json`) +} + +function loadLocaleCatalogs(projectRoot: string): LocaleCatalogs { + return Object.fromEntries( + locales.map((locale) => [ + locale, + JSON.parse(fs.readFileSync(getCatalogFilePath(projectRoot, locale), 'utf8')) as JsonObject, + ]) + ) as LocaleCatalogs +} + +function buildCatalogIndex(catalog: JsonObject): CatalogIndex { + const leafMap = new Map<string, string>() + const objectPaths = new Set<string>() + const leafPathsByRoot = new Map<string, string[]>() + + const visit = (value: JsonValue, pathParts: string[]) => { + if (typeof value === 'string') { + const pathKey = pathParts.join('.') + leafMap.set(pathKey, value) + for (let index = 1; index < pathParts.length; index += 1) { + const rootPathKey = pathParts.slice(0, index).join('.') + const rootLeafPaths = leafPathsByRoot.get(rootPathKey) ?? [] + rootLeafPaths.push(pathKey) + leafPathsByRoot.set(rootPathKey, rootLeafPaths) + } + return + } + + if (!value || typeof value !== 'object') { + return + } + + if (Array.isArray(value)) { + if (pathParts.length > 0) { + objectPaths.add(pathParts.join('.')) + } + + for (const [index, child] of value.entries()) { + visit(child, [...pathParts, String(index)]) + } + return + } + + if (pathParts.length > 0) { + objectPaths.add(pathParts.join('.')) + } + + for (const [key, child] of Object.entries(value)) { + visit(child, [...pathParts, key]) + } + } + + visit(catalog, []) + return { leafMap, objectPaths, leafPathsByRoot } +} + +type CoverageSummary = { + dynamicRootPaths: string[] + missingKeys: MissingKey[] + usedKeys: string[] +} + +function buildCoverageSummary( + coverage: CoverageRecord[], + catalogIndex: CatalogIndex +): CoverageSummary { + const usedKeys = new Set<string>() + const missingKeysByPath = new Map<string, MissingKey>() + const dynamicRootPaths = new Set<string>() + + const addSubtreeLeafUsage = (pathKey: string) => { + if (catalogIndex.leafMap.has(pathKey)) { + usedKeys.add(pathKey) + return true + } + + const leafPaths = catalogIndex.leafPathsByRoot.get(pathKey) + if (!leafPaths) { + return false + } + + for (const leafPath of leafPaths) { + usedKeys.add(leafPath) + } + + return true + } + + for (const record of coverage) { + if (record.mode === 'subtree' && record.subtreeReason === 'dynamic-root') { + dynamicRootPaths.add(record.pathKey) + } + + if (record.mode === 'subtree') { + if (addSubtreeLeafUsage(record.pathKey)) { + continue + } + + if (catalogIndex.objectPaths.has(record.pathKey)) { + continue + } + } else if (catalogIndex.leafMap.has(record.pathKey)) { + usedKeys.add(record.pathKey) + continue + } else if (catalogIndex.objectPaths.has(record.pathKey)) { + continue + } + + if (!missingKeysByPath.has(record.pathKey)) { + missingKeysByPath.set(record.pathKey, { + filePath: record.filePath, + line: record.line, + column: record.column, + pathKey: record.pathKey, + }) + } + } + + return { + dynamicRootPaths: [...dynamicRootPaths].sort(), + usedKeys: [...usedKeys].sort(), + missingKeys: [...missingKeysByPath.values()].sort((left, right) => + left.pathKey.localeCompare(right.pathKey) + ), + } +} + +function isOwnedPath(pathKey: string, ownedNamespaces: string[]) { + if (ownedNamespaces.length === 0) { + return true + } + + return ownedNamespaces.some( + (namespace) => pathKey === namespace || pathKey.startsWith(`${namespace}.`) + ) +} + +function buildTargetLocaleGaps(usedKeys: string[], catalogs: LocaleCatalogs) { + const gaps: TargetLocaleGap[] = [] + const targetLocales = locales.filter( + (locale): locale is TargetLocaleCode => locale !== defaultLocale + ) + const localeIndexes = Object.fromEntries( + targetLocales.map((locale) => [locale, buildCatalogIndex(catalogs[locale])]) + ) as Record<TargetLocaleCode, CatalogIndex> + + for (const locale of targetLocales) { + for (const pathKey of usedKeys) { + if (!localeIndexes[locale].leafMap.has(pathKey)) { + gaps.push({ locale, pathKey }) + } + } + } + + return gaps.sort((left, right) => + left.locale === right.locale + ? left.pathKey.localeCompare(right.pathKey) + : left.locale.localeCompare(right.locale) + ) +} + +function normalizeCatalogText(value: string) { + return value.replace(/\s+/g, ' ').trim().toLowerCase() +} + +function buildCatalogValueIndex(catalogIndex: CatalogIndex, ownedNamespaces: string[]) { + const valueIndex = new Map<string, string[]>() + + for (const [pathKey, value] of catalogIndex.leafMap.entries()) { + if (!isOwnedPath(pathKey, ownedNamespaces)) { + continue + } + + const normalizedValue = normalizeCatalogText(value) + const pathKeys = valueIndex.get(normalizedValue) ?? [] + pathKeys.push(pathKey) + valueIndex.set(normalizedValue, pathKeys) + } + + return valueIndex +} + +function findExistingPathKey(candidate: HardcodedCandidate, valueIndex: Map<string, string[]>) { + const matches = valueIndex.get(normalizeCatalogText(candidate.text)) ?? [] + const namespaceMatches = matches.filter( + (pathKey) => pathKey === candidate.namespace || pathKey.startsWith(`${candidate.namespace}.`) + ) + + if (namespaceMatches.length === 1) { + return namespaceMatches[0] + } + + return matches.length === 1 ? matches[0] : undefined +} + +function toSuggestedPathKey(candidate: HardcodedCandidate) { + const suffix = candidate.relativeKeyParts.join('.') + return suffix ? `${candidate.namespace}.${suffix}` : candidate.namespace +} + +export function buildCatalogReport(options: BuildReportOptions): CatalogReport { + const catalogs = loadLocaleCatalogs(options.projectRoot) + const englishCatalogIndex = buildCatalogIndex(catalogs.en) + const hasGlobalScan = Boolean(options.globalScanResult) + + const globalCoverageSummary = options.globalScanResult + ? buildCoverageSummary(options.globalScanResult.coverage, englishCatalogIndex) + : null + const routeCoverageSummary = + options.scanResult.mode === 'all' + ? (globalCoverageSummary ?? + buildCoverageSummary(options.scanResult.coverage, englishCatalogIndex)) + : buildCoverageSummary(options.scanResult.coverage, englishCatalogIndex) + + const ownedNamespaces = + options.scanResult.mode === 'route' ? options.scanResult.ownedNamespaces : [] + const catalogValueIndex = buildCatalogValueIndex(englishCatalogIndex, ownedNamespaces) + const orphanedKeys = hasGlobalScan + ? [...englishCatalogIndex.leafMap.keys()] + .filter((pathKey) => !globalCoverageSummary!.usedKeys.includes(pathKey)) + .filter((pathKey) => isOwnedPath(pathKey, ownedNamespaces)) + .sort() + : null + + const dynamicProtectedRoots = hasGlobalScan + ? globalCoverageSummary!.dynamicRootPaths + .filter((pathKey) => isOwnedPath(pathKey, ownedNamespaces)) + .sort() + : null + + const report: CatalogReport = { + routePath: options.scanResult.routePath, + scannedFiles: options.scanResult.scannedFiles, + usedKeys: routeCoverageSummary.usedKeys, + missingKeys: routeCoverageSummary.missingKeys.map((entry) => ({ + ...entry, + filePath: toRelativeProjectPath(options.projectRoot, entry.filePath), + })), + targetLocaleGaps: buildTargetLocaleGaps(routeCoverageSummary.usedKeys, catalogs), + hardcodedCandidates: [...options.scanResult.hardcodedCandidates] + .sort((left, right) => { + if (left.filePath !== right.filePath) { + return left.filePath.localeCompare(right.filePath) + } + if (left.line !== right.line) { + return left.line - right.line + } + return left.column - right.column + }) + .map((candidate) => ({ + filePath: toRelativeProjectPath(options.projectRoot, candidate.filePath), + line: candidate.line, + column: candidate.column, + text: candidate.text, + kind: candidate.kind, + namespace: candidate.namespace, + namespaceSource: candidate.namespaceSource, + attributeName: candidate.attributeName, + metadata: candidate.metadata, + existingPathKey: findExistingPathKey(candidate, catalogValueIndex), + suggestedPathKey: toSuggestedPathKey(candidate), + })), + } + + if (hasGlobalScan) { + report.orphanedKeys = orphanedKeys!.map((pathKey) => ({ pathKey })) + report.dynamicProtectedRoots = dynamicProtectedRoots! + } + + return report +} diff --git a/apps/tradinggoose/scripts/i18n-catalog/entries.ts b/apps/tradinggoose/scripts/i18n-catalog/entries.ts new file mode 100644 index 000000000..1f151e087 --- /dev/null +++ b/apps/tradinggoose/scripts/i18n-catalog/entries.ts @@ -0,0 +1,487 @@ +import fs from 'node:fs' +import path from 'node:path' +import { findBestMatchingRoutePattern, normalizeRoutePath } from './ownership' + +export const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'] as const + +type FrameworkEntryBasename = + | 'page' + | 'layout' + | 'template' + | 'loading' + | 'error' + | 'global-error' + | 'not-found' + +export type EntryExportName = 'default' | 'generateMetadata' + +type FrameworkEntrySpec = { + basename: FrameworkEntryBasename + exportNames: EntryExportName[] +} + +type EntryDiscoveryResult = { + entryExportNamesByFile: Map<string, EntryExportName[]> + entryFiles: string[] +} + +export type RouteResolution = EntryDiscoveryResult & { + pageFilePath: string + routeOwnedRoots: string[] + routePath: string +} + +export type EntryDiscoveryContext = { + allModeEntries: EntryDiscoveryResult | null + appRoot: string + appSourceFiles: string[] + knownRoutePaths: Set<string> + localizedPageFiles: string[] + localeRoot: string + pageFilePathByRoute: Map<string, string> + projectRoot: string + routeEntriesByRoute: Map<string, RouteResolution> + routePathByDirectory: Map<string, string | null> +} + +type EntryDiscoveryInput = EntryDiscoveryContext | string + +const FRAMEWORK_ENTRY_SPECS: readonly FrameworkEntrySpec[] = [ + { basename: 'page', exportNames: ['default', 'generateMetadata'] }, + { basename: 'layout', exportNames: ['default', 'generateMetadata'] }, + { basename: 'template', exportNames: ['default'] }, + { basename: 'loading', exportNames: ['default'] }, + { basename: 'error', exportNames: ['default'] }, + { basename: 'global-error', exportNames: ['default'] }, + { basename: 'not-found', exportNames: ['default'] }, +] + +const FRAMEWORK_ENTRY_SPEC_BY_BASENAME = new Map( + FRAMEWORK_ENTRY_SPECS.map((spec) => [spec.basename, spec]) +) + +const FRAMEWORK_ENTRY_BASENAMES = new Set<FrameworkEntryBasename>( + FRAMEWORK_ENTRY_SPECS.map((spec) => spec.basename) +) + +const ROUTE_SIBLING_ENTRY_BASENAMES = new Set<FrameworkEntryBasename>( + FRAMEWORK_ENTRY_SPECS.map((spec) => spec.basename) +) + +const ANCESTOR_LOCALIZED_ENTRY_BASENAMES = new Set<FrameworkEntryBasename>([ + 'layout', + 'template', + 'loading', + 'error', + 'global-error', + 'not-found', +]) + +const NON_LOCALIZED_BOUNDARY_ENTRY_BASENAMES = new Set<FrameworkEntryBasename>([ + 'template', + 'loading', + 'error', + 'global-error', + 'not-found', +]) + +function splitProjectPath(relativePath: string) { + return relativePath.split('/').filter(Boolean) +} + +function hasPathPrefix(segments: string[], prefix: string[]) { + return prefix.every((segment, index) => segments[index] === segment) +} + +export function isIgnoredProjectPath(relativePath: string) { + const segments = splitProjectPath(relativePath) + return ( + segments.includes('node_modules') || + segments.includes('migration') || + segments.includes('__tests__') || + segments.includes('__mocks__') || + hasPathPrefix(segments, ['app', 'api']) || + /\.(test|spec|stories)\.[^.]+$/.test(relativePath) + ) +} + +function isIgnoredDirectoryPath(relativePath: string) { + const segments = splitProjectPath(relativePath) + return ( + segments.includes('node_modules') || + segments.includes('.next') || + segments.includes('dist') || + segments.includes('migration') || + segments.includes('__tests__') || + segments.includes('__mocks__') || + hasPathPrefix(segments, ['app', 'api']) + ) +} + +function isSourceFilePath(filePath: string) { + return SOURCE_EXTENSIONS.some((extension) => filePath.endsWith(extension)) +} + +export function toRelativeProjectPath(projectRoot: string, filePath: string) { + return path.relative(projectRoot, filePath).split(path.sep).join('/') +} + +export function walkProjectSourceFiles(projectRoot: string, startDirectory = projectRoot): string[] { + const pending = [startDirectory] + const results: string[] = [] + + while (pending.length > 0) { + const directory = pending.pop()! + const entries = fs.readdirSync(directory, { withFileTypes: true }) + + for (const entry of entries) { + const absolutePath = path.join(directory, entry.name) + const relativePath = toRelativeProjectPath(projectRoot, absolutePath) + + if (entry.isDirectory()) { + if (isIgnoredDirectoryPath(relativePath)) { + continue + } + pending.push(absolutePath) + continue + } + + if (!entry.isFile() || !isSourceFilePath(absolutePath)) { + continue + } + + if (isIgnoredProjectPath(relativePath)) { + continue + } + + results.push(absolutePath) + } + } + + return results.sort() +} + +export function isPathInsideDirectory(targetPath: string, directoryPath: string) { + const relativePath = path.relative(directoryPath, targetPath) + return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) +} + +function isRouteGroupSegment(segment: string) { + return /^\(.+\)$/.test(segment) +} + +function toRoutePath(routeSegments: string[]) { + return routeSegments.length === 0 ? '/' : `/${routeSegments.join('/')}` +} + +function normalizeAppRouteSegments(routeSegments: string[]) { + const normalizedSegments = routeSegments.filter((segment) => !isRouteGroupSegment(segment)) + if (normalizedSegments[0] === '[locale]') { + normalizedSegments.shift() + } + return normalizedSegments +} + +function getLocalizedAppRoot(projectRoot: string) { + return path.join(projectRoot, 'app', '[locale]') +} + +function isFrameworkEntryBasename(value: string): value is FrameworkEntryBasename { + return FRAMEWORK_ENTRY_BASENAMES.has(value as FrameworkEntryBasename) +} + +function getFrameworkEntrySpec(filePath: string) { + const basename = path.basename(filePath, path.extname(filePath)) + if (!isFrameworkEntryBasename(basename)) { + return null + } + + return FRAMEWORK_ENTRY_SPEC_BY_BASENAME.get(basename) ?? null +} + +function addEntryFile( + entryExportNamesByFile: Map<string, EntryExportName[]>, + filePath: string, + exportNames: EntryExportName[] +) { + entryExportNamesByFile.set(filePath, exportNames) +} + +function collectFrameworkEntriesInDirectory( + directoryPath: string, + allowedBasenames: ReadonlySet<FrameworkEntryBasename>, + entryExportNamesByFile: Map<string, EntryExportName[]> +) { + for (const basename of allowedBasenames) { + const spec = FRAMEWORK_ENTRY_SPEC_BY_BASENAME.get(basename) + if (!spec) { + continue + } + + for (const extension of SOURCE_EXTENSIONS) { + const candidatePath = path.join(directoryPath, `${basename}${extension}`) + if (fs.existsSync(candidatePath) && fs.statSync(candidatePath).isFile()) { + addEntryFile(entryExportNamesByFile, candidatePath, spec.exportNames) + } + } + } +} + +function resolveEntryDiscoveryContext(input: EntryDiscoveryInput) { + return typeof input === 'string' ? createEntryDiscoveryContext(input) : input +} + +export function createEntryDiscoveryContext(projectRoot: string): EntryDiscoveryContext { + const appRoot = path.join(projectRoot, 'app') + const localeRoot = getLocalizedAppRoot(projectRoot) + const appSourceFiles = walkProjectSourceFiles(projectRoot, appRoot) + const localizedPageFiles = appSourceFiles + .filter( + (filePath) => + isPathInsideDirectory(filePath, localeRoot) && + path.basename(filePath, path.extname(filePath)) === 'page' + ) + .sort() + const pageFilePathByRoute = new Map<string, string>() + + for (const filePath of localizedPageFiles) { + const relativePath = path.relative(localeRoot, filePath).split(path.sep) + const routePath = toRoutePath(normalizeAppRouteSegments(relativePath.slice(0, -1))) + pageFilePathByRoute.set(routePath, filePath) + } + + return { + allModeEntries: null, + appRoot, + appSourceFiles, + knownRoutePaths: new Set(pageFilePathByRoute.keys()), + localizedPageFiles, + localeRoot, + pageFilePathByRoute, + projectRoot, + routeEntriesByRoute: new Map(), + routePathByDirectory: new Map(), + } +} + +function findLocalizedPageFile(context: EntryDiscoveryContext, routePath: string) { + const normalizedRoutePath = normalizeRoutePath(routePath) + const matchedRoutePath = + context.pageFilePathByRoute.get(normalizedRoutePath) !== undefined + ? normalizedRoutePath + : findBestMatchingRoutePattern(normalizedRoutePath, context.pageFilePathByRoute.keys()) + const pageFilePath = matchedRoutePath ? context.pageFilePathByRoute.get(matchedRoutePath) : null + if (!pageFilePath) { + throw new Error(`Unable to resolve route "${normalizedRoutePath}" from app/[locale]`) + } + + return { + filePath: pageFilePath, + routePath: matchedRoutePath!, + } +} + +function collectLocalizedRouteEntries(localeRoot: string, pageFilePath: string) { + const entryExportNamesByFile = new Map<string, EntryExportName[]>() + let directory = path.dirname(pageFilePath) + let currentBasenames = ROUTE_SIBLING_ENTRY_BASENAMES + + while (directory.startsWith(localeRoot)) { + collectFrameworkEntriesInDirectory(directory, currentBasenames, entryExportNamesByFile) + + if (directory === localeRoot) { + break + } + + directory = path.dirname(directory) + currentBasenames = ANCESTOR_LOCALIZED_ENTRY_BASENAMES + } + + return entryExportNamesByFile +} + +export function isRoutePathPrefix(prefix: string, routePath: string) { + if (prefix === routePath) { + return true + } + + if (prefix === '/') { + return true + } + + return routePath.startsWith(`${prefix}/`) +} + +function getRoutePathForAppDirectory(context: EntryDiscoveryContext, directoryPath: string) { + if (context.routePathByDirectory.has(directoryPath)) { + return context.routePathByDirectory.get(directoryPath) ?? null + } + + let routePath: string | null = null + + if (isPathInsideDirectory(directoryPath, context.appRoot)) { + const relativePath = path.relative(context.appRoot, directoryPath) + routePath = !relativePath + ? '/' + : toRoutePath(normalizeAppRouteSegments(relativePath.split(path.sep).filter(Boolean))) + } + + context.routePathByDirectory.set(directoryPath, routePath) + return routePath +} + +export function resolveAppRoutePathForFile( + input: EntryDiscoveryInput, + filePath: string +): string | null { + const context = resolveEntryDiscoveryContext(input) + if (!isPathInsideDirectory(filePath, context.appRoot)) { + return null + } + + return getRoutePathForAppDirectory(context, path.dirname(filePath)) +} + +function collectNonLocalizedRouteBoundaryEntries(context: EntryDiscoveryContext, routePath: string) { + const entryExportNamesByFile = new Map<string, EntryExportName[]>() + + for (const filePath of context.appSourceFiles) { + if (isPathInsideDirectory(filePath, context.localeRoot)) { + continue + } + + const spec = getFrameworkEntrySpec(filePath) + if (!spec || !NON_LOCALIZED_BOUNDARY_ENTRY_BASENAMES.has(spec.basename)) { + continue + } + + const directoryPath = path.dirname(filePath) + const directoryRoutePath = getRoutePathForAppDirectory(context, directoryPath) + if (!directoryRoutePath || !isRoutePathPrefix(directoryRoutePath, routePath)) { + continue + } + + addEntryFile(entryExportNamesByFile, filePath, spec.exportNames) + } + + return entryExportNamesByFile +} + +function collectExactNonLocalizedRouteOwnedRoots(context: EntryDiscoveryContext, routePath: string) { + const routeOwnedRoots = new Set<string>() + + for (const filePath of context.appSourceFiles) { + if (isPathInsideDirectory(filePath, context.localeRoot)) { + continue + } + + let directoryPath = path.dirname(filePath) + while (directoryPath !== context.appRoot && isPathInsideDirectory(directoryPath, context.appRoot)) { + if (getRoutePathForAppDirectory(context, directoryPath) === routePath) { + routeOwnedRoots.add(directoryPath) + break + } + + const parentDirectoryPath = path.dirname(directoryPath) + if (parentDirectoryPath === directoryPath) { + break + } + directoryPath = parentDirectoryPath + } + } + + return [...routeOwnedRoots].sort() +} + +function toEntryDiscoveryResult(entryExportNamesByFile: Map<string, EntryExportName[]>): EntryDiscoveryResult { + return { + entryExportNamesByFile, + entryFiles: [...entryExportNamesByFile.keys()].sort(), + } +} + +export function discoverAllModeEntries(input: EntryDiscoveryInput): EntryDiscoveryResult { + const context = resolveEntryDiscoveryContext(input) + if (context.allModeEntries) { + return context.allModeEntries + } + + const entryExportNamesByFile = new Map<string, EntryExportName[]>() + + for (const filePath of context.appSourceFiles) { + const spec = getFrameworkEntrySpec(filePath) + if (!spec) { + continue + } + + addEntryFile(entryExportNamesByFile, filePath, spec.exportNames) + } + + const result = toEntryDiscoveryResult(entryExportNamesByFile) + context.allModeEntries = result + return result +} + +export function resolveOwningRoutePathForFile( + input: EntryDiscoveryInput, + filePath: string +): string | null { + const context = resolveEntryDiscoveryContext(input) + if (!isPathInsideDirectory(filePath, context.appRoot)) { + return null + } + + let directoryPath = path.dirname(filePath) + while (isPathInsideDirectory(directoryPath, context.appRoot)) { + const routePath = getRoutePathForAppDirectory(context, directoryPath) + if (routePath && context.knownRoutePaths.has(routePath)) { + return routePath + } + + if (directoryPath === context.appRoot) { + break + } + + const parentDirectoryPath = path.dirname(directoryPath) + if (parentDirectoryPath === directoryPath) { + break + } + directoryPath = parentDirectoryPath + } + + return null +} + +export function resolveRouteEntries(input: EntryDiscoveryInput, routePath: string): RouteResolution { + const context = resolveEntryDiscoveryContext(input) + const normalizedRoutePath = normalizeRoutePath(routePath) + const { filePath: pageFilePath, routePath: matchedRoutePath } = findLocalizedPageFile( + context, + normalizedRoutePath + ) + const cached = context.routeEntriesByRoute.get(matchedRoutePath) + if (cached) { + return cached + } + + const localizedEntries = collectLocalizedRouteEntries(context.localeRoot, pageFilePath) + const nonLocalizedEntries = collectNonLocalizedRouteBoundaryEntries(context, matchedRoutePath) + + const entryExportNamesByFile = new Map<string, EntryExportName[]>(localizedEntries) + for (const [filePath, exportNames] of nonLocalizedEntries.entries()) { + addEntryFile(entryExportNamesByFile, filePath, exportNames) + } + + const result = { + pageFilePath, + routePath: matchedRoutePath, + routeOwnedRoots: [ + path.dirname(pageFilePath), + ...collectExactNonLocalizedRouteOwnedRoots(context, matchedRoutePath), + ].sort(), + ...toEntryDiscoveryResult(entryExportNamesByFile), + } + + context.routeEntriesByRoute.set(matchedRoutePath, result) + return result +} diff --git a/apps/tradinggoose/scripts/i18n-catalog/index.test.ts b/apps/tradinggoose/scripts/i18n-catalog/index.test.ts new file mode 100644 index 000000000..f629715c1 --- /dev/null +++ b/apps/tradinggoose/scripts/i18n-catalog/index.test.ts @@ -0,0 +1,3425 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { defaultLocale, locales } from '../../i18n/routing' +import { buildCatalogReport } from './catalog' +import { discoverAllModeEntries, resolveRouteEntries } from './entries' +import { runCatalogCli } from './index' +import { deriveRouteNamespace, getRouteOwnedNamespaces } from './ownership' +import { scanCatalogProject, type CatalogScanResult, type CoverageRecord } from './scan' + +const tempRoots: string[] = [] + +function writeProjectFiles(projectRoot: string, files: Record<string, string>) { + for (const [relativePath, contents] of Object.entries(files)) { + const absolutePath = path.join(projectRoot, relativePath) + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }) + fs.writeFileSync(absolutePath, contents, 'utf8') + } +} + +function createTempProject(files: Record<string, string>) { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-catalog-')) + tempRoots.push(projectRoot) + writeProjectFiles(projectRoot, files) + return projectRoot +} + +function matchesProjectFilePath(filePath: string, relativePath: string) { + return filePath.endsWith(`/${relativePath}`) +} + +function createLocaleMessages() { + return JSON.stringify( + { + meta: { + privacy: { + title: 'Privacy', + description: 'Privacy description', + }, + }, + changelog: { + pageTitle: 'Changelog', + viewOnGitHub: 'View on GitHub', + documentation: 'Documentation', + rssFeed: 'RSS feed', + viewContributorAriaLabel: 'View contributor', + contributorAvatarAlt: 'Contributor avatar', + loadingMore: 'Loading more', + showMore: 'Show more', + }, + notFound: { + title: 'Not found', + description: 'Page not found', + returnHome: 'Return home', + supportPrefix: 'Need help?', + supportLinkLabel: 'Contact support', + }, + workspace: { + monitor: { + title: 'Monitor', + used: 'Used', + layoutBadge: 'Layout badge', + orphan: 'Orphan', + errors: { + loadViews: 'Unable to load views', + createDefaultView: 'Unable to create default view', + invalidViewResponse: 'Invalid saved view response', + }, + fields: { + status: 'Status', + }, + values: { + running: 'Running', + paused: 'Paused', + }, + }, + templates: { + used: 'Templates used', + otherOrphan: 'Other orphan', + }, + nav: { + workspace: { + dashboard: 'Dashboard', + knowledge: 'Knowledge', + files: 'Files', + records: 'Records', + monitor: 'Monitor', + }, + more: { + environment: 'Environment', + apiKeys: 'API Keys', + integrations: 'Integrations', + }, + admin: { + overview: 'Overview', + billing: 'Billing', + services: 'Services', + integrations: 'Integrations', + registration: 'Registration', + }, + }, + }, + }, + null, + 2 + ) +} + +function parseLocaleMessages() { + return JSON.parse(createLocaleMessages()) as Record<string, any> +} + +function toJson(value: unknown) { + return JSON.stringify(value, null, 2) +} + +function createBaseProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/layout.tsx': ` +import { useTranslations } from 'next-intl' + +export default function MonitorLayout({ children }: { children: React.ReactNode }) { + const t = useTranslations('workspace.monitor') + + return ( + <section title="Monitor shell"> + <div>{t('layoutBadge')}</div> + {children} + </section> + ) +} +`, + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/[locale]/workspace/[workspaceId]/templates/page.tsx': + "import { TemplatesPage } from '@/app/workspace/[workspaceId]/templates/templates'\nexport default function Page(){ return <TemplatesPage /> }\n", + 'app/workspace/[workspaceId]/monitor/copy.ts': ` +import type { Messages } from 'next-intl' +import { useMessages } from 'next-intl' + +export type MonitorCopy = Messages['workspace']['monitor'] + +export function useMonitorCopy() { + return { + copy: useMessages().workspace.monitor, + } +} + +export function getStatusLabel(copy: MonitorCopy) { + return copy.fields.status +} +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' +import { getStatusLabel, useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' + +export function MonitorPage() { + const t = useTranslations('workspace.monitor') + const { copy } = useMonitorCopy() + const mode = 'running' + + return ( + <button title="Run now"> + {t('title')} - {getStatusLabel(copy)} - {copy.values[mode]} + </button> + ) +} +`, + 'app/workspace/[workspaceId]/templates/templates.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export function TemplatesPage() { + const t = useTranslations('workspace.templates') + return <div>{t('used')}</div> +} +`, + }) +} + +function createChangelogProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'i18n/public-copy.ts': ` +import type { Messages } from 'next-intl' + +export type PublicCopy = Messages +`, + 'app/[locale]/changelog/page.tsx': + "import { ChangelogPage } from '@/app/changelog/changelog-page'\nexport default function Page(){ return <ChangelogPage /> }\n", + 'app/changelog/changelog-page.tsx': ` +'use client' + +import { useMessages } from 'next-intl' +import { ChangelogContent } from '@/app/changelog/components/changelog-content' + +export function ChangelogPage() { + const copy = useMessages().changelog + + return <ChangelogContent copy={copy} locale="en" /> +} +`, + 'app/changelog/components/changelog-content.tsx': ` +import type { PublicCopy } from '@/i18n/public-copy' +import { TimelineList } from '@/app/changelog/components/timeline-list' + +type ChangelogCopy = PublicCopy['changelog'] + +interface ChangelogContentProps { + copy: ChangelogCopy + locale: string +} + +export function ChangelogContent({ copy, locale }: ChangelogContentProps) { + return ( + <section> + <h1>{copy.pageTitle}</h1> + <a>{copy.viewOnGitHub}</a> + <a>{copy.documentation}</a> + <a>{copy.rssFeed}</a> + <TimelineList copy={copy} locale={locale} /> + </section> + ) +} +`, + 'app/changelog/components/timeline-list.tsx': ` +import type { Messages } from 'next-intl' + +type Props = { + copy: Messages['changelog'] + locale: string +} + +export function TimelineList({ copy }: Props) { + const contributors = ['ada'] + + return ( + <section> + {contributors.map((contributor) => ( + <button key={contributor} aria-label={copy.viewContributorAriaLabel}> + <img alt={copy.contributorAvatarAlt} /> + </button> + ))} + <div>{copy.loadingMore}</div> + <button>{copy.showMore}</button> + </section> + ) +} +`, + }) +} + +function createOptionalChainMonitorProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import type { Messages } from 'next-intl' + +type MonitorErrorsCopy = Messages['workspace']['monitor']['errors'] + +export function MonitorPage({ copy }: { copy?: MonitorErrorsCopy }) { + return ( + <div> + <span>{copy?.loadViews}</span> + <span>{copy?.createDefaultView}</span> + <span>{copy?.invalidViewResponse}</span> + </div> + ) +} +`, + }) +} + +function createVerifyPromiseProject() { + const messages = parseLocaleMessages() + messages.auth = { + verify: { + pendingTitle: 'Verify your email', + errors: { + attempts: 'Too many attempts', + expired: 'Verification code expired', + generic: 'Verification failed', + invalid: 'Verification code invalid', + resendFailed: 'Unable to resend code', + }, + }, + } + const localeMessages = toJson(messages) + + return createTempProject({ + 'i18n/messages/en.json': localeMessages, + 'i18n/messages/es.json': localeMessages, + 'i18n/messages/zh.json': localeMessages, + 'app/[locale]/(auth)/verify/page.tsx': + "import { VerifyContent } from '@/app/(auth)/verify/verify-content'\nexport default function Page(){ return <VerifyContent /> }\n", + 'app/(auth)/verify/error-copy.ts': ` +import type { Messages } from 'next-intl' + +type VerifyCopy = Messages['auth']['verify'] + +export function getVerificationErrorMessage(copy: VerifyCopy) { + return [ + copy.errors.expired, + copy.errors.invalid, + copy.errors.attempts, + copy.errors.generic, + ].join(' ') +} +`, + 'app/(auth)/verify/verify-content.tsx': ` +'use client' + +import { useEffect } from 'react' +import { useMessages } from 'next-intl' +import { getVerificationErrorMessage } from '@/app/(auth)/verify/error-copy' + +export function VerifyContent() { + const copy = useMessages().auth.verify + + useEffect(() => { + void Promise.reject(new Error('x')).then(undefined, () => copy.errors.expired) + void Promise.reject(new Error('x')).catch(() => getVerificationErrorMessage(copy)) + }, [copy]) + + function handleResend() { + void Promise.reject(new Error('x')).catch(() => copy.errors.resendFailed) + } + + return <button onClick={handleResend}>{copy.pendingTitle}</button> +} +`, + }) +} + +function createTimerCallbackMonitorProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/copy.ts': ` +import type { Messages } from 'next-intl' +import { useMessages } from 'next-intl' + +export type MonitorCopy = Messages['workspace']['monitor'] + +export function useMonitorCopy() { + return { + copy: useMessages().workspace.monitor, + } +} +`, + 'app/workspace/[workspaceId]/monitor/scheduler.ts': ` +import type { MonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' + +export function scheduleMonitorBootstrap(copy: MonitorCopy) { + setTimeout(() => copy.errors.loadViews, 0) + setInterval(() => copy.errors.createDefaultView, 1000) + queueMicrotask(() => copy.errors.invalidViewResponse) +} +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { useEffect } from 'react' +import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' +import { scheduleMonitorBootstrap } from '@/app/workspace/[workspaceId]/monitor/scheduler' + +export function MonitorPage() { + const { copy } = useMonitorCopy() + + useEffect(() => { + scheduleMonitorBootstrap(copy) + }, [copy]) + + return <div>{copy.used}</div> +} +`, + }) +} + +function createStartTransitionMonitorProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/copy.ts': ` +import type { Messages } from 'next-intl' +import { useMessages } from 'next-intl' + +export type MonitorCopy = Messages['workspace']['monitor'] + +export function useMonitorCopy() { + return { + copy: useMessages().workspace.monitor, + } +} +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { startTransition, useEffect } from 'react' +import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' + +export function MonitorPage() { + const { copy } = useMonitorCopy() + + useEffect(() => { + startTransition(() => { + copy.errors.loadViews + copy.errors.createDefaultView + }) + }, [copy]) + + return <div>{copy.used}</div> +} +`, + }) +} + +function createRenderPropMonitorProject() { + const messages = parseLocaleMessages() + messages.workspace.monitor.timezone = { + empty: 'No timezones found', + label: 'Timezone', + loading: 'Loading timezones', + placeholder: 'Select timezone', + searchPlaceholder: 'Search timezones', + triggerLabel: 'Timezone {label}', + } + const localeMessages = toJson(messages) + + return createTempProject({ + 'i18n/messages/en.json': localeMessages, + 'i18n/messages/es.json': localeMessages, + 'i18n/messages/zh.json': localeMessages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/copy.ts': ` +import { useMessages } from 'next-intl' + +export function useMonitorCopy() { + return { + copy: useMessages().workspace.monitor, + } +} +`, + 'app/workspace/[workspaceId]/monitor/dropdown.tsx': ` +type DropdownOption = { + label: string + value: string +} + +type DropdownProps = { + onOpenChange?: (open: boolean) => unknown + onValueChange: (value: string) => unknown + renderOption?: (option: DropdownOption, selected: boolean) => React.ReactNode + renderTriggerValue?: (selected: DropdownOption | null) => React.ReactNode +} + +const option = { value: 'UTC', label: 'UTC' } + +export function Dropdown(props: DropdownProps) { + const openResult = props.onOpenChange?.(true) + const valueResult = props.onValueChange(option.value) + + return ( + <div> + <div>{props.renderTriggerValue ? props.renderTriggerValue(option) : null}</div> + <div>{props.renderOption ? props.renderOption(option, true) : null}</div> + <div>{openResult ?? null}</div> + <div>{valueResult ?? null}</div> + </div> + ) +} +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' +import { Dropdown } from '@/app/workspace/[workspaceId]/monitor/dropdown' + +export function MonitorPage() { + const { copy } = useMonitorCopy() + + return ( + <Dropdown + onOpenChange={(open) => (open ? copy.timezone.empty : copy.timezone.loading)} + onValueChange={() => copy.timezone.placeholder} + renderOption={(option) => <span>{copy.timezone.loading} {option.label}</span>} + renderTriggerValue={() => <span>{copy.timezone.label}</span>} + /> + ) +} +`, + }) +} + +function createImportedRenderPropMonitorProject() { + const messages = parseLocaleMessages() + messages.workspace.monitor.timezone = { + empty: 'No timezones found', + label: 'Timezone', + loading: 'Loading timezones', + } + const localeMessages = toJson(messages) + + return createTempProject({ + 'i18n/messages/en.json': localeMessages, + 'i18n/messages/es.json': localeMessages, + 'i18n/messages/zh.json': localeMessages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/copy.ts': ` +import type { Messages } from 'next-intl' +import { useMessages } from 'next-intl' + +export type MonitorCopy = Messages['workspace']['monitor'] + +export function useMonitorCopy() { + return { + copy: useMessages().workspace.monitor, + } +} +`, + 'app/workspace/[workspaceId]/monitor/renderers.tsx': ` +import type { MonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' + +export function renderTriggerValue(copy: MonitorCopy) { + return <span>{copy.timezone.label}</span> +} + +export function handleOpenChange(copy: MonitorCopy, open: boolean) { + return open ? copy.timezone.empty : copy.timezone.loading +} +`, + 'app/workspace/[workspaceId]/monitor/dropdown.tsx': ` +import type { MonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' + +type DropdownProps = { + copy: MonitorCopy + onOpenChange?: (copy: MonitorCopy, open: boolean) => unknown + renderTriggerValue?: (copy: MonitorCopy) => React.ReactNode +} + +export function Dropdown({ copy, onOpenChange, renderTriggerValue }: DropdownProps) { + const openResult = onOpenChange?.(copy, true) + + return ( + <div> + <div>{renderTriggerValue ? renderTriggerValue(copy) : null}</div> + <div>{openResult ?? null}</div> + </div> + ) +} +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' +import { Dropdown } from '@/app/workspace/[workspaceId]/monitor/dropdown' +import { + handleOpenChange, + renderTriggerValue, +} from '@/app/workspace/[workspaceId]/monitor/renderers' + +export function MonitorPage() { + const { copy } = useMonitorCopy() + const dropdownProps = { + copy, + onOpenChange: handleOpenChange, + renderTriggerValue, + } + + return <Dropdown {...dropdownProps} /> +} +`, + }) +} + +function createUnusedRenderPropMonitorProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/copy.ts': ` +import { useMessages } from 'next-intl' + +export function useMonitorCopy() { + return { + copy: useMessages().workspace.monitor, + } +} +`, + 'app/workspace/[workspaceId]/monitor/dropdown.tsx': ` +type DropdownProps = { + renderTriggerValue?: () => React.ReactNode +} + +export function Dropdown({ renderTriggerValue }: DropdownProps) { + return <div data-render={typeof renderTriggerValue} /> +} +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' +import { Dropdown } from '@/app/workspace/[workspaceId]/monitor/dropdown' + +export function MonitorPage() { + const { copy } = useMonitorCopy() + + return <Dropdown renderTriggerValue={() => <span>{copy.orphan}</span>} /> +} +`, + }) +} + +function createUnusedImportedRenderPropMonitorProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/copy.ts': ` +import type { Messages } from 'next-intl' + +export type MonitorCopy = Messages['workspace']['monitor'] +`, + 'app/workspace/[workspaceId]/monitor/renderers.tsx': ` +import type { MonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' + +export function renderTriggerValue(copy: MonitorCopy) { + return <span>{copy.orphan}</span> +} +`, + 'app/workspace/[workspaceId]/monitor/dropdown.tsx': ` +type DropdownProps = { + renderTriggerValue?: () => React.ReactNode +} + +export function Dropdown({ renderTriggerValue }: DropdownProps) { + return <div data-render={typeof renderTriggerValue} /> +} +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { Dropdown } from '@/app/workspace/[workspaceId]/monitor/dropdown' +import { renderTriggerValue } from '@/app/workspace/[workspaceId]/monitor/renderers' + +export function MonitorPage() { + const dropdownProps = { renderTriggerValue } + + return <Dropdown {...dropdownProps} /> +} +`, + }) +} + +function createCopyPassThroughMonitorProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/copy.ts': ` +import type { Messages } from 'next-intl' +import { useMessages } from 'next-intl' + +export type MonitorCopy = Messages['workspace']['monitor'] + +export function useMonitorCopy() { + return { + copy: useMessages().workspace.monitor, + } +} +`, + 'app/workspace/[workspaceId]/monitor/error-callout.tsx': ` +type MonitorErrorCopy = { + createDefaultView: string + invalidViewResponse: string + loadViews: string +} + +export function ErrorCallout({ copy }: { copy: MonitorErrorCopy }) { + return ( + <div> + <span>{copy.loadViews}</span> + <span>{copy.createDefaultView}</span> + <span>{copy.invalidViewResponse}</span> + </div> + ) +} +`, + 'app/workspace/[workspaceId]/monitor/view-bootstrap.tsx': ` +import { ErrorCallout } from '@/app/workspace/[workspaceId]/monitor/error-callout' + +type MonitorErrorCopy = { + createDefaultView: string + invalidViewResponse: string + loadViews: string +} + +export function MonitorErrorBootstrap({ copy }: { copy: MonitorErrorCopy }) { + return <ErrorCallout copy={copy} /> +} +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' +import { MonitorErrorBootstrap } from '@/app/workspace/[workspaceId]/monitor/view-bootstrap' + +export function MonitorPage() { + const { copy } = useMonitorCopy() + + return <MonitorErrorBootstrap copy={copy.errors} /> +} +`, + }) +} + +function createUseCallbackMonitorProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/copy.ts': ` +import type { Messages } from 'next-intl' +import { useMessages } from 'next-intl' + +export type MonitorCopy = Messages['workspace']['monitor'] + +export function useMonitorCopy() { + return { + copy: useMessages().workspace.monitor, + } +} +`, + 'app/workspace/[workspaceId]/monitor/view-bootstrap.ts': ` +type MonitorErrorCopy = { + createDefaultView: string + invalidViewResponse: string + loadViews: string +} + +export function bootstrapMonitorViews(copy: MonitorErrorCopy) { + return [copy.loadViews, copy.createDefaultView, copy.invalidViewResponse].join(' ') +} +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { useCallback, useEffect } from 'react' +import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' +import { bootstrapMonitorViews } from '@/app/workspace/[workspaceId]/monitor/view-bootstrap' + +export function MonitorPage() { + const { copy } = useMonitorCopy() + const reloadViewState = useCallback(async () => bootstrapMonitorViews(copy.errors), [copy]) + + useEffect(() => { + void reloadViewState() + }, [reloadViewState]) + + return <div>{copy.used}</div> +} +`, + }) +} + +function createUnusedUseCallbackMonitorProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/copy.ts': ` +import type { Messages } from 'next-intl' +import { useMessages } from 'next-intl' + +export type MonitorCopy = Messages['workspace']['monitor'] + +export function useMonitorCopy() { + return { + copy: useMessages().workspace.monitor, + } +} +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { useCallback } from 'react' +import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' + +export function MonitorPage() { + const { copy } = useMonitorCopy() + const unusedHandler = useCallback(() => copy.orphan, [copy]) + + return <div data-unused={typeof unusedHandler}>{copy.used}</div> +} +`, + }) +} + +function createReturnTypeMonitorProject() { + const messages = parseLocaleMessages() + messages.workspace.monitor.configSearch = { + activeMonitors: 'Active monitors', + pausedMonitors: 'Paused monitors', + lastOutcome: 'Last outcome', + hasLastExecution: 'Has last execution', + noLastExecution: 'No last execution', + hasLastOutcome: 'Has last outcome', + noLastOutcome: 'No last outcome', + hasLastExecutionLog: 'Has last execution log', + noLastExecutionLog: 'No last execution log', + } + + const localeMessages = toJson(messages) + + return createTempProject({ + 'i18n/messages/en.json': localeMessages, + 'i18n/messages/es.json': localeMessages, + 'i18n/messages/zh.json': localeMessages, + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/copy.ts': ` +import { useMessages } from 'next-intl' + +export function useMonitorCopy() { + return { + copy: useMessages().workspace.monitor, + } +} +`, + 'app/workspace/[workspaceId]/monitor/config-search.ts': ` +import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' + +export function buildConfigSearchSuggestionSet(copy: ReturnType<typeof useMonitorCopy>['copy']) { + return [ + copy.configSearch.activeMonitors, + copy.configSearch.pausedMonitors, + copy.configSearch.lastOutcome, + copy.configSearch.hasLastExecution, + copy.configSearch.noLastExecution, + copy.configSearch.hasLastOutcome, + copy.configSearch.noLastOutcome, + copy.configSearch.hasLastExecutionLog, + copy.configSearch.noLastExecutionLog, + ].join(' ') +} +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { useMemo } from 'react' +import { buildConfigSearchSuggestionSet } from '@/app/workspace/[workspaceId]/monitor/config-search' +import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' + +export function MonitorPage() { + const { copy } = useMonitorCopy() + const suggestions = useMemo(() => buildConfigSearchSuggestionSet(copy), [copy]) + + return <div>{suggestions}</div> +} +`, + }) +} + +function createNotFoundAllModeProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/not-found.tsx': ` +import NotFoundContent from '@/app/not-found-content' + +export default function NotFound() { + return <NotFoundContent /> +} +`, + 'app/not-found-content.tsx': ` +'use client' + +import { useMessages } from 'next-intl' + +export default function NotFoundContent() { + const copy = useMessages().notFound + + return ( + <section> + <h1>{copy.title}</h1> + <p>{copy.description}</p> + <button>{copy.returnHome}</button> + <span>{copy.supportPrefix}</span> + <a>{copy.supportLinkLabel}</a> + </section> + ) +} +`, + }) +} + +function createLoadingAllModeProject() { + const messages = parseLocaleMessages() + messages.workspace.loading = { + title: 'Loading title', + description: 'Loading description', + orphan: 'Loading orphan', + } + + const localeMessages = toJson(messages) + + return createTempProject({ + 'i18n/messages/en.json': localeMessages, + 'i18n/messages/es.json': localeMessages, + 'i18n/messages/zh.json': localeMessages, + 'app/[locale]/workspace/[workspaceId]/page.tsx': + 'export default function Page(){ return <div /> }\n', + 'app/workspace/[workspaceId]/loading.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export default function Loading() { + const t = useTranslations('workspace.loading') + + return ( + <section> + <span>{t('title')}</span> + <span>{t('description')}</span> + </section> + ) +} +`, + }) +} + +function createConcreteRouteResolutionProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/(landing)/blog/page.tsx': + 'export default function BlogIndexPage() { return <div /> }\n', + 'app/[locale]/(landing)/blog/[slug]/page.tsx': + 'export default function BlogSlugPage() { return <div /> }\n', + 'app/[locale]/(auth)/error/[[...callback]]/page.tsx': + 'export default function ErrorPage() { return <div /> }\n', + 'app/[locale]/[...notFound]/page.tsx': + 'export default function NotFoundPage() { return <div /> }\n', + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + 'export default function MonitorPage() { return <div /> }\n', + }) +} + +function createRouteBoundaryProject() { + const enMessages = parseLocaleMessages() + const esMessages = parseLocaleMessages() + const zhMessages = parseLocaleMessages() + + for (const localeMessages of [enMessages, esMessages, zhMessages]) { + localeMessages.workspace.monitor.boundary = { + errorTitle: 'Route error', + globalErrorTitle: 'Route global error', + } + } + + return createTempProject({ + 'i18n/messages/en.json': toJson(enMessages), + 'i18n/messages/es.json': toJson(esMessages), + 'i18n/messages/zh.json': toJson(zhMessages), + 'app/[locale]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + 'export default function MonitorPage() { return <div /> }\n', + 'app/[locale]/not-found.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export default function NotFound() { + const t = useTranslations('notFound') + + return ( + <section> + <h1>{t('title')}</h1> + <p>{t('description')}</p> + </section> + ) +} +`, + 'app/workspace/[workspaceId]/error.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export default function WorkspaceError() { + const t = useTranslations('workspace.monitor.boundary') + return <div>{t('errorTitle')}</div> +} +`, + 'app/workspace/[workspaceId]/monitor/global-error.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export default function MonitorGlobalError() { + const t = useTranslations('workspace.monitor.boundary') + return <div>{t('globalErrorTitle')}</div> +} +`, + }) +} + +function createAllModeOwnershipProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +import { MonitorRoutePanel } from '@/app/workspace/[workspaceId]/monitor/monitor-route-panel' +import { SharedLabel } from '@/components/shared-label' + +export function MonitorPage() { + return ( + <section> + <MonitorRoutePanel /> + <SharedLabel /> + </section> + ) +} +`, + 'app/workspace/[workspaceId]/monitor/monitor-route-panel.tsx': ` +export function MonitorRoutePanel() { + return <button title="Run now" /> +} +`, + 'components/shared-label.tsx': ` +export function SharedLabel() { + return <span>Shared label</span> +} +`, + }) +} + +function createSharedAdminRouteProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/admin/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/admin/integrations/page.tsx': + "import { IntegrationsAdmin } from '@/app/admin/integrations/integrations-admin'\nexport default function Page(){ return <IntegrationsAdmin /> }\n", + 'app/[locale]/admin/services/page.tsx': + "import { ServicesAdmin } from '@/app/admin/services/services-admin'\nexport default function Page(){ return <ServicesAdmin /> }\n", + 'app/admin/admin-inline-secret-field.tsx': ` +export function AdminInlineSecretField() { + return ( + <div> + <button>Save</button> + <button>Edit</button> + </div> + ) +} +`, + 'app/admin/integrations/integrations-admin.tsx': ` +import { AdminInlineSecretField } from '@/app/admin/admin-inline-secret-field' + +export function IntegrationsAdmin() { + return <AdminInlineSecretField /> +} +`, + 'app/admin/services/services-admin.tsx': ` +import { AdminInlineSecretField } from '@/app/admin/admin-inline-secret-field' + +export function ServicesAdmin() { + return <AdminInlineSecretField /> +} +`, + }) +} + +function createAllModeAliasNamespaceProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/admin/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/admin/billing/page.tsx': + "import { BillingNotice } from '@/app/admin/billing/billing-notice'\nexport default function Page(){ return <BillingNotice /> }\n", + 'app/[locale]/admin/billing/[tierId]/page.tsx': + "import { BillingNotice } from '@/app/admin/billing/billing-notice'\nexport default function Page(){ return <BillingNotice /> }\n", + 'app/admin/billing/billing-notice.tsx': ` +export function BillingNotice() { + return <button title="Refresh catalog" /> +} +`, + }) +} + +function createAppRootGlobalBoundaryProject() { + const enMessages = parseLocaleMessages() + const esMessages = parseLocaleMessages() + const zhMessages = parseLocaleMessages() + + for (const localeMessages of [enMessages, esMessages, zhMessages]) { + localeMessages.workspace.monitor.boundary = { + rootGlobalErrorTitle: 'Root global error', + } + } + + return createTempProject({ + 'i18n/messages/en.json': toJson(enMessages), + 'i18n/messages/es.json': toJson(esMessages), + 'i18n/messages/zh.json': toJson(zhMessages), + 'app/[locale]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/global-error.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export default function GlobalError() { + const t = useTranslations('workspace.monitor.boundary') + return <div>{t('rootGlobalErrorTitle')}</div> +} +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +import { SharedLabel } from '@/components/shared-label' + +export function MonitorPage() { + return <SharedLabel /> +} +`, + 'components/shared-label.tsx': ` +export function SharedLabel() { + return <span>Shared label</span> +} +`, + }) +} + +function createDynamicLocaleGapProject() { + const enMessages = parseLocaleMessages() + const esMessages = parseLocaleMessages() + const zhMessages = parseLocaleMessages() + + delete esMessages.workspace.monitor.values.paused + delete zhMessages.workspace.monitor.values.paused + + return createTempProject({ + 'i18n/messages/en.json': toJson(enMessages), + 'i18n/messages/es.json': toJson(esMessages), + 'i18n/messages/zh.json': toJson(zhMessages), + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { useMessages } from 'next-intl' + +export function MonitorPage() { + const copy = useMessages().workspace.monitor + const status = 'paused' + + return <div>{copy.values[status]}</div> +} +`, + }) +} + +function createLandingArrayProject() { + const enMessages = parseLocaleMessages() + const esMessages = parseLocaleMessages() + const zhMessages = parseLocaleMessages() + + for (const localeMessages of [enMessages, esMessages, zhMessages]) { + localeMessages.landing = { + hero: { + leadWords: ['Build', 'Test'], + highlightWords: ['Trading Analysis', 'Signal Detection'], + featureBadges: ['A', 'B', 'C'], + }, + features: { + rows: [ + { + title: 'One', + bullets: ['B1', 'B2'], + }, + ], + }, + howItWorks: { + processes: [ + { + title: 'Collect', + description: 'Collect inputs', + }, + ], + }, + } + } + + esMessages.landing.hero.leadWords = ['Build', null] + esMessages.landing.hero.featureBadges = ['A', null, 'C'] + + return createTempProject({ + 'i18n/messages/en.json': toJson(enMessages), + 'i18n/messages/es.json': toJson(esMessages), + 'i18n/messages/zh.json': toJson(zhMessages), + 'app/[locale]/page.tsx': + "import { LandingPage } from '@/app/landing/landing-page'\nexport default function Page(){ return <LandingPage /> }\n", + 'app/landing/landing-page.tsx': ` +'use client' + +import { useMessages } from 'next-intl' + +export function LandingPage() { + const copy = useMessages() + const words = copy.landing.hero.leadWords + + return ( + <section> + <span>{copy.landing.hero.featureBadges[0]}</span> + <div>{words.join(' ')}</div> + <div>{copy.landing.features.rows.map((row) => row.title).join(' ')}</div> + <div>{copy.landing.howItWorks.processes.map((process) => process.title).join(' ')}</div> + </section> + ) +} + `, + }) +} + +function createArrayBuiltinProject() { + const messages = parseLocaleMessages() + messages.workspace.monitor.nextSteps = ['One', 'Two'] + + const localeMessages = toJson(messages) + + return createTempProject({ + 'i18n/messages/en.json': localeMessages, + 'i18n/messages/es.json': localeMessages, + 'i18n/messages/zh.json': localeMessages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/details-list.tsx': ` +type DetailsListProps = { + details: string[] +} + +export function DetailsList({ details }: DetailsListProps) { + return details.length > 0 ? ( + <section> + {details.map((detail) => ( + <span key={detail}>{detail}</span> + ))} + </section> + ) : null +} +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { useMessages } from 'next-intl' +import { DetailsList } from '@/app/workspace/[workspaceId]/monitor/details-list' + +export function MonitorPage() { + const copy = useMessages().workspace.monitor + + return <DetailsList details={copy.nextSteps} /> +} +`, + }) +} + +function createInlineFormatterProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' +import { formatTemplate } from '@/i18n/utils' + +export function MonitorPage() { + const t = useTranslations('workspace.monitor') + + return <div>{t('used')} {formatTemplate('Hello {name}', { name: 'Ada' })}</div> +} +`, + 'i18n/utils.ts': ` +import { createTranslator } from 'next-intl' + +export function formatTemplate(template: string, values: Record<string, string>) { + let formatError: unknown + const translator = createTranslator({ + locale: 'en', + messages: { value: template }, + onError(error) { + formatError = error + }, + }) + const formatted = translator('value', values) + + if (formatError) { + throw formatError + } + + return formatted +} +`, + }) +} + +function createUnusedHelperMonitorProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { useMessages, useTranslations } from 'next-intl' + +function UnusedMonitorCopy() { + const copy = useMessages().workspace.monitor + + return <div>{copy.orphan}</div> +} + +export function MonitorPage() { + const t = useTranslations('workspace.monitor') + + return <div>{t('used')}</div> +} +`, + }) +} + +function createUnusedExportedHelperMonitorProject() { + const messages = createLocaleMessages() + + return createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export function MonitorPage() { + const t = useTranslations('workspace.monitor') + + return <div>{t('used')}</div> +} +`, + 'app/workspace/[workspaceId]/monitor/unused-helper.tsx': ` +'use client' + +import { useMessages } from 'next-intl' + +export function UnusedExportedHelper() { + const copy = useMessages().workspace.monitor + + return <div>{copy.orphan}</div> +} +`, + }) +} + +function buildRouteReport( + projectRoot: string, + routePath: string, + options?: { withOrphans?: boolean } +) { + const scanResult = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath, + }) + const globalScanResult = options?.withOrphans + ? scanCatalogProject({ mode: 'all', projectRoot }) + : undefined + + return { + scanResult, + globalScanResult, + report: buildCatalogReport({ + projectRoot, + scanResult, + globalScanResult, + }), + } +} + +function buildAllReport(projectRoot: string) { + const scanResult = scanCatalogProject({ mode: 'all', projectRoot }) + + return { + scanResult, + report: buildCatalogReport({ + projectRoot, + scanResult, + globalScanResult: scanResult, + }), + } +} + +function getCoveragePathKeys( + scanResult: CatalogScanResult, + options?: { + mode?: CoverageRecord['mode'] + subtreeReason?: CoverageRecord['subtreeReason'] + } +) { + return [ + ...new Set( + scanResult.coverage + .filter((coverage) => (options?.mode ? coverage.mode === options.mode : true)) + .filter((coverage) => + options?.subtreeReason ? coverage.subtreeReason === options.subtreeReason : true + ) + .map((coverage) => coverage.pathKey) + ), + ] +} + +function snapshotFiles(projectRoot: string, relativePaths: string[]) { + return Object.fromEntries( + relativePaths.map((relativePath) => [ + relativePath, + fs.readFileSync(path.join(projectRoot, relativePath), 'utf8'), + ]) + ) +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +describe('i18n catalog entry discovery', () => { + it('discovers real framework entry basenames in all mode', () => { + const projectRoot = createTempProject({ + 'i18n/messages/en.json': createLocaleMessages(), + 'i18n/messages/es.json': createLocaleMessages(), + 'i18n/messages/zh.json': createLocaleMessages(), + 'app/[locale]/layout.tsx': 'export default function Layout() { return null }\n', + 'app/[locale]/page.tsx': 'export default function Page() { return null }\n', + 'app/[locale]/not-found.tsx': 'export default function NotFound() { return null }\n', + 'app/workspace/[workspaceId]/loading.tsx': + 'export default function Loading() { return null }\n', + 'app/workspace/[workspaceId]/default.tsx': + 'export default function Default() { return null }\n', + }) + + const entries = discoverAllModeEntries(projectRoot) + const relativeEntryFiles = entries.entryFiles.map((filePath) => + path.relative(projectRoot, filePath) + ) + + expect(relativeEntryFiles).toEqual( + expect.arrayContaining([ + 'app/[locale]/layout.tsx', + 'app/[locale]/page.tsx', + 'app/[locale]/not-found.tsx', + 'app/workspace/[workspaceId]/loading.tsx', + ]) + ) + expect(relativeEntryFiles).not.toContain('app/workspace/[workspaceId]/default.tsx') + }) + + it('resolves concrete dynamic and catch-all pathnames to canonical route patterns', () => { + const projectRoot = createConcreteRouteResolutionProject() + + expect(resolveRouteEntries(projectRoot, '/blog/hello-world').routePath).toBe('/blog/[slug]') + expect(resolveRouteEntries(projectRoot, '/workspace/ws-1/monitor').routePath).toBe( + '/workspace/[workspaceId]/monitor' + ) + expect(resolveRouteEntries(projectRoot, '/error').routePath).toBe('/error/[[...callback]]') + expect(resolveRouteEntries(projectRoot, '/missing/deep').routePath).toBe('/[...notFound]') + expect(resolveRouteEntries(projectRoot, '/workspace/[workspaceId]/monitor').routePath).toBe( + '/workspace/[workspaceId]/monitor' + ) + }) + + it('includes localized and route-owned boundary entries for route scans', () => { + const projectRoot = createRouteBoundaryProject() + + const entries = resolveRouteEntries(projectRoot, '/workspace/ws-1/monitor') + const relativeEntryFiles = entries.entryFiles.map((filePath) => + path.relative(projectRoot, filePath) + ) + + expect(relativeEntryFiles).toEqual( + expect.arrayContaining([ + 'app/[locale]/layout.tsx', + 'app/[locale]/workspace/[workspaceId]/layout.tsx', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx', + 'app/[locale]/not-found.tsx', + 'app/workspace/[workspaceId]/error.tsx', + 'app/workspace/[workspaceId]/monitor/global-error.tsx', + ]) + ) + }) + + it('includes app-root global boundaries for route scans', () => { + const projectRoot = createAppRootGlobalBoundaryProject() + + const entries = resolveRouteEntries(projectRoot, '/workspace/ws-1/monitor') + const relativeEntryFiles = entries.entryFiles.map((filePath) => + path.relative(projectRoot, filePath) + ) + + expect(relativeEntryFiles).toContain('app/global-error.tsx') + }) +}) + +describe('i18n catalog scanner', () => { + it('resolves localized wrappers and extracts translations, aliases, and dynamic protection', () => { + const projectRoot = createBaseProject() + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/workspace/[workspaceId]/monitor', + }) + + expect(result.scannedFiles).toContain('app/[locale]/workspace/[workspaceId]/monitor/layout.tsx') + expect(result.scannedFiles).toContain('app/[locale]/workspace/[workspaceId]/monitor/page.tsx') + expect(result.scannedFiles).toContain('app/workspace/[workspaceId]/monitor/monitor.tsx') + expect(getCoveragePathKeys(result)).toEqual( + expect.arrayContaining([ + 'workspace.monitor.title', + 'workspace.monitor.fields.status', + 'workspace.monitor.layoutBadge', + ]) + ) + expect( + getCoveragePathKeys(result, { mode: 'subtree', subtreeReason: 'dynamic-root' }) + ).toContain('workspace.monitor.values') + expect(result.hardcodedCandidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: 'Run now', + namespace: 'workspace.monitor', + }), + expect.objectContaining({ + text: 'Monitor shell', + namespace: 'workspace.monitor', + }), + ]) + ) + }) + + it('scans the root locale layout when it owns route copy', () => { + const messages = JSON.stringify( + { + workspace: { + monitor: { + rootLayoutBadge: 'Root layout badge', + }, + }, + }, + null, + 2 + ) + const projectRoot = createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/layout.tsx': ` +import { useTranslations } from 'next-intl' + +export default function RootLayout({ children }: { children: React.ReactNode }) { + const t = useTranslations('workspace.monitor') + + return ( + <section title="Shell badge"> + <div>{t('rootLayoutBadge')}</div> + {children} + </section> + ) +} +`, + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/monitor.tsx': + 'export function MonitorPage() { return <div /> }\n', + }) + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/workspace/[workspaceId]/monitor', + }) + + expect(result.scannedFiles).toContain('app/[locale]/layout.tsx') + expect(getCoveragePathKeys(result)).toContain('workspace.monitor.rootLayoutBadge') + expect(result.hardcodedCandidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: 'Shell badge', + namespace: 'workspace.monitor', + }), + ]) + ) + }) + + it('does not assign shared ui or email literals to the active route fallback namespace', () => { + const messages = createLocaleMessages() + const projectRoot = createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': ` +import { getSession } from '@/lib/auth' +import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor' + +export default async function Page() { + await getSession() + return <MonitorPage /> +} +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { DialogContent } from '@/components/ui/dialog' + +export function MonitorPage() { + return ( + <DialogContent> + <button title="Run now" /> + </DialogContent> + ) +} +`, + 'components/ui/dialog.tsx': ` +export function DialogContent({ children }: { children: React.ReactNode }) { + return ( + <div> + {children} + <span className='sr-only'>Close</span> + </div> + ) +} +`, + 'lib/auth.ts': ` +import { renderSomething } from '@/components/emails/render-email' + +export async function getSession() { + return renderSomething ? { user: { id: '1' } } : null +} +`, + 'components/emails/render-email.ts': ` +import { LocalizedEmail } from '@/components/emails/localized-email' + +export const renderSomething = LocalizedEmail +`, + 'components/emails/localized-email.tsx': ` +import EmailFooter from '@/components/emails/footer' + +export function LocalizedEmail() { + return ( + <div> + <EmailFooter /> + </div> + ) +} +`, + 'components/emails/footer.tsx': ` +export default function EmailFooter() { + return <a>Discord</a> +} +`, + }) + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/workspace/[workspaceId]/monitor', + }) + + expect(result.scannedFiles).toEqual( + expect.arrayContaining(['components/ui/dialog.tsx', 'components/emails/footer.tsx']) + ) + expect(result.hardcodedCandidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: 'Run now', + namespace: 'workspace.monitor', + }), + ]) + ) + expect(result.hardcodedCandidates).not.toEqual( + expect.arrayContaining([expect.objectContaining({ text: 'Close' })]) + ) + expect(result.hardcodedCandidates).not.toEqual( + expect.arrayContaining([expect.objectContaining({ text: 'Discord' })]) + ) + }) + + it('scans app-root global boundaries without widening route fallback to shared files', () => { + const projectRoot = createAppRootGlobalBoundaryProject() + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/workspace/[workspaceId]/monitor', + }) + + expect(result.scannedFiles).toEqual( + expect.arrayContaining(['app/global-error.tsx', 'components/shared-label.tsx']) + ) + expect(getCoveragePathKeys(result)).toContain('workspace.monitor.boundary.rootGlobalErrorTitle') + expect(result.hardcodedCandidates).not.toEqual( + expect.arrayContaining([expect.objectContaining({ text: 'Shared label' })]) + ) + }) + + it('suppresses all-mode hardcoded candidates for shared non-app files without static namespaces', () => { + const projectRoot = createAllModeOwnershipProject() + + const result = scanCatalogProject({ + mode: 'all', + projectRoot, + }) + + expect(result.scannedFiles).toEqual( + expect.arrayContaining([ + 'app/workspace/[workspaceId]/monitor/monitor-route-panel.tsx', + 'components/shared-label.tsx', + ]) + ) + expect(result.hardcodedCandidates).not.toEqual( + expect.arrayContaining([expect.objectContaining({ text: 'Shared label' })]) + ) + }) + + it('derives all-mode fallback namespaces from concrete app routes', () => { + const projectRoot = createAllModeOwnershipProject() + + const result = scanCatalogProject({ + mode: 'all', + projectRoot, + }) + + expect(result.hardcodedCandidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: 'Run now', + namespace: 'workspace.monitor', + }), + ]) + ) + }) + + it('attributes shared app admin hardcoded copy to the active route in route mode', () => { + const projectRoot = createSharedAdminRouteProject() + + const integrationsResult = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/admin/integrations', + }) + const servicesResult = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/admin/services', + }) + + expect(integrationsResult.hardcodedCandidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + filePath: expect.stringMatching(/\/app\/admin\/admin-inline-secret-field\.tsx$/), + text: 'Save', + namespace: 'admin.integrations', + }), + expect.objectContaining({ + filePath: expect.stringMatching(/\/app\/admin\/admin-inline-secret-field\.tsx$/), + text: 'Edit', + namespace: 'admin.integrations', + }), + ]) + ) + expect(servicesResult.hardcodedCandidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + filePath: expect.stringMatching(/\/app\/admin\/admin-inline-secret-field\.tsx$/), + text: 'Save', + namespace: 'admin.services', + }), + expect.objectContaining({ + filePath: expect.stringMatching(/\/app\/admin\/admin-inline-secret-field\.tsx$/), + text: 'Edit', + namespace: 'admin.services', + }), + ]) + ) + expect(integrationsResult.hardcodedCandidates).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + filePath: expect.stringMatching(/\/app\/admin\/admin-inline-secret-field\.tsx$/), + namespace: 'admin.home', + }), + ]) + ) + expect(servicesResult.hardcodedCandidates).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + filePath: expect.stringMatching(/\/app\/admin\/admin-inline-secret-field\.tsx$/), + namespace: 'admin.home', + }), + ]) + ) + }) + + it('keeps per-route namespaces for shared app admin copy in all mode', () => { + const projectRoot = createSharedAdminRouteProject() + + const result = scanCatalogProject({ + mode: 'all', + projectRoot, + }) + + const saveNamespaces = result.hardcodedCandidates + .filter( + (entry) => + matchesProjectFilePath(entry.filePath, 'app/admin/admin-inline-secret-field.tsx') && + entry.text === 'Save' + ) + .map((entry) => entry.namespace) + .sort() + + expect(saveNamespaces).toEqual(['admin.integrations', 'admin.services']) + }) + + it('dedupes identical all-mode hardcoded candidates for alias routes sharing a namespace', () => { + const projectRoot = createAllModeAliasNamespaceProject() + + const result = scanCatalogProject({ + mode: 'all', + projectRoot, + }) + + const refreshCandidates = result.hardcodedCandidates.filter( + (entry) => + matchesProjectFilePath(entry.filePath, 'app/admin/billing/billing-notice.tsx') && + entry.text === 'Refresh catalog' + ) + + expect(refreshCandidates).toEqual([ + expect.objectContaining({ + namespace: 'admin.billing', + }), + ]) + }) + + it('does not follow type-only imports into the runtime route graph', () => { + const messages = createLocaleMessages() + const projectRoot = createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +import type { MonitorProps } from '@/app/workspace/[workspaceId]/monitor/types' + +export function MonitorPage(_props: MonitorProps) { + return <button title="Run now" /> +} +`, + 'app/workspace/[workspaceId]/monitor/types.ts': ` +import { DialogContent } from '@/components/ui/dialog' + +export type MonitorProps = { + dialog?: typeof DialogContent +} +`, + 'components/ui/dialog.tsx': ` +export function DialogContent({ children }: { children: React.ReactNode }) { + return ( + <div> + {children} + <span className='sr-only'>Close</span> + </div> + ) +} +`, + }) + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/workspace/[workspaceId]/monitor', + }) + + expect(result.scannedFiles).toContain('app/workspace/[workspaceId]/monitor/monitor.tsx') + expect(result.scannedFiles).not.toContain('app/workspace/[workspaceId]/monitor/types.ts') + expect(result.scannedFiles).not.toContain('components/ui/dialog.tsx') + expect(result.hardcodedCandidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: 'Run now', + namespace: 'workspace.monitor', + }), + ]) + ) + }) + + it('captures copy access through array callbacks on local type-literal props aliases', () => { + const projectRoot = createChangelogProject() + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/changelog', + }) + + expect(result.scannedFiles).toContain('app/changelog/components/timeline-list.tsx') + expect(getCoveragePathKeys(result)).toEqual( + expect.arrayContaining([ + 'changelog.viewContributorAriaLabel', + 'changelog.contributorAvatarAlt', + 'changelog.loadingMore', + 'changelog.showMore', + ]) + ) + }) + + it('captures copy access through interfaces wrapping imported PublicCopy aliases', () => { + const projectRoot = createChangelogProject() + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/changelog', + }) + + expect(result.scannedFiles).toContain('app/changelog/components/changelog-content.tsx') + expect(getCoveragePathKeys(result)).toEqual( + expect.arrayContaining([ + 'changelog.pageTitle', + 'changelog.viewOnGitHub', + 'changelog.documentation', + 'changelog.rssFeed', + ]) + ) + }) + + it('captures copy access through imported cross-file aliases', () => { + const messages = createLocaleMessages() + const projectRoot = createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/copy-types.ts': ` +import type { Messages } from 'next-intl' + +export type MonitorCopy = Messages['workspace']['monitor'] +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +import type { MonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy-types' + +export function getStatusLabel(copy: MonitorCopy) { + return copy.fields.status +} + +export function MonitorPage() { + return <div>{getStatusLabel({} as MonitorCopy)}</div> +} +`, + }) + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/workspace/[workspaceId]/monitor', + }) + + expect(result.scannedFiles).toContain('app/workspace/[workspaceId]/monitor/monitor.tsx') + expect(result.scannedFiles).not.toContain('app/workspace/[workspaceId]/monitor/copy-types.ts') + expect(getCoveragePathKeys(result)).toContain('workspace.monitor.fields.status') + }) + + it('captures copy access through optional-chain monitor error copy', () => { + const projectRoot = createOptionalChainMonitorProject() + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/workspace/[workspaceId]/monitor', + }) + + expect(getCoveragePathKeys(result)).toEqual( + expect.arrayContaining([ + 'workspace.monitor.errors.loadViews', + 'workspace.monitor.errors.createDefaultView', + 'workspace.monitor.errors.invalidViewResponse', + ]) + ) + }) + + it('captures copy access through promise callbacks in verify flows', () => { + const projectRoot = createVerifyPromiseProject() + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/verify', + }) + + expect(getCoveragePathKeys(result)).toEqual( + expect.arrayContaining([ + 'auth.verify.pendingTitle', + 'auth.verify.errors.expired', + 'auth.verify.errors.invalid', + 'auth.verify.errors.attempts', + 'auth.verify.errors.generic', + 'auth.verify.errors.resendFailed', + ]) + ) + }) + + it('captures copy access through timer and microtask callbacks', () => { + const projectRoot = createTimerCallbackMonitorProject() + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/workspace/[workspaceId]/monitor', + }) + + expect(getCoveragePathKeys(result)).toEqual( + expect.arrayContaining([ + 'workspace.monitor.used', + 'workspace.monitor.errors.loadViews', + 'workspace.monitor.errors.createDefaultView', + 'workspace.monitor.errors.invalidViewResponse', + ]) + ) + }) + + it('captures copy access through startTransition callbacks', () => { + const projectRoot = createStartTransitionMonitorProject() + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/workspace/[workspaceId]/monitor', + }) + + expect(getCoveragePathKeys(result)).toEqual( + expect.arrayContaining([ + 'workspace.monitor.used', + 'workspace.monitor.errors.loadViews', + 'workspace.monitor.errors.createDefaultView', + ]) + ) + }) + + it('captures copy access through invoked render props and callback props', () => { + const projectRoot = createRenderPropMonitorProject() + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/workspace/[workspaceId]/monitor', + }) + + expect(getCoveragePathKeys(result)).toEqual( + expect.arrayContaining([ + 'workspace.monitor.timezone.label', + 'workspace.monitor.timezone.loading', + 'workspace.monitor.timezone.empty', + 'workspace.monitor.timezone.placeholder', + ]) + ) + }) + + it('propagates semantics through local export clauses with the same name', () => { + const messages = createLocaleMessages() + const projectRoot = createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/copy.ts': ` +import { useMessages } from 'next-intl' + +function getMonitorCopy() { + return useMessages().workspace.monitor +} + +export { getMonitorCopy } +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { getMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' + +export function MonitorPage() { + return <div>{getMonitorCopy().fields.status}</div> +} +`, + }) + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/workspace/[workspaceId]/monitor', + }) + + expect(getCoveragePathKeys(result)).toContain('workspace.monitor.fields.status') + }) + + it('propagates semantics through renamed local export clauses', () => { + const messages = createLocaleMessages() + const projectRoot = createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/copy.ts': ` +import { useMessages } from 'next-intl' + +function getCopy() { + return useMessages().workspace.monitor +} + +export { getCopy as useMonitorCopy } +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' + +export function MonitorPage() { + return <div>{useMonitorCopy().fields.status}</div> +} +`, + }) + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/workspace/[workspaceId]/monitor', + }) + + expect(getCoveragePathKeys(result)).toContain('workspace.monitor.fields.status') + }) + + it('propagates semantics through pass-through local export clauses of imported helpers', () => { + const messages = createLocaleMessages() + const projectRoot = createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/workspace/[workspaceId]/monitor/source.ts': ` +import { useMessages } from 'next-intl' + +export function getMonitorCopy() { + return useMessages().workspace.monitor +} +`, + 'app/workspace/[workspaceId]/monitor/copy.ts': ` +import { getMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/source' + +export { getMonitorCopy as useMonitorCopy } +`, + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' + +import { useMonitorCopy } from '@/app/workspace/[workspaceId]/monitor/copy' + +export function MonitorPage() { + return <div>{useMonitorCopy().fields.status}</div> +} +`, + }) + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/workspace/[workspaceId]/monitor', + }) + + expect(result.scannedFiles).toEqual( + expect.arrayContaining([ + 'app/workspace/[workspaceId]/monitor/source.ts', + 'app/workspace/[workspaceId]/monitor/copy.ts', + ]) + ) + expect(getCoveragePathKeys(result)).toContain('workspace.monitor.fields.status') + }) + + it('skips repo-root app/api, __tests__, and __mocks__ files in all-project scans', () => { + const projectRoot = createTempProject({ + 'i18n/messages/en.json': createLocaleMessages(), + 'i18n/messages/es.json': createLocaleMessages(), + 'i18n/messages/zh.json': createLocaleMessages(), + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + 'export default function Page(){ return <div>Hello</div> }\n', + 'app/api/admin/services/route.ts': + "export function GET() { return Response.json({ label: 'Nope' }) }\n", + '__tests__/root.tsx': 'export function RootTest() { return <span>Ignored</span> }\n', + '__mocks__/next-intl.ts': "export const mocked = 'ignored'\n", + 'components/__tests__/dialog.tsx': + 'export function DialogText() { return <span>Ignored nested</span> }\n', + }) + + const result = scanCatalogProject({ + mode: 'all', + projectRoot, + }) + + expect(result.scannedFiles).toContain('app/[locale]/workspace/[workspaceId]/monitor/page.tsx') + expect(result.scannedFiles).not.toContain('app/api/admin/services/route.ts') + expect(result.scannedFiles).not.toContain('__tests__/root.tsx') + expect(result.scannedFiles).not.toContain('__mocks__/next-intl.ts') + expect(result.scannedFiles).not.toContain('components/__tests__/dialog.tsx') + }) + + it('ignores punctuation-only hardcoded candidates', () => { + const messages = createLocaleMessages() + const projectRoot = createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': ` +export default function Page() { + return ( + <div> + : + % + | + </div> + ) +} +`, + }) + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/workspace/[workspaceId]/monitor', + }) + + expect(result.hardcodedCandidates).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: ':' }), + expect.objectContaining({ text: '%' }), + expect.objectContaining({ text: '|' }), + ]) + ) + }) + + it('follows runtime re-exports through barrels during route scans', () => { + const messages = createLocaleMessages() + const projectRoot = createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': ` +import { GlobalNavbar } from '@/global-navbar' + +export default function Layout({ children }: { children: React.ReactNode }) { + return <GlobalNavbar>{children}</GlobalNavbar> +} +`, + 'app/[locale]/workspace/[workspaceId]/dashboard/page.tsx': + "import { DashboardPage } from '@/app/workspace/[workspaceId]/dashboard/dashboard'\nexport default function Page(){ return <DashboardPage /> }\n", + 'app/workspace/[workspaceId]/dashboard/dashboard.tsx': + 'export function DashboardPage() { return <div>Dashboard body</div> }\n', + 'global-navbar/index.ts': + "export { GlobalNavbar } from './global-navbar'\nexport { StrayNavbar } from './stray-navbar'\n", + 'global-navbar/global-navbar.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export function GlobalNavbar({ children }: { children: React.ReactNode }) { + const t = useTranslations('workspace.nav') + return ( + <nav aria-label={t('workspace.dashboard')}> + {children} + </nav> + ) +} +`, + 'global-navbar/stray-navbar.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export function StrayNavbar() { + const t = useTranslations('workspace.monitor') + return <aside>{t('stray')}</aside> +} +`, + }) + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/workspace/[workspaceId]/dashboard', + }) + + expect(result.scannedFiles).toEqual( + expect.arrayContaining(['global-navbar/index.ts', 'global-navbar/global-navbar.tsx']) + ) + expect(result.scannedFiles).not.toContain('global-navbar/stray-navbar.tsx') + expect(getCoveragePathKeys(result)).toContain('workspace.nav.workspace.dashboard') + expect(getCoveragePathKeys(result)).not.toContain('workspace.monitor.stray') + }) + + it('only follows requested named barrel re-exports during route scans', () => { + const messages = createLocaleMessages() + const projectRoot = createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': ` +import { UsedWidget } from '@/widgets' + +export default function Page() { + return <UsedWidget /> +} +`, + 'widgets/index.ts': + "export { UsedWidget } from './used'\nexport { StrayWidget } from './stray'\n", + 'widgets/used.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export function UsedWidget() { + const t = useTranslations('workspace.monitor') + return <div>{t('used')}</div> +} +`, + 'widgets/stray.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export function StrayWidget() { + const t = useTranslations('workspace.monitor') + return <div>{t('stray')}</div> +} +`, + }) + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/workspace/[workspaceId]/monitor', + }) + + expect(result.scannedFiles).toEqual( + expect.arrayContaining(['widgets/index.ts', 'widgets/used.tsx']) + ) + expect(result.scannedFiles).not.toContain('widgets/stray.tsx') + expect(getCoveragePathKeys(result)).toContain('workspace.monitor.used') + expect(getCoveragePathKeys(result)).not.toContain('workspace.monitor.stray') + }) + + it('ignores type-only barrel re-exports in route reachability', () => { + const messages = createLocaleMessages() + const projectRoot = createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': ` +import '@/barrel' + +export default function Page() { + return <div>Monitor</div> +} +`, + 'barrel.ts': "export type { DialogProps } from '@/components/ui/dialog'\n", + 'components/ui/dialog.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export type DialogProps = { + title: string +} + +export function Dialog() { + const t = useTranslations('workspace.monitor') + return <div>{t('stray')}</div> +} +`, + }) + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/workspace/[workspaceId]/monitor', + }) + + expect(result.scannedFiles).toContain('barrel.ts') + expect(result.scannedFiles).not.toContain('components/ui/dialog.tsx') + expect(getCoveragePathKeys(result)).not.toContain('workspace.monitor.stray') + }) + + it('only scans imported exports from chat-style multi-export barrels', () => { + const messages = createLocaleMessages() + const projectRoot = createTempProject({ + 'i18n/messages/en.json': messages, + 'i18n/messages/es.json': messages, + 'i18n/messages/zh.json': messages, + 'app/[locale]/chat/[identifier]/page.tsx': ` +import { ChatHeader, VoiceInterface } from '@/app/chat/components' + +export default function Page() { + return ( + <> + <ChatHeader /> + <VoiceInterface /> + </> + ) +} +`, + 'app/chat/components/index.ts': ` +export { default as EmailAuth } from './auth/email/email-auth' +export { ChatHeader } from './header/header' +export { ChatLoadingState } from './loading-state/loading-state' +export type { ChatMessage } from './message/message' +export { ChatMessageContainer } from './message-container/message-container' +export { VoiceInterface } from './voice-interface/voice-interface' +`, + 'app/chat/components/auth/email/email-auth.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export default function EmailAuth() { + const t = useTranslations('workspace.monitor') + return <div>{t('emailAuthOnly')}</div> +} +`, + 'app/chat/components/header/header.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export function ChatHeader() { + const t = useTranslations('workspace.monitor') + return <header>{t('title')}</header> +} +`, + 'app/chat/components/loading-state/loading-state.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export function ChatLoadingState() { + const t = useTranslations('workspace.monitor') + return <div>{t('loadingOnly')}</div> +} +`, + 'app/chat/components/message/message.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export type ChatMessage = { + id: string +} + +export function MessagePreview() { + const t = useTranslations('workspace.monitor') + return <div>{t('messageOnly')}</div> +} +`, + 'app/chat/components/message-container/message-container.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export function ChatMessageContainer() { + const t = useTranslations('workspace.monitor') + return <section>{t('containerOnly')}</section> +} +`, + 'app/chat/components/voice-interface/voice-interface.tsx': ` +'use client' + +import { useTranslations } from 'next-intl' + +export function VoiceInterface() { + const t = useTranslations('workspace.monitor') + return <div>{t('used')}</div> +} +`, + }) + + const result = scanCatalogProject({ + mode: 'route', + projectRoot, + routePath: '/chat/[identifier]', + }) + + expect(result.scannedFiles).toEqual( + expect.arrayContaining([ + 'app/chat/components/index.ts', + 'app/chat/components/header/header.tsx', + 'app/chat/components/voice-interface/voice-interface.tsx', + ]) + ) + expect(result.scannedFiles).not.toEqual( + expect.arrayContaining([ + 'app/chat/components/auth/email/email-auth.tsx', + 'app/chat/components/loading-state/loading-state.tsx', + 'app/chat/components/message/message.tsx', + 'app/chat/components/message-container/message-container.tsx', + ]) + ) + expect(getCoveragePathKeys(result)).toEqual( + expect.arrayContaining(['workspace.monitor.title', 'workspace.monitor.used']) + ) + expect(getCoveragePathKeys(result)).not.toEqual( + expect.arrayContaining([ + 'workspace.monitor.emailAuthOnly', + 'workspace.monitor.loadingOnly', + 'workspace.monitor.messageOnly', + 'workspace.monitor.containerOnly', + ]) + ) + }) +}) + +describe('i18n catalog report derivation', () => { + it('canonicalizes concrete CLI route inputs in scan and report output', () => { + const projectRoot = createBaseProject() + + const cliResult = runCatalogCli(projectRoot, { + route: '/workspace/ws-1/monitor', + }) + + expect(cliResult.scan.routePath).toBe('/workspace/[workspaceId]/monitor') + expect(cliResult.report.routePath).toBe('/workspace/[workspaceId]/monitor') + }) + + it('omits orphan fields by default for route reports and CLI output', () => { + const projectRoot = createCopyPassThroughMonitorProject() + + const { report } = buildRouteReport(projectRoot, '/workspace/[workspaceId]/monitor') + const cliResult = runCatalogCli(projectRoot, { + route: '/workspace/[workspaceId]/monitor', + }) + const cliJson = JSON.parse( + JSON.stringify({ scan: cliResult.scan, report: cliResult.report }) + ) as { + report: Record<string, unknown> + } + + expect(report.orphanedKeys).toBeUndefined() + expect(report.dynamicProtectedRoots).toBeUndefined() + expect(cliResult.text).not.toContain('Orphaned keys:') + expect(cliResult.text).not.toContain('Dynamic protected roots:') + expect(cliJson.report).not.toHaveProperty('orphanedKeys') + expect(cliJson.report).not.toHaveProperty('dynamicProtectedRoots') + }) + + it('includes orphan fields when route CLI opts in with `--with-orphans`', () => { + const projectRoot = createCopyPassThroughMonitorProject() + + const cliResult = runCatalogCli(projectRoot, { + route: '/workspace/[workspaceId]/monitor', + withOrphans: true, + }) + + expect(cliResult.report.orphanedKeys).toBeDefined() + expect(cliResult.report.dynamicProtectedRoots).toBeDefined() + expect(cliResult.text).toContain('Orphaned keys:') + expect(cliResult.text).toContain('Dynamic protected roots:') + }) + + it('rejects `--all --with-orphans`', () => { + const projectRoot = createBaseProject() + + expect(() => + runCatalogCli(projectRoot, { + all: true, + withOrphans: true, + }) + ).toThrow('`--with-orphans` is only valid with `--route <pathname>`') + }) + + it('does not report changelog structural and imported copy keys as orphaned', () => { + const projectRoot = createChangelogProject() + + const changelogReport = buildRouteReport(projectRoot, '/changelog', { + withOrphans: true, + }).report + + expect(changelogReport.orphanedKeys).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ pathKey: 'changelog.pageTitle' }), + expect.objectContaining({ pathKey: 'changelog.viewOnGitHub' }), + expect.objectContaining({ pathKey: 'changelog.documentation' }), + expect.objectContaining({ pathKey: 'changelog.rssFeed' }), + expect.objectContaining({ pathKey: 'changelog.viewContributorAriaLabel' }), + expect.objectContaining({ pathKey: 'changelog.contributorAvatarAlt' }), + expect.objectContaining({ pathKey: 'changelog.loadingMore' }), + expect.objectContaining({ pathKey: 'changelog.showMore' }), + ]) + ) + }) + + it('does not report optional-chain monitor error copy as orphaned', () => { + const projectRoot = createOptionalChainMonitorProject() + + const monitorReport = buildRouteReport(projectRoot, '/workspace/[workspaceId]/monitor', { + withOrphans: true, + }).report + + expect(monitorReport.orphanedKeys).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ pathKey: 'workspace.monitor.errors.loadViews' }), + expect.objectContaining({ pathKey: 'workspace.monitor.errors.createDefaultView' }), + expect.objectContaining({ pathKey: 'workspace.monitor.errors.invalidViewResponse' }), + ]) + ) + }) + + it('does not report invoked useCallback-wrapped monitor error copy as orphaned', () => { + const projectRoot = createUseCallbackMonitorProject() + + const { report } = buildRouteReport(projectRoot, '/workspace/[workspaceId]/monitor', { + withOrphans: true, + }) + + expect(report.usedKeys).toEqual( + expect.arrayContaining([ + 'workspace.monitor.errors.loadViews', + 'workspace.monitor.errors.createDefaultView', + 'workspace.monitor.errors.invalidViewResponse', + ]) + ) + expect(report.orphanedKeys).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ pathKey: 'workspace.monitor.errors.loadViews' }), + expect.objectContaining({ pathKey: 'workspace.monitor.errors.createDefaultView' }), + expect.objectContaining({ pathKey: 'workspace.monitor.errors.invalidViewResponse' }), + ]) + ) + }) + + it('does not mark copy as used from useCallback closures that are never invoked', () => { + const projectRoot = createUnusedUseCallbackMonitorProject() + + const { scanResult, report } = buildRouteReport( + projectRoot, + '/workspace/[workspaceId]/monitor', + { + withOrphans: true, + } + ) + + expect(getCoveragePathKeys(scanResult)).not.toContain('workspace.monitor.orphan') + expect(report.orphanedKeys).toEqual( + expect.arrayContaining([expect.objectContaining({ pathKey: 'workspace.monitor.orphan' })]) + ) + }) + + it('does not report verify promise callback error copy as orphaned', () => { + const projectRoot = createVerifyPromiseProject() + + const { report } = buildRouteReport(projectRoot, '/verify', { withOrphans: true }) + const expectedKeys = [ + 'auth.verify.errors.expired', + 'auth.verify.errors.invalid', + 'auth.verify.errors.attempts', + 'auth.verify.errors.generic', + 'auth.verify.errors.resendFailed', + ] + + expect(report.usedKeys).toEqual(expect.arrayContaining(expectedKeys)) + expect(report.orphanedKeys).not.toEqual( + expect.arrayContaining(expectedKeys.map((pathKey) => expect.objectContaining({ pathKey }))) + ) + }) + + it('propagates copy usage through helpers and child components without protecting the whole root', () => { + const projectRoot = createCopyPassThroughMonitorProject() + + const { scanResult, report } = buildRouteReport( + projectRoot, + '/workspace/[workspaceId]/monitor', + { + withOrphans: true, + } + ) + + expect(getCoveragePathKeys(scanResult)).toContain('workspace.monitor.errors') + expect(report.dynamicProtectedRoots).not.toContain('workspace.monitor') + expect(report.usedKeys).toEqual( + expect.arrayContaining([ + 'workspace.monitor.errors.loadViews', + 'workspace.monitor.errors.createDefaultView', + 'workspace.monitor.errors.invalidViewResponse', + ]) + ) + expect(report.orphanedKeys).toEqual( + expect.arrayContaining([expect.objectContaining({ pathKey: 'workspace.monitor.orphan' })]) + ) + }) + + it("resolves ReturnType<typeof ...>['copy'] parameter descriptors through useMemo callbacks", () => { + const projectRoot = createReturnTypeMonitorProject() + + const { scanResult, report } = buildRouteReport( + projectRoot, + '/workspace/[workspaceId]/monitor', + { + withOrphans: true, + } + ) + const expectedKeys = [ + 'workspace.monitor.configSearch.activeMonitors', + 'workspace.monitor.configSearch.pausedMonitors', + 'workspace.monitor.configSearch.lastOutcome', + 'workspace.monitor.configSearch.hasLastExecution', + 'workspace.monitor.configSearch.noLastExecution', + 'workspace.monitor.configSearch.hasLastOutcome', + 'workspace.monitor.configSearch.noLastOutcome', + 'workspace.monitor.configSearch.hasLastExecutionLog', + 'workspace.monitor.configSearch.noLastExecutionLog', + ] + + expect(getCoveragePathKeys(scanResult)).toEqual(expect.arrayContaining(expectedKeys)) + expect(report.orphanedKeys).not.toEqual( + expect.arrayContaining(expectedKeys.map((pathKey) => expect.objectContaining({ pathKey }))) + ) + }) + + it('does not report array built-ins as missing keys when array copy is passed through props', () => { + const projectRoot = createArrayBuiltinProject() + + const { report } = buildRouteReport(projectRoot, '/workspace/[workspaceId]/monitor') + + expect(report.usedKeys).toEqual( + expect.arrayContaining(['workspace.monitor.nextSteps.0', 'workspace.monitor.nextSteps.1']) + ) + expect(report.missingKeys).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ pathKey: 'workspace.monitor.nextSteps.length' }), + expect.objectContaining({ pathKey: 'workspace.monitor.nextSteps.map' }), + ]) + ) + }) + + it('ignores createTranslator calls without a static namespace when formatting inline templates', () => { + const projectRoot = createInlineFormatterProject() + + const { report } = buildRouteReport(projectRoot, '/workspace/[workspaceId]/monitor') + + expect(report.missingKeys).not.toEqual( + expect.arrayContaining([expect.objectContaining({ pathKey: 'value' })]) + ) + }) + + it('does not mark copy as used from local helpers that are never invoked', () => { + const projectRoot = createUnusedHelperMonitorProject() + + const { report } = buildRouteReport(projectRoot, '/workspace/[workspaceId]/monitor', { + withOrphans: true, + }) + + expect(report.orphanedKeys).toEqual( + expect.arrayContaining([expect.objectContaining({ pathKey: 'workspace.monitor.orphan' })]) + ) + }) + + it('does not mark copy as used from exported helpers that are never reached in all mode', () => { + const projectRoot = createUnusedExportedHelperMonitorProject() + + const { scanResult, report } = buildAllReport(projectRoot) + + expect(scanResult.scannedFiles).not.toContain( + 'app/workspace/[workspaceId]/monitor/unused-helper.tsx' + ) + expect(getCoveragePathKeys(scanResult)).not.toContain('workspace.monitor.orphan') + expect(report.orphanedKeys).toEqual( + expect.arrayContaining([expect.objectContaining({ pathKey: 'workspace.monitor.orphan' })]) + ) + }) + + it('includes route-reachable localized and non-localized boundaries in route reports', () => { + const projectRoot = createRouteBoundaryProject() + + const { scanResult, report } = buildRouteReport( + projectRoot, + '/workspace/[workspaceId]/monitor', + { + withOrphans: true, + } + ) + + expect(scanResult.scannedFiles).toEqual( + expect.arrayContaining([ + 'app/[locale]/not-found.tsx', + 'app/workspace/[workspaceId]/error.tsx', + 'app/workspace/[workspaceId]/monitor/global-error.tsx', + ]) + ) + expect(report.usedKeys).toEqual( + expect.arrayContaining([ + 'notFound.title', + 'notFound.description', + 'workspace.monitor.boundary.errorTitle', + 'workspace.monitor.boundary.globalErrorTitle', + ]) + ) + }) + + it('expands dynamic subtree coverage into target locale gaps', () => { + const projectRoot = createDynamicLocaleGapProject() + + const { report } = buildRouteReport(projectRoot, '/workspace/[workspaceId]/monitor', { + withOrphans: true, + }) + + expect(report.dynamicProtectedRoots).toContain('workspace.monitor.values') + expect(report.usedKeys).toEqual( + expect.arrayContaining([ + 'workspace.monitor.values.running', + 'workspace.monitor.values.paused', + ]) + ) + expect(report.orphanedKeys).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ pathKey: 'workspace.monitor.values.running' }), + expect.objectContaining({ pathKey: 'workspace.monitor.values.paused' }), + ]) + ) + const pausedGapLocales = report.targetLocaleGaps + .filter((entry) => entry.pathKey === 'workspace.monitor.values.paused') + .map((entry) => entry.locale) + .sort() + const expectedTargetLocales = locales + .filter((locale) => locale !== defaultLocale) + .sort() + + expect(pausedGapLocales).toEqual(expectedTargetLocales) + }) + + it('does not report timer callback copy usage as orphaned', () => { + const projectRoot = createTimerCallbackMonitorProject() + + const { report } = buildRouteReport(projectRoot, '/workspace/[workspaceId]/monitor', { + withOrphans: true, + }) + const expectedKeys = [ + 'workspace.monitor.errors.loadViews', + 'workspace.monitor.errors.createDefaultView', + 'workspace.monitor.errors.invalidViewResponse', + ] + + expect(report.usedKeys).toEqual(expect.arrayContaining(expectedKeys)) + expect(report.orphanedKeys).not.toEqual( + expect.arrayContaining(expectedKeys.map((pathKey) => expect.objectContaining({ pathKey }))) + ) + }) + + it('does not report startTransition callback copy usage as orphaned', () => { + const projectRoot = createStartTransitionMonitorProject() + + const { report } = buildRouteReport(projectRoot, '/workspace/[workspaceId]/monitor', { + withOrphans: true, + }) + const expectedKeys = [ + 'workspace.monitor.errors.loadViews', + 'workspace.monitor.errors.createDefaultView', + ] + + expect(report.usedKeys).toEqual(expect.arrayContaining(expectedKeys)) + expect(report.orphanedKeys).not.toEqual( + expect.arrayContaining(expectedKeys.map((pathKey) => expect.objectContaining({ pathKey }))) + ) + }) + + it('does not report invoked render-prop copy usage as orphaned', () => { + const projectRoot = createRenderPropMonitorProject() + + const { report } = buildRouteReport(projectRoot, '/workspace/[workspaceId]/monitor', { + withOrphans: true, + }) + const expectedKeys = [ + 'workspace.monitor.timezone.label', + 'workspace.monitor.timezone.loading', + 'workspace.monitor.timezone.empty', + 'workspace.monitor.timezone.placeholder', + ] + + expect(report.usedKeys).toEqual(expect.arrayContaining(expectedKeys)) + expect(report.orphanedKeys).not.toEqual( + expect.arrayContaining(expectedKeys.map((pathKey) => expect.objectContaining({ pathKey }))) + ) + }) + + it('does not report imported render-prop callback copy usage as orphaned', () => { + const projectRoot = createImportedRenderPropMonitorProject() + + const { report } = buildRouteReport(projectRoot, '/workspace/[workspaceId]/monitor', { + withOrphans: true, + }) + const expectedKeys = [ + 'workspace.monitor.timezone.label', + 'workspace.monitor.timezone.empty', + 'workspace.monitor.timezone.loading', + ] + + expect(report.usedKeys).toEqual(expect.arrayContaining(expectedKeys)) + expect(report.orphanedKeys).not.toEqual( + expect.arrayContaining(expectedKeys.map((pathKey) => expect.objectContaining({ pathKey }))) + ) + }) + + it('does not mark unused render props as used when the child never invokes them', () => { + const projectRoot = createUnusedRenderPropMonitorProject() + + const { scanResult, report } = buildRouteReport( + projectRoot, + '/workspace/[workspaceId]/monitor', + { + withOrphans: true, + } + ) + + expect(getCoveragePathKeys(scanResult)).not.toContain('workspace.monitor.orphan') + expect(report.orphanedKeys).toEqual( + expect.arrayContaining([expect.objectContaining({ pathKey: 'workspace.monitor.orphan' })]) + ) + }) + + it('does not mark imported render props as used when the child never invokes them', () => { + const projectRoot = createUnusedImportedRenderPropMonitorProject() + + const { scanResult, report } = buildRouteReport( + projectRoot, + '/workspace/[workspaceId]/monitor', + { + withOrphans: true, + } + ) + + expect(getCoveragePathKeys(scanResult)).not.toContain('workspace.monitor.orphan') + expect(report.orphanedKeys).toEqual( + expect.arrayContaining([expect.objectContaining({ pathKey: 'workspace.monitor.orphan' })]) + ) + }) + + it('tracks localized not-found entry copy in all mode', () => { + const projectRoot = createNotFoundAllModeProject() + + const { scanResult, report } = buildAllReport(projectRoot) + + expect(scanResult.scannedFiles).toEqual( + expect.arrayContaining(['app/[locale]/not-found.tsx', 'app/not-found-content.tsx']) + ) + expect(report.usedKeys).toEqual( + expect.arrayContaining([ + 'notFound.title', + 'notFound.description', + 'notFound.returnHome', + 'notFound.supportPrefix', + 'notFound.supportLinkLabel', + ]) + ) + expect(report.orphanedKeys).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ pathKey: 'notFound.title' }), + expect.objectContaining({ pathKey: 'notFound.description' }), + expect.objectContaining({ pathKey: 'notFound.returnHome' }), + expect.objectContaining({ pathKey: 'notFound.supportPrefix' }), + expect.objectContaining({ pathKey: 'notFound.supportLinkLabel' }), + ]) + ) + }) + + it('tracks non-localized framework entry files in all mode', () => { + const projectRoot = createLoadingAllModeProject() + + const { scanResult, report } = buildAllReport(projectRoot) + + expect(scanResult.scannedFiles).toContain('app/workspace/[workspaceId]/loading.tsx') + expect(report.usedKeys).toEqual( + expect.arrayContaining(['workspace.loading.title', 'workspace.loading.description']) + ) + expect(report.orphanedKeys).toEqual( + expect.arrayContaining([expect.objectContaining({ pathKey: 'workspace.loading.orphan' })]) + ) + }) + + it('tracks landing array roots and expands them to used leaves', () => { + const projectRoot = createLandingArrayProject() + + const { scanResult, report } = buildRouteReport(projectRoot, '/', { + withOrphans: true, + }) + + expect(getCoveragePathKeys(scanResult)).toEqual( + expect.arrayContaining([ + 'landing.hero.featureBadges.0', + 'landing.hero.leadWords', + 'landing.features.rows', + 'landing.howItWorks.processes', + ]) + ) + expect(report.usedKeys).toEqual( + expect.arrayContaining([ + 'landing.hero.featureBadges.0', + 'landing.hero.leadWords.0', + 'landing.hero.leadWords.1', + 'landing.features.rows.0.title', + 'landing.features.rows.0.bullets.0', + 'landing.features.rows.0.bullets.1', + 'landing.howItWorks.processes.0.title', + 'landing.howItWorks.processes.0.description', + ]) + ) + expect(report.usedKeys).not.toContain('landing.hero.featureBadges.1') + expect(report.usedKeys).not.toContain('landing.hero.featureBadges.2') + expect(report.targetLocaleGaps).toEqual( + expect.arrayContaining([{ locale: 'es', pathKey: 'landing.hero.leadWords.1' }]) + ) + expect(report.targetLocaleGaps).not.toEqual( + expect.arrayContaining([{ locale: 'es', pathKey: 'landing.hero.featureBadges.1' }]) + ) + }) + + it('derives owned namespaces for concrete and canonical catch-all routes', () => { + expect(getRouteOwnedNamespaces('/blog/hello-world')).toEqual(['blog', 'meta.blog']) + expect(deriveRouteNamespace('/blog/hello-world')).toBe('blog') + expect(getRouteOwnedNamespaces('/error')).toEqual([ + 'auth.common', + 'auth.error', + 'auth.sso', + ]) + expect(deriveRouteNamespace('/error')).toBe('auth.error') + expect(deriveRouteNamespace('/error/callback')).toBe('auth.error') + expect(getRouteOwnedNamespaces('/missing')).toEqual(['notFound']) + expect(getRouteOwnedNamespaces('/missing/deep')).toEqual(['notFound']) + expect(deriveRouteNamespace('/missing/deep')).toBe('notFound') + expect(getRouteOwnedNamespaces('/error/[[...callback]]')).toEqual([ + 'auth.common', + 'auth.error', + 'auth.sso', + ]) + expect(deriveRouteNamespace('/error/[[...callback]]')).toBe('auth.error') + expect(getRouteOwnedNamespaces('/[...notFound]')).toEqual(['notFound']) + expect(deriveRouteNamespace('/[...notFound]')).toBe('notFound') + }) + + it('builds read-only reports without mutating source or locale files', () => { + const projectRoot = createTempProject({ + 'i18n/messages/en.json': createLocaleMessages(), + 'i18n/messages/es.json': createLocaleMessages(), + 'i18n/messages/zh.json': createLocaleMessages(), + 'app/[locale]/(landing)/privacy/page.tsx': ` +import type { Metadata } from 'next' + +export async function generateMetadata(): Promise<Metadata> { + return { + title: 'Hardcoded privacy title', + description: 'Hardcoded privacy description', + } +} + +export default function PrivacyPage() { + return <div /> +} +`, + 'app/[locale]/workspace/[workspaceId]/layout.tsx': + 'export default function Layout({ children }: { children: React.ReactNode }) { return children }\n', + 'app/[locale]/workspace/[workspaceId]/monitor/page.tsx': + "import { MonitorPage } from '@/app/workspace/[workspaceId]/monitor/monitor'\nexport default function Page(){ return <MonitorPage /> }\n", + 'app/[locale]/workspace/[workspaceId]/templates/page.tsx': + "import { TemplatesPage } from '@/app/workspace/[workspaceId]/templates/templates'\nexport default function Page(){ return <TemplatesPage /> }\n", + 'app/workspace/[workspaceId]/monitor/monitor.tsx': ` +'use client' +import { useTranslations } from 'next-intl' + +export function MonitorPage() { + const t = useTranslations('workspace.monitor') + return <button title="Run now">{t('used')}</button> +} +`, + 'app/workspace/[workspaceId]/templates/templates.tsx': ` +'use client' +import { useTranslations } from 'next-intl' + +export function TemplatesPage() { + const t = useTranslations('workspace.templates') + return <div>{t('used')}</div> +} +`, + }) + + const trackedFiles = [ + 'i18n/messages/en.json', + 'i18n/messages/es.json', + 'i18n/messages/zh.json', + 'app/[locale]/(landing)/privacy/page.tsx', + 'app/workspace/[workspaceId]/monitor/monitor.tsx', + 'app/workspace/[workspaceId]/templates/templates.tsx', + ] + const before = snapshotFiles(projectRoot, trackedFiles) + + const privacyReport = buildRouteReport(projectRoot, '/privacy', { + withOrphans: true, + }).report + const monitorReport = buildRouteReport(projectRoot, '/workspace/[workspaceId]/monitor', { + withOrphans: true, + }).report + + expect(privacyReport.hardcodedCandidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: 'Hardcoded privacy title', + namespace: 'meta.privacy', + metadata: true, + }), + ]) + ) + expect(monitorReport.orphanedKeys).toEqual( + expect.arrayContaining([expect.objectContaining({ pathKey: 'workspace.monitor.orphan' })]) + ) + expect(monitorReport.orphanedKeys).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ pathKey: 'workspace.templates.otherOrphan' }), + ]) + ) + expect(monitorReport.hardcodedCandidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: 'Run now', + namespace: 'workspace.monitor', + suggestedPathKey: 'workspace.monitor.runNow', + }), + ]) + ) + expect(snapshotFiles(projectRoot, trackedFiles)).toEqual(before) + }) + + it('reuses existing catalog keys for exact hardcoded metadata matches', () => { + const messages = parseLocaleMessages() + messages.meta.privacy.title = 'Hardcoded privacy title' + const projectRoot = createTempProject({ + 'i18n/messages/en.json': toJson(messages), + 'i18n/messages/es.json': toJson(messages), + 'i18n/messages/zh.json': toJson(messages), + 'app/[locale]/(landing)/privacy/page.tsx': ` +import type { Metadata } from 'next' + +export async function generateMetadata(): Promise<Metadata> { + return { + title: 'Hardcoded privacy title', + } +} + +export default function PrivacyPage() { + return <div /> +} +`, + }) + + const { report } = buildRouteReport(projectRoot, '/privacy') + const candidate = report.hardcodedCandidates.find( + (entry) => entry.text === 'Hardcoded privacy title' + ) + + expect(candidate?.existingPathKey).toBe('meta.privacy.title') + }) + + it('generates lower-camel fallback suggestions for ownership-based metadata candidates', () => { + const messages = parseLocaleMessages() + messages.meta.landing = { + title: 'Landing title', + description: 'Landing description', + } + const projectRoot = createTempProject({ + 'i18n/messages/en.json': toJson(messages), + 'i18n/messages/es.json': toJson(messages), + 'i18n/messages/zh.json': toJson(messages), + 'app/[locale]/(landing)/page.tsx': ` +import type { Metadata } from 'next' + +export async function generateMetadata(): Promise<Metadata> { + return { + title: 'Fresh landing title', + } +} + +export default function LandingPage() { + return <div /> +} +`, + }) + + const { report } = buildRouteReport(projectRoot, '/') + const candidate = report.hardcodedCandidates.find( + (entry) => entry.text === 'Fresh landing title' + ) + + expect(candidate?.suggestedPathKey).toBe('meta.landing.freshLandingTitle') + expect(candidate?.suggestedPathKey).not.toContain('.Landing.') + }) + + it('normalizes report file paths to project-relative output', () => { + const messages = parseLocaleMessages() + messages.legal = { + common: { + contactSupport: 'Contact support', + }, + privacy: { + title: 'Privacy title', + }, + } + const localeMessages = toJson(messages) + const projectRoot = createTempProject({ + 'i18n/messages/en.json': localeMessages, + 'i18n/messages/es.json': localeMessages, + 'i18n/messages/zh.json': localeMessages, + 'app/[locale]/(landing)/privacy/page.tsx': ` +import { useTranslations } from 'next-intl' + +export default function PrivacyPage() { + const t = useTranslations('legal.privacy') + + return <button title="Hardcoded privacy action">{t('missingLabel')}</button> +} +`, + }) + + const { report } = buildRouteReport(projectRoot, '/privacy') + const missingKey = report.missingKeys.find( + (entry) => entry.pathKey === 'legal.privacy.missingLabel' + ) + const candidate = report.hardcodedCandidates.find( + (entry) => entry.text === 'Hardcoded privacy action' + ) + + expect(missingKey?.filePath).toBe('app/[locale]/(landing)/privacy/page.tsx') + expect(candidate?.filePath).toBe('app/[locale]/(landing)/privacy/page.tsx') + }) + + it('keeps structured-data-like object literals out of hardcoded candidates', () => { + const projectRoot = createTempProject({ + 'i18n/messages/en.json': createLocaleMessages(), + 'i18n/messages/es.json': createLocaleMessages(), + 'i18n/messages/zh.json': createLocaleMessages(), + 'app/[locale]/(landing)/privacy/page.tsx': ` +import type { Metadata } from 'next' + +const structuredData = { + name: 'Hardcoded organization name', + description: 'Hardcoded organization description', +} + +export async function generateMetadata(): Promise<Metadata> { + return { + title: 'Hardcoded privacy title', + description: 'Hardcoded privacy description', + } +} + +export default function PrivacyPage() { + return <script type='application/ld+json'>{JSON.stringify(structuredData)}</script> +} +`, + }) + + const { report } = buildRouteReport(projectRoot, '/privacy') + + expect(report.hardcodedCandidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: 'Hardcoded privacy title', metadata: true }), + expect.objectContaining({ text: 'Hardcoded privacy description', metadata: true }), + ]) + ) + expect(report.hardcodedCandidates).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: 'Hardcoded organization name' }), + expect.objectContaining({ text: 'Hardcoded organization description' }), + ]) + ) + }) +}) diff --git a/apps/tradinggoose/scripts/i18n-catalog/index.ts b/apps/tradinggoose/scripts/i18n-catalog/index.ts new file mode 100644 index 000000000..a9a36bc51 --- /dev/null +++ b/apps/tradinggoose/scripts/i18n-catalog/index.ts @@ -0,0 +1,147 @@ +import { parseArgs } from 'node:util' +import { buildCatalogReport, type CatalogReport } from './catalog' +import { + createCatalogProjectContext, + type CatalogScanResult, + scanCatalogProjectWithContext, +} from './scan' + +export type CliFlags = { + route?: string + all?: boolean + json?: boolean + withOrphans?: boolean +} + +export type CatalogCliResult = { + scan: { + mode: CatalogScanResult['mode'] + routePath: CatalogScanResult['routePath'] + } + report: CatalogReport + text: string +} + +export function parseCliFlags(argv = process.argv.slice(2)): CliFlags { + const { values } = parseArgs({ + args: argv, + options: { + route: { type: 'string' }, + all: { type: 'boolean', default: false }, + json: { type: 'boolean', default: false }, + 'with-orphans': { type: 'boolean', default: false }, + }, + strict: true, + allowPositionals: false, + }) + + return { + route: values.route, + all: values.all, + json: values.json, + withOrphans: values['with-orphans'], + } +} + +function formatList(title: string, entries: string[]) { + if (entries.length === 0) { + return `${title}: 0` + } + + return `${title}: ${entries.length}\n${entries.map((entry) => ` - ${entry}`).join('\n')}` +} + +export function formatCatalogCliText(scanResult: CatalogScanResult, report: CatalogReport) { + const sections = [ + `Mode: ${scanResult.mode}${scanResult.routePath ? ` (${scanResult.routePath})` : ''}`, + `Scanned files: ${report.scannedFiles.length}`, + formatList('Used keys', report.usedKeys), + formatList('Missing keys', report.missingKeys.map((entry) => entry.pathKey)), + formatList( + 'Target locale gaps', + report.targetLocaleGaps.map((entry) => `${entry.locale}: ${entry.pathKey}`) + ), + formatList( + 'Hardcoded candidates', + report.hardcodedCandidates.map( + (entry) => + `${entry.filePath}:${entry.line}:${entry.column} ${ + entry.existingPathKey ? `${entry.existingPathKey} (existing)` : entry.suggestedPathKey + } -> ${JSON.stringify(entry.text)}` + ) + ), + ] + + if (report.orphanedKeys) { + sections.splice( + 4, + 0, + formatList('Orphaned keys', report.orphanedKeys.map((entry) => entry.pathKey)) + ) + } + if (report.dynamicProtectedRoots) { + const insertionIndex = report.orphanedKeys ? 5 : 4 + sections.splice( + insertionIndex, + 0, + formatList('Dynamic protected roots', report.dynamicProtectedRoots) + ) + } + + return sections.join('\n\n') +} + +export function runCatalogCli(projectRoot: string, flags: CliFlags): CatalogCliResult { + if ((!flags.route && !flags.all) || (flags.route && flags.all)) { + throw new Error('Pass exactly one of --route <pathname> or --all') + } + + if (flags.all && flags.withOrphans) { + throw new Error('`--with-orphans` is only valid with `--route <pathname>`') + } + + const context = createCatalogProjectContext(projectRoot) + const scanResult = flags.all + ? scanCatalogProjectWithContext(context, { mode: 'all' }) + : scanCatalogProjectWithContext(context, { mode: 'route', routePath: flags.route! }) + const globalScanResult = + flags.all || flags.withOrphans + ? scanResult.mode === 'all' + ? scanResult + : scanCatalogProjectWithContext(context, { mode: 'all' }) + : undefined + const report = buildCatalogReport({ + projectRoot, + scanResult, + globalScanResult, + }) + + return { + scan: { + mode: scanResult.mode, + routePath: scanResult.routePath, + }, + report, + text: formatCatalogCliText(scanResult, report), + } +} + +async function main() { + const flags = parseCliFlags() + const result = runCatalogCli(process.cwd(), flags) + + if (flags.json) { + console.log(JSON.stringify({ scan: result.scan, report: result.report }, null, 2)) + return + } + + console.log(result.text) +} + +if (import.meta.main) { + main().catch((error) => { + const message = error instanceof Error ? error.message : String(error) + console.error(message) + process.exitCode = 1 + }) +} diff --git a/apps/tradinggoose/scripts/i18n-catalog/ownership.ts b/apps/tradinggoose/scripts/i18n-catalog/ownership.ts new file mode 100644 index 000000000..93b203ca0 --- /dev/null +++ b/apps/tradinggoose/scripts/i18n-catalog/ownership.ts @@ -0,0 +1,446 @@ +import path from 'node:path' + +export type RouteOwnershipRule = { + pattern: string + defaultNamespace: string + metadataNamespace?: string + namespaces: string[] +} + +const ROUTE_OWNERSHIP_RULES: RouteOwnershipRule[] = [ + { + pattern: '/', + defaultNamespace: 'landing', + metadataNamespace: 'meta.landing', + namespaces: ['landing', 'meta.landing'], + }, + { + pattern: '/blog', + defaultNamespace: 'blog', + metadataNamespace: 'meta.blog', + namespaces: ['blog', 'meta.blog'], + }, + { + pattern: '/blog/[slug]', + defaultNamespace: 'blog', + metadataNamespace: 'meta.blog', + namespaces: ['blog', 'meta.blog'], + }, + { + pattern: '/privacy', + defaultNamespace: 'legal.privacy', + metadataNamespace: 'meta.privacy', + namespaces: ['legal.common', 'legal.privacy', 'meta.privacy'], + }, + { + pattern: '/terms', + defaultNamespace: 'legal.terms', + metadataNamespace: 'meta.terms', + namespaces: ['legal.common', 'legal.terms', 'meta.terms'], + }, + { + pattern: '/licenses', + defaultNamespace: 'legal', + metadataNamespace: 'meta.licenses', + namespaces: ['legal', 'meta.licenses'], + }, + { + pattern: '/careers', + defaultNamespace: 'careers', + metadataNamespace: 'meta.careers', + namespaces: ['careers', 'meta.careers'], + }, + { + pattern: '/changelog', + defaultNamespace: 'changelog', + metadataNamespace: 'meta.changelog', + namespaces: ['changelog', 'meta.changelog'], + }, + { + pattern: '/invite/[id]', + defaultNamespace: 'invite', + namespaces: ['invite'], + }, + { + pattern: '/unsubscribe', + defaultNamespace: 'unsubscribe', + namespaces: ['unsubscribe'], + }, + { + pattern: '/chat/[identifier]', + defaultNamespace: 'chat', + namespaces: ['chat'], + }, + { + pattern: '/workspace', + defaultNamespace: 'workspace.entry', + namespaces: ['workspace.entry'], + }, + { + pattern: '/workspace/[workspaceId]', + defaultNamespace: 'workspace.dashboard', + namespaces: ['workspace.dashboard'], + }, + { + pattern: '/workspace/[workspaceId]/dashboard', + defaultNamespace: 'workspace.dashboard', + namespaces: ['workspace.dashboard'], + }, + { + pattern: '/workspace/[workspaceId]/knowledge', + defaultNamespace: 'workspace.knowledge', + namespaces: ['workspace.knowledge'], + }, + { + pattern: '/workspace/[workspaceId]/files', + defaultNamespace: 'workspace.files', + namespaces: ['workspace.files'], + }, + { + pattern: '/workspace/[workspaceId]/records', + defaultNamespace: 'workspace.records', + namespaces: ['workspace.logs', 'workspace.records'], + }, + { + pattern: '/workspace/[workspaceId]/monitor', + defaultNamespace: 'workspace.monitor', + namespaces: ['workspace.monitor'], + }, + { + pattern: '/workspace/[workspaceId]/templates', + defaultNamespace: 'workspace.templates', + namespaces: ['workspace.templates'], + }, + { + pattern: '/workspace/[workspaceId]/templates/[id]', + defaultNamespace: 'workspace.templates', + namespaces: ['workspace.templates'], + }, + { + pattern: '/workspace/[workspaceId]/api-keys', + defaultNamespace: 'workspace.apiKeys', + namespaces: ['workspace.apiKeys'], + }, + { + pattern: '/workspace/[workspaceId]/integrations', + defaultNamespace: 'workspace.integrations', + namespaces: ['workspace.integrations'], + }, + { + pattern: '/workspace/[workspaceId]/environment', + defaultNamespace: 'workspace.environment', + namespaces: ['workspace.environment'], + }, + { + pattern: '/admin', + defaultNamespace: 'admin.home', + namespaces: ['admin.home'], + }, + { + pattern: '/admin/services', + defaultNamespace: 'admin.services', + namespaces: ['admin.services'], + }, + { + pattern: '/admin/integrations', + defaultNamespace: 'admin.integrations', + namespaces: ['admin.integrations'], + }, + { + pattern: '/admin/registration', + defaultNamespace: 'admin.registration', + namespaces: ['admin.registration'], + }, + { + pattern: '/admin/billing', + defaultNamespace: 'admin.billing', + namespaces: ['admin.billing'], + }, + { + pattern: '/admin/billing/create', + defaultNamespace: 'admin.billing', + namespaces: ['admin.billing'], + }, + { + pattern: '/admin/billing/[tierId]', + defaultNamespace: 'admin.billing', + namespaces: ['admin.billing'], + }, + { + pattern: '/login', + defaultNamespace: 'auth.login', + namespaces: ['auth.login'], + }, + { + pattern: '/signup', + defaultNamespace: 'auth.signup', + namespaces: ['auth.signup'], + }, + { + pattern: '/verify', + defaultNamespace: 'auth.verify', + namespaces: ['auth.verify'], + }, + { + pattern: '/waitlist', + defaultNamespace: 'auth.waitlist', + namespaces: ['auth.waitlist'], + }, + { + pattern: '/reset-password', + defaultNamespace: 'auth.resetPassword', + namespaces: ['auth.resetPassword'], + }, + { + pattern: '/sso', + defaultNamespace: 'auth.sso', + namespaces: ['auth.sso'], + }, + { + pattern: '/error/[[...callback]]', + defaultNamespace: 'auth.error', + namespaces: ['auth.common', 'auth.error', 'auth.sso'], + }, + { + pattern: '/mcp/authorize', + defaultNamespace: 'auth.mcp', + namespaces: ['auth.mcp'], + }, + { + pattern: '/[...notFound]', + defaultNamespace: 'notFound', + namespaces: ['notFound'], + }, +] + +export function normalizeRoutePath(pathname: string) { + const normalized = pathname.trim() + + if (!normalized.startsWith('/')) { + throw new Error(`Expected a canonical route pathname, received "${pathname}"`) + } + + if (normalized === '/') { + return '/' + } + + return normalized.replace(/\/+$/, '') +} + +function splitPathSegments(pathname: string) { + return normalizeRoutePath(pathname).split('/').filter(Boolean) +} + +type RouteSegmentKind = 'static' | 'dynamic' | 'catch-all' | 'optional-catch-all' + +type RoutePatternMatch = { + depth: number + specificity: number[] +} + +const ROUTE_SEGMENT_SPECIFICITY: Record<RouteSegmentKind, number> = { + static: 3, + dynamic: 2, + 'catch-all': 1, + 'optional-catch-all': 0, +} + +function getRouteSegmentKind(segment: string): RouteSegmentKind { + if (/^\[\[\.\.\.[^[\]/]+\]\]$/.test(segment)) { + return 'optional-catch-all' + } + + if (/^\[\.\.\.[^[\]/]+\]$/.test(segment)) { + return 'catch-all' + } + + if (/^\[[^[\]/]+\]$/.test(segment)) { + return 'dynamic' + } + + return 'static' +} + +function isDynamicSegment(segment: string) { + return getRouteSegmentKind(segment) !== 'static' +} + +function matchRoutePattern(pattern: string, pathname: string): RoutePatternMatch | null { + const routeSegments = splitPathSegments(pathname) + const patternSegments = splitPathSegments(pattern) + const specificity: number[] = [] + let routeIndex = 0 + + for (const segment of patternSegments) { + const segmentKind = getRouteSegmentKind(segment) + specificity.push(ROUTE_SEGMENT_SPECIFICITY[segmentKind]) + + if (segmentKind === 'static') { + if (routeSegments[routeIndex] !== segment) { + return null + } + routeIndex += 1 + continue + } + + if (segmentKind === 'dynamic') { + if (!routeSegments[routeIndex]) { + return null + } + routeIndex += 1 + continue + } + + if (segmentKind === 'catch-all') { + if (routeIndex >= routeSegments.length) { + return null + } + routeIndex = routeSegments.length + continue + } + + routeIndex = routeSegments.length + } + + if (routeIndex !== routeSegments.length) { + return null + } + + return { + depth: patternSegments.length, + specificity, + } +} + +function compareRoutePatternMatches(left: RoutePatternMatch, right: RoutePatternMatch) { + const maxSpecificityLength = Math.min(left.specificity.length, right.specificity.length) + for (let index = 0; index < maxSpecificityLength; index += 1) { + const difference = left.specificity[index]! - right.specificity[index]! + if (difference !== 0) { + return difference + } + } + + if (left.depth !== right.depth) { + return left.depth - right.depth + } + + return 0 +} + +function findBestMatchingRoute<T>( + pathname: string, + candidates: Iterable<T>, + getPattern: (candidate: T) => string +): T | null { + const normalizedPathname = normalizeRoutePath(pathname) + let bestMatch: { candidate: T; routeMatch: RoutePatternMatch } | null = null + + for (const candidate of candidates) { + const pattern = normalizeRoutePath(getPattern(candidate)) + + if (pattern === normalizedPathname) { + return candidate + } + + const routeMatch = matchRoutePattern(pattern, normalizedPathname) + if (!routeMatch) { + continue + } + + if (!bestMatch || compareRoutePatternMatches(routeMatch, bestMatch.routeMatch) > 0) { + bestMatch = { + candidate, + routeMatch, + } + } + } + + return bestMatch?.candidate ?? null +} + +export function findBestMatchingRoutePattern(pathname: string, routePatterns: Iterable<string>) { + return findBestMatchingRoute(pathname, routePatterns, (routePattern) => routePattern) +} + +function toKeySegment(value: string) { + return value + .replace(/\[\[\.\.\.[^[\]/]+\]\]|\[\.\.\.[^[\]/]+\]|\[[^[\]/]+\]/g, '') + .replace(/[^a-zA-Z0-9]+([a-zA-Z0-9])/g, (_, next: string) => next.toUpperCase()) + .replace(/^[^a-zA-Z0-9]+/, '') + .replace(/[^a-zA-Z0-9]/g, '') +} + +function getLastStaticRouteSegment(pathname: string) { + const segments = splitPathSegments(pathname) + for (let index = segments.length - 1; index >= 0; index -= 1) { + const segment = segments[index] + if (!segment || isDynamicSegment(segment)) { + continue + } + return toKeySegment(segment) + } + + return 'route' +} + +export function getRouteOwnership(pathname: string): RouteOwnershipRule | null { + return findBestMatchingRoute(pathname, ROUTE_OWNERSHIP_RULES, (rule) => rule.pattern) +} + +export function getRouteOwnedNamespaces(pathname: string) { + const ownership = getRouteOwnership(pathname) + if (ownership) { + return ownership.namespaces + } + + const fallbackNamespace = deriveFallbackNamespace(pathname) + return [fallbackNamespace] +} + +export function deriveFallbackNamespace(pathname: string, options?: { metadata?: boolean }) { + const segment = getLastStaticRouteSegment(pathname) + if (options?.metadata) { + return `meta.${segment}` + } + + const normalizedPathname = normalizeRoutePath(pathname) + if (normalizedPathname.startsWith('/workspace/')) { + return `workspace.${segment}` + } + + if (normalizedPathname.startsWith('/admin/')) { + return `admin.${segment}` + } + + return segment +} + +export function deriveRouteNamespace(pathname: string, options?: { metadata?: boolean }) { + const ownership = getRouteOwnership(pathname) + if (!ownership) { + return deriveFallbackNamespace(pathname, options) + } + + if (options?.metadata) { + return ownership.metadataNamespace ?? deriveFallbackNamespace(pathname, { metadata: true }) + } + + return ownership.defaultNamespace +} + +export function deriveComponentKeySegment(filePath: string, projectRoot: string) { + const relativePath = path.relative(projectRoot, filePath) + const withoutExtension = relativePath.replace(/\.[^.]+$/, '') + const baseName = path.basename(withoutExtension) + const candidate = + baseName === 'page' || baseName === 'layout' || baseName === 'index' + ? path.basename(path.dirname(withoutExtension)) + : baseName + + const normalized = toKeySegment(candidate) + const lowerCamelNormalized = normalized + ? `${normalized.charAt(0).toLowerCase()}${normalized.slice(1)}` + : '' + return lowerCamelNormalized || 'copy' +} diff --git a/apps/tradinggoose/scripts/i18n-catalog/scan.ts b/apps/tradinggoose/scripts/i18n-catalog/scan.ts new file mode 100644 index 000000000..344c10830 --- /dev/null +++ b/apps/tradinggoose/scripts/i18n-catalog/scan.ts @@ -0,0 +1,3578 @@ +import fs from 'node:fs' +import path from 'node:path' +import ts from 'typescript' +import { + deriveComponentKeySegment, + deriveRouteNamespace, + getRouteOwnedNamespaces, +} from './ownership' +import { + createEntryDiscoveryContext, + discoverAllModeEntries, + isIgnoredProjectPath, + isPathInsideDirectory, + isRoutePathPrefix, + resolveAppRoutePathForFile, + resolveOwningRoutePathForFile, + resolveRouteEntries, + SOURCE_EXTENSIONS, + toRelativeProjectPath, + type EntryDiscoveryContext, + type EntryExportName, +} from './entries' + +const HARD_CODED_PROP_NAMES = new Set([ + 'title', + 'placeholder', + 'aria-label', + 'alt', + 'label', + 'description', + 'helperText', + 'tooltip', +]) +const ARRAY_CONSUMER_METHOD_NAMES = new Set([ + 'at', + 'concat', + 'entries', + 'every', + 'filter', + 'find', + 'findIndex', + 'findLast', + 'findLastIndex', + 'flat', + 'flatMap', + 'forEach', + 'includes', + 'indexOf', + 'join', + 'keys', + 'lastIndexOf', + 'map', + 'reduce', + 'reduceRight', + 'slice', + 'some', + 'toReversed', + 'toSorted', + 'toSpliced', + 'values', + 'with', +]) +const ARRAY_RUNTIME_CALLBACK_METHOD_NAMES = new Set([ + 'every', + 'filter', + 'find', + 'findIndex', + 'findLast', + 'findLastIndex', + 'flatMap', + 'forEach', + 'map', + 'reduce', + 'reduceRight', + 'some', + 'toSorted', +]) +const RUNTIME_CALLBACK_HOOK_NAMES = new Set([ + 'useEffect', + 'useInsertionEffect', + 'useLayoutEffect', + 'useMemo', +]) +const RUNTIME_CALLBACK_FUNCTION_NAMES = new Set([ + 'queueMicrotask', + 'requestAnimationFrame', + 'startTransition', + 'setInterval', + 'setTimeout', +]) +const ROOT_HINT_NAME = /(^copy$|Copy$|^messages$|Messages$|^widgetsCopy$|^monitorCopy$)/i +const METADATA_PROP_NAMES = new Set(['title', 'description', 'alt']) + +type Descriptor = + | { kind: 'root'; path: string[] } + | { kind: 'translator'; namespace: string[] } + | { kind: 'object'; properties: Record<string, Descriptor> } + | { kind: 'callable'; filePath: string; closureScope: Scope; targetNode: NamedFunctionNode } + +type NamedFunctionNode = ts.FunctionLikeDeclaration & { name?: ts.PropertyName | ts.BindingName } + +type ImportBinding = { + importedName: string + localName: string + resolvedFilePath: string | null +} + +type ReExportBinding = { + importedName: string + exportedName: string + resolvedFilePath: string | null +} + +type LocalExportBinding = { + localName: string + exportedName: string +} + +type TypeImportBinding = { + importedName: string + localName: string + resolvedFilePath: string | null +} + +type TypeExportBinding = { + localName: string + exportedName: string +} + +type TypeReExportBinding = { + importedName: string + exportedName: string + resolvedFilePath: string | null +} + +type TypeDeclarationNode = ts.TypeAliasDeclaration | ts.InterfaceDeclaration + +type RequestedExports = 'all' | Set<string> + +type RuntimeImportEdge = { + resolvedFilePath: string + requestedExports: RequestedExports +} + +type PendingReachability = { + filePath: string + requestedExports: RequestedExports +} + +type ProjectFile = { + filePath: string + relativePath: string + sourceFile: ts.SourceFile + importBindings: Map<string, ImportBinding> + typeImportBindings: Map<string, TypeImportBinding> + runtimeImportEdges: RuntimeImportEdge[] + localExportBindings: LocalExportBinding[] + reExportBindings: ReExportBinding[] + typeReExportBindings: TypeReExportBinding[] + exportAllPaths: string[] + exportedValueNames: Set<string> + hasDefaultExport: boolean + localTypeDeclarations: Map<string, TypeDeclarationNode> + localTypeExportBindings: TypeExportBinding[] + typeRoots: Map<string, Descriptor> + localFunctions: Map<string, NamedFunctionNode> + exportedFunctions: Set<string> + defaultExportFunction: NamedFunctionNode | null + defaultExportBindingName: string | null +} + +type TranslatorHint = { + name: string + namespace: string[] +} + +type RootHint = { + name: string + path: string[] +} + +type ScopeKind = 'root' | 'function' | 'block' + +type Scope = { + kind: ScopeKind + parent: Scope | null + hoistTarget: Scope | null + bindings: Map<string, Descriptor> + translatorHints: TranslatorHint[] + rootHints: RootHint[] + currentFunction: ts.FunctionLikeDeclaration | null + inMetadata: boolean +} + +type FunctionSemantics = Map<string, Descriptor> +type FileSemantics = Map<string, Descriptor> +type ArgumentDescriptor = { + index: number + descriptor: Descriptor +} + +type FileAnalysis = { + file: ProjectFile + importedCallableDescriptors: Map<string, Descriptor> + importedSemantics: Map<string, Descriptor> + localSemantics: Map<string, Descriptor> +} + +type ExportedFunctionTarget = { + targetFile: ProjectFile + targetNode: NamedFunctionNode +} + +type CallableTarget = { + activeRoutePath: string | null + closureScope: Scope | null + targetFile: ProjectFile + targetNode: NamedFunctionNode + targetImportedSemantics: Map<string, Descriptor> +} + +type ScanState = { + coverage: CoverageRecord[] + hardcodedCandidates: HardcodedCandidate[] +} + +type ScanContext = { + entryDiscoveryContext: EntryDiscoveryContext + projectRoot: string + projectFiles: Map<string, ProjectFile> + semanticsByFile: Map<string, Map<string, Descriptor>> + analysisByFile: Map<string, FileAnalysis> + routePath: string | null + invocationCache: Set<string> +} + +type EntryInvocation = { + activeRoutePath: string | null + exportName: EntryExportName +} + +type ScanFileOptions = { + entryInvocations: EntryInvocation[] + rootScanRoutePaths: Array<string | null> +} + +export type CoverageRecord = { + filePath: string + line: number + column: number + path: string[] + pathKey: string + mode: 'exact' | 'subtree' + source: 'copy-access' | 'translation' + subtreeReason?: 'array-root' | 'dynamic-root' +} + +export type HardcodedCandidate = { + filePath: string + line: number + column: number + text: string + kind: 'jsx-text' | 'jsx-attribute' | 'metadata' + namespace: string + namespaceSource: 'static' | 'ownership' | 'fallback' + relativeKeyParts: string[] + attributeName?: string + metadata: boolean +} + +export type ScanMode = 'route' | 'all' + +export type CatalogScanResult = { + mode: ScanMode + routePath: string | null + ownedNamespaces: string[] + scannedFiles: string[] + coverage: CoverageRecord[] + hardcodedCandidates: HardcodedCandidate[] +} + +export type CatalogProjectContext = { + entryDiscoveryContext: EntryDiscoveryContext + projectFiles: Map<string, ProjectFile> + projectRoot: string +} + +type ContextScanOptions = { mode: 'all' } | { mode: 'route'; routePath: string } + +type ScanOptions = + | ({ mode: 'all' } & { projectRoot: string }) + | { mode: 'route'; projectRoot: string; routePath: string } + +function rootDescriptor(pathParts: string[]): Descriptor { + return { kind: 'root', path: [...pathParts] } +} + +function translatorDescriptor(namespaceParts: string[]): Descriptor { + return { kind: 'translator', namespace: [...namespaceParts] } +} + +function objectDescriptor(properties: Record<string, Descriptor>): Descriptor { + return { kind: 'object', properties: { ...properties } } +} + +function callableDescriptor( + filePath: string, + targetNode: NamedFunctionNode, + closureScope: Scope +): Descriptor { + return { kind: 'callable', filePath, closureScope, targetNode } +} + +function cloneRequestedExports(requestedExports: RequestedExports): RequestedExports { + return requestedExports === 'all' ? 'all' : new Set(requestedExports) +} + +function requestedExportsCover( + current: RequestedExports | undefined, + incoming: RequestedExports +): boolean { + if (!current) { + return false + } + + if (current === 'all') { + return true + } + + if (incoming === 'all') { + return false + } + + for (const name of incoming) { + if (!current.has(name)) { + return false + } + } + + return true +} + +function mergeRequestedExports( + current: RequestedExports | undefined, + incoming: RequestedExports +): RequestedExports { + if (!current) { + return cloneRequestedExports(incoming) + } + + if (current === 'all' || incoming === 'all') { + return 'all' + } + + const merged = new Set(current) + for (const name of incoming) { + merged.add(name) + } + return merged +} + +function requestedExportsForName(name: string): RequestedExports { + return new Set([name]) +} + +function collectBindingNames(name: ts.BindingName): string[] { + if (ts.isIdentifier(name)) { + return [name.text] + } + + const bindingNames: string[] = [] + for (const element of name.elements) { + if (!ts.isBindingElement(element)) { + continue + } + bindingNames.push(...collectBindingNames(element.name)) + } + return bindingNames +} + +function cloneDescriptor(descriptor: Descriptor): Descriptor { + if (descriptor.kind === 'root') { + return rootDescriptor(descriptor.path) + } + if (descriptor.kind === 'translator') { + return translatorDescriptor(descriptor.namespace) + } + if (descriptor.kind === 'callable') { + return callableDescriptor(descriptor.filePath, descriptor.targetNode, descriptor.closureScope) + } + return objectDescriptor( + Object.fromEntries( + Object.entries(descriptor.properties).map(([key, value]) => [key, cloneDescriptor(value)]) + ) + ) +} + +function sameDescriptor(left: Descriptor | null, right: Descriptor | null): boolean { + if (!left || !right || left.kind !== right.kind) { + return left === right + } + + if (left.kind === 'root' && right.kind === 'root') { + return left.path.join('.') === right.path.join('.') + } + + if (left.kind === 'translator' && right.kind === 'translator') { + return left.namespace.join('.') === right.namespace.join('.') + } + + if (left.kind === 'callable' && right.kind === 'callable') { + return descriptorToPathKey(left) === descriptorToPathKey(right) + } + + if (left.kind === 'object' && right.kind === 'object') { + const leftKeys = Object.keys(left.properties) + const rightKeys = Object.keys(right.properties) + if (leftKeys.length !== rightKeys.length) { + return false + } + return leftKeys.every((key) => + sameDescriptor(left.properties[key] ?? null, right.properties[key] ?? null) + ) + } + + return false +} + +function descriptorToPathKey(descriptor: Descriptor): string { + if (descriptor.kind === 'root') { + return descriptor.path.join('.') + } + if (descriptor.kind === 'translator') { + return descriptor.namespace.join('.') + } + if (descriptor.kind === 'callable') { + return `callable:${descriptor.filePath}:${descriptor.targetNode.getStart( + descriptor.targetNode.getSourceFile() + )}` + } + return JSON.stringify( + Object.fromEntries( + Object.entries(descriptor.properties) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => [key, descriptorToPathKey(value)]) + ) + ) +} + +function descriptorMapKey(descriptors: Map<string, Descriptor> | undefined) { + if (!descriptors) { + return '' + } + + return [...descriptors.entries()] + .map(([name, descriptor]) => `${name}:${descriptorToPathKey(descriptor)}`) + .sort() + .join('|') +} + +function getScopeBindingSignature(scope: Scope | null) { + const entries: string[] = [] + const seenNames = new Set<string>() + let current: Scope | null = scope + + while (current) { + for (const [name, descriptor] of current.bindings.entries()) { + if (seenNames.has(name)) { + continue + } + + seenNames.add(name) + entries.push(`${name}:${descriptorToPathKey(descriptor)}`) + } + + current = current.parent + } + + return entries.sort().join('|') +} + +function descriptorMapsEqual( + left: Map<string, Descriptor> | undefined, + right: Map<string, Descriptor> | undefined +) { + return descriptorMapKey(left) === descriptorMapKey(right) +} + +function readPropertyDescriptor(descriptor: Descriptor, propertyName: string): Descriptor | null { + if (descriptor.kind === 'root') { + return rootDescriptor([...descriptor.path, propertyName]) + } + if (descriptor.kind === 'object') { + return descriptor.properties[propertyName] + ? cloneDescriptor(descriptor.properties[propertyName]!) + : null + } + return null +} + +function getLiteralPropertyName(name: ts.PropertyName | ts.BindingName | undefined): string | null { + if (!name) { + return null + } + + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { + return name.text + } + + if (ts.isComputedPropertyName(name)) { + return getStaticPropertyKey(name.expression) + } + + return null +} + +function getStaticTextValue(node: ts.Expression): string | null { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return node.text + } + + return null +} + +function getStaticPropertyKey(node: ts.Expression): string | null { + if (ts.isNumericLiteral(node)) { + return node.text + } + + return getStaticTextValue(node) +} + +function getTypeLiteralPropertyKey(node: ts.TypeNode): string | null { + if (!ts.isLiteralTypeNode(node)) { + return null + } + + if (ts.isStringLiteral(node.literal) || ts.isNumericLiteral(node.literal)) { + return node.literal.text + } + + return null +} + +function unwrapExpression(node: ts.Expression): ts.Expression { + if (ts.isParenthesizedExpression(node)) { + return unwrapExpression(node.expression) + } + if ( + ts.isAsExpression(node) || + ts.isTypeAssertionExpression(node) || + ts.isNonNullExpression(node) + ) { + return unwrapExpression(node.expression) + } + if (ts.isSatisfiesExpression(node)) { + return unwrapExpression(node.expression) + } + if (ts.isAwaitExpression(node)) { + return unwrapExpression(node.expression) + } + return node +} + +function isSourceFilePath(filePath: string) { + return SOURCE_EXTENSIONS.some((extension) => filePath.endsWith(extension)) +} + +function resolveImportPath(projectRoot: string, importerFilePath: string, specifier: string) { + let basePath: string | null = null + if (specifier.startsWith('@/')) { + basePath = path.join(projectRoot, specifier.slice(2)) + } else if (specifier.startsWith('./') || specifier.startsWith('../')) { + basePath = path.resolve(path.dirname(importerFilePath), specifier) + } + + if (!basePath) { + return null + } + + const candidates = [basePath, ...SOURCE_EXTENSIONS.map((extension) => `${basePath}${extension}`)] + for (const extension of SOURCE_EXTENSIONS) { + candidates.push(path.join(basePath, `index${extension}`)) + } + + for (const candidate of candidates) { + if (!candidate.startsWith(projectRoot)) { + continue + } + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + if (!isSourceFilePath(candidate)) { + return null + } + const relativePath = toRelativeProjectPath(projectRoot, candidate) + if (isIgnoredProjectPath(relativePath)) { + return null + } + return candidate + } + } + + return null +} + +function getScriptKind(filePath: string) { + if (filePath.endsWith('.tsx')) return ts.ScriptKind.TSX + if (filePath.endsWith('.jsx')) return ts.ScriptKind.JSX + if (filePath.endsWith('.js')) return ts.ScriptKind.JS + return ts.ScriptKind.TS +} + +function extractCallableInitializer(expression: ts.Expression): NamedFunctionNode | null { + const node = unwrapExpression(expression) + if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) { + return node + } + + if (!ts.isCallExpression(node) && !ts.isCallChain(node)) { + return null + } + + const callee = unwrapExpression(node.expression) + let calleeName: string | null = null + if (ts.isIdentifier(callee)) { + calleeName = callee.text + } else if (ts.isPropertyAccessExpression(callee) || ts.isPropertyAccessChain(callee)) { + calleeName = callee.name.text + } else if ( + (ts.isElementAccessExpression(callee) || ts.isElementAccessChain(callee)) && + callee.argumentExpression + ) { + calleeName = getStaticPropertyKey(unwrapExpression(callee.argumentExpression)) + } + + if (calleeName !== 'useCallback') { + return null + } + + const callback = node.arguments[0] ? unwrapExpression(node.arguments[0]!) : null + if (!callback) { + return null + } + + return ts.isArrowFunction(callback) || ts.isFunctionExpression(callback) ? callback : null +} + +function collectProjectFile(projectRoot: string, filePath: string): ProjectFile { + const sourceText = fs.readFileSync(filePath, 'utf8') + const sourceFile = ts.createSourceFile( + filePath, + sourceText, + ts.ScriptTarget.Latest, + true, + getScriptKind(filePath) + ) + const importBindings = new Map<string, ImportBinding>() + const typeImportBindings = new Map<string, TypeImportBinding>() + const runtimeImportEdges = new Map<string, RequestedExports>() + const localExportBindings: LocalExportBinding[] = [] + const reExportBindings: ReExportBinding[] = [] + const typeReExportBindings: TypeReExportBinding[] = [] + const exportAllPaths = new Set<string>() + const exportedValueNames = new Set<string>() + const localTypeDeclarations = new Map<string, TypeDeclarationNode>() + const localTypeExportBindings: TypeExportBinding[] = [] + const localFunctions = new Map<string, NamedFunctionNode>() + const exportedFunctions = new Set<string>() + let defaultExportFunction: NamedFunctionNode | null = null + let defaultExportBindingName: string | null = null + let hasDefaultExport = false + + const hasModifier = (node: { modifiers?: ts.NodeArray<ts.ModifierLike> }, kind: ts.SyntaxKind) => + Boolean(node.modifiers?.some((modifier) => modifier.kind === kind)) + + const addRuntimeImportEdge = ( + resolvedFilePath: string | null, + requestedExports: RequestedExports + ) => { + if (!resolvedFilePath) { + return + } + + runtimeImportEdges.set( + resolvedFilePath, + mergeRequestedExports(runtimeImportEdges.get(resolvedFilePath), requestedExports) + ) + } + + const registerTypeImportBinding = ( + localName: string, + importedName: string, + resolvedFilePath: string | null + ) => { + typeImportBindings.set(localName, { + importedName, + localName, + resolvedFilePath, + }) + } + + const registerLocalTypeExportBinding = (localName: string, exportedName: string) => { + localTypeExportBindings.push({ localName, exportedName }) + } + + const registerExportedValueName = (name: string | null) => { + if (!name) { + return + } + + if (name === 'default') { + hasDefaultExport = true + return + } + + exportedValueNames.add(name) + } + + const registerFunction = ( + name: string | null, + node: NamedFunctionNode, + namedExported: boolean + ) => { + if (!name) { + return + } + + localFunctions.set(name, node) + if (namedExported) { + exportedFunctions.add(name) + registerExportedValueName(name) + } + } + + const visit = (node: ts.Node) => { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + const importClause = node.importClause + const resolvedFilePath = resolveImportPath(projectRoot, filePath, node.moduleSpecifier.text) + + if (!importClause) { + addRuntimeImportEdge(resolvedFilePath, 'all') + ts.forEachChild(node, visit) + return + } + + if (importClause.isTypeOnly) { + if (importClause.name) { + registerTypeImportBinding(importClause.name.text, 'default', resolvedFilePath) + } + + if (importClause.namedBindings && ts.isNamedImports(importClause.namedBindings)) { + for (const binding of importClause.namedBindings.elements) { + const importedName = binding.propertyName?.text ?? binding.name.text + registerTypeImportBinding(binding.name.text, importedName, resolvedFilePath) + } + } + + ts.forEachChild(node, visit) + return + } + + if (importClause.name) { + addRuntimeImportEdge(resolvedFilePath, requestedExportsForName('default')) + importBindings.set(importClause.name.text, { + importedName: 'default', + localName: importClause.name.text, + resolvedFilePath, + }) + } + + if (importClause.namedBindings) { + if (ts.isNamespaceImport(importClause.namedBindings)) { + addRuntimeImportEdge(resolvedFilePath, 'all') + } else { + for (const binding of importClause.namedBindings.elements) { + if (binding.isTypeOnly) { + const importedName = binding.propertyName?.text ?? binding.name.text + registerTypeImportBinding(binding.name.text, importedName, resolvedFilePath) + continue + } + const importedName = binding.propertyName?.text ?? binding.name.text + addRuntimeImportEdge(resolvedFilePath, requestedExportsForName(importedName)) + importBindings.set(binding.name.text, { + importedName, + localName: binding.name.text, + resolvedFilePath, + }) + } + } + } + } + + if (ts.isExportDeclaration(node)) { + if (!node.moduleSpecifier) { + if (node.exportClause && ts.isNamedExports(node.exportClause)) { + for (const element of node.exportClause.elements) { + const localName = element.propertyName?.text ?? element.name.text + + if (node.isTypeOnly || element.isTypeOnly) { + registerLocalTypeExportBinding(localName, element.name.text) + continue + } + registerExportedValueName(element.name.text) + localExportBindings.push({ + localName, + exportedName: element.name.text, + }) + } + } + ts.forEachChild(node, visit) + return + } + + if (!ts.isStringLiteral(node.moduleSpecifier)) { + ts.forEachChild(node, visit) + return + } + + const resolvedFilePath = resolveImportPath(projectRoot, filePath, node.moduleSpecifier.text) + + if (!node.exportClause) { + if (!node.isTypeOnly && resolvedFilePath) { + exportAllPaths.add(resolvedFilePath) + } + ts.forEachChild(node, visit) + return + } + + if (ts.isNamedExports(node.exportClause)) { + for (const element of node.exportClause.elements) { + const importedName = element.propertyName?.text ?? element.name.text + + if (node.isTypeOnly || element.isTypeOnly) { + typeReExportBindings.push({ + importedName, + exportedName: element.name.text, + resolvedFilePath, + }) + continue + } + + registerExportedValueName(element.name.text) + reExportBindings.push({ + importedName, + exportedName: element.name.text, + resolvedFilePath, + }) + } + } + } + + if (ts.isFunctionDeclaration(node)) { + const exported = hasModifier(node, ts.SyntaxKind.ExportKeyword) + const defaultExported = hasModifier(node, ts.SyntaxKind.DefaultKeyword) + const namedExported = exported && !defaultExported + + registerFunction(node.name?.text ?? null, node, namedExported) + + if (defaultExported) { + hasDefaultExport = true + defaultExportFunction = node + } + } + + if (ts.isVariableStatement(node)) { + const exported = hasModifier(node, ts.SyntaxKind.ExportKeyword) + for (const declaration of node.declarationList.declarations) { + if (exported) { + for (const bindingName of collectBindingNames(declaration.name)) { + registerExportedValueName(bindingName) + } + } + + if (ts.isIdentifier(declaration.name) && declaration.initializer) { + const functionTarget = extractCallableInitializer(declaration.initializer) + if (functionTarget) { + registerFunction(declaration.name.text, functionTarget, exported) + } + } + } + } + + if (ts.isClassDeclaration(node)) { + const exported = hasModifier(node, ts.SyntaxKind.ExportKeyword) + const defaultExported = hasModifier(node, ts.SyntaxKind.DefaultKeyword) + if (exported && !defaultExported) { + registerExportedValueName(node.name?.text ?? null) + } + if (defaultExported) { + hasDefaultExport = true + } + } + + if (ts.isEnumDeclaration(node) && hasModifier(node, ts.SyntaxKind.ExportKeyword)) { + registerExportedValueName(node.name.text) + } + + if (ts.isTypeAliasDeclaration(node)) { + localTypeDeclarations.set(node.name.text, node) + + if (hasModifier(node, ts.SyntaxKind.ExportKeyword)) { + const exportedName = hasModifier(node, ts.SyntaxKind.DefaultKeyword) + ? 'default' + : node.name.text + registerLocalTypeExportBinding(node.name.text, exportedName) + } + } + + if (ts.isInterfaceDeclaration(node)) { + localTypeDeclarations.set(node.name.text, node) + + if (hasModifier(node, ts.SyntaxKind.ExportKeyword)) { + const exportedName = hasModifier(node, ts.SyntaxKind.DefaultKeyword) + ? 'default' + : node.name.text + registerLocalTypeExportBinding(node.name.text, exportedName) + } + } + + if (ts.isExportAssignment(node) && !node.isExportEquals) { + hasDefaultExport = true + const functionTarget = extractCallableInitializer(node.expression) + if (functionTarget) { + defaultExportFunction = functionTarget + } else if (ts.isIdentifier(node.expression)) { + defaultExportBindingName = node.expression.text + } + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) + + return { + filePath, + relativePath: toRelativeProjectPath(projectRoot, filePath), + sourceFile, + importBindings, + typeImportBindings, + runtimeImportEdges: [...runtimeImportEdges.entries()].map( + ([resolvedFilePath, requestedExports]) => ({ + resolvedFilePath, + requestedExports: cloneRequestedExports(requestedExports), + }) + ), + localExportBindings, + reExportBindings, + typeReExportBindings, + exportAllPaths: [...exportAllPaths], + exportedValueNames, + hasDefaultExport, + localTypeDeclarations, + localTypeExportBindings, + typeRoots: new Map(), + localFunctions, + exportedFunctions, + defaultExportFunction, + defaultExportBindingName, + } +} + +function getProjectFile(context: CatalogProjectContext, filePath: string) { + const cached = context.projectFiles.get(filePath) + if (cached) { + return cached + } + + if (!filePath.startsWith(context.projectRoot) || !isSourceFilePath(filePath)) { + return null + } + + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + return null + } + + const relativePath = toRelativeProjectPath(context.projectRoot, filePath) + if (isIgnoredProjectPath(relativePath)) { + return null + } + + const projectFile = collectProjectFile(context.projectRoot, filePath) + context.projectFiles.set(filePath, projectFile) + return projectFile +} + +function createBuiltinTypeRoots() { + return new Map<string, Descriptor>([ + ['Messages', rootDescriptor([])], + ['PublicCopy', rootDescriptor([])], + ]) +} + +function resolveNamedTypeDescriptor( + nameNode: ts.EntityName | ts.Expression, + availableTypes: Map<string, Descriptor> +) { + if (!ts.isIdentifier(nameNode)) { + return null + } + + const descriptor = availableTypes.get(nameNode.text) + return descriptor ? cloneDescriptor(descriptor) : null +} + +function resolveStructuralTypeDescriptor( + node: ts.TypeNode | TypeDeclarationNode | ts.ExpressionWithTypeArguments | undefined, + availableTypes: Map<string, Descriptor> +): Descriptor | null { + if (!node) { + return null + } + + if (ts.isTypeAliasDeclaration(node)) { + return resolveStructuralTypeDescriptor(node.type, availableTypes) + } + + if (ts.isInterfaceDeclaration(node)) { + const properties: Record<string, Descriptor> = {} + let resolvedAny = false + + for (const heritageClause of node.heritageClauses ?? []) { + if (heritageClause.token !== ts.SyntaxKind.ExtendsKeyword) { + continue + } + + for (const typeNode of heritageClause.types) { + const baseDescriptor = resolveStructuralTypeDescriptor(typeNode, availableTypes) + if (baseDescriptor?.kind !== 'object') { + continue + } + + resolvedAny = true + for (const [propertyName, propertyDescriptor] of Object.entries( + baseDescriptor.properties + )) { + properties[propertyName] = cloneDescriptor(propertyDescriptor) + } + } + } + + for (const member of node.members) { + if ( + !ts.isPropertySignature(member) || + !member.type || + ts.isComputedPropertyName(member.name) + ) { + continue + } + + const propertyName = getLiteralPropertyName(member.name) + if (!propertyName) { + continue + } + + const propertyDescriptor = resolveStructuralTypeDescriptor(member.type, availableTypes) + if (!propertyDescriptor) { + continue + } + + properties[propertyName] = propertyDescriptor + resolvedAny = true + } + + return resolvedAny ? objectDescriptor(properties) : null + } + + if (ts.isExpressionWithTypeArguments(node)) { + return resolveNamedTypeDescriptor(node.expression, availableTypes) + } + + if (ts.isTypeReferenceNode(node)) { + return resolveNamedTypeDescriptor(node.typeName, availableTypes) + } + + if (ts.isIndexedAccessTypeNode(node)) { + const objectTypeDescriptor = resolveStructuralTypeDescriptor(node.objectType, availableTypes) + const indexType = node.indexType + if ( + !objectTypeDescriptor || + !ts.isLiteralTypeNode(indexType) || + !ts.isStringLiteral(indexType.literal) + ) { + return null + } + return readPropertyDescriptor(objectTypeDescriptor, indexType.literal.text) + } + + if (ts.isTypeLiteralNode(node)) { + const properties: Record<string, Descriptor> = {} + let resolvedAny = false + + for (const member of node.members) { + if ( + !ts.isPropertySignature(member) || + !member.type || + ts.isComputedPropertyName(member.name) + ) { + continue + } + + const propertyName = getLiteralPropertyName(member.name) + if (!propertyName) { + continue + } + + const propertyDescriptor = resolveStructuralTypeDescriptor(member.type, availableTypes) + if (!propertyDescriptor) { + continue + } + + properties[propertyName] = propertyDescriptor + resolvedAny = true + } + + return resolvedAny ? objectDescriptor(properties) : null + } + + return null +} + +function buildTypeRootsForFile( + file: ProjectFile, + exportedTypeDescriptorsByFile: Map<string, Map<string, Descriptor>> +) { + const typeRoots = createBuiltinTypeRoots() + + for (const binding of file.typeImportBindings.values()) { + if (!binding.resolvedFilePath) { + continue + } + + const exportedTypes = exportedTypeDescriptorsByFile.get(binding.resolvedFilePath) + const descriptor = exportedTypes?.get(binding.importedName) + if (descriptor) { + typeRoots.set(binding.localName, cloneDescriptor(descriptor)) + } + } + + let changed = true + while (changed) { + changed = false + + for (const [name, declaration] of file.localTypeDeclarations.entries()) { + const descriptor = resolveStructuralTypeDescriptor(declaration, typeRoots) + if (!descriptor) { + continue + } + + const existing = typeRoots.get(name) ?? null + if (!sameDescriptor(existing, descriptor)) { + typeRoots.set(name, cloneDescriptor(descriptor)) + changed = true + } + } + } + + return typeRoots +} + +function buildExportedTypeDescriptorsForFile( + file: ProjectFile, + typeRoots: Map<string, Descriptor>, + exportedTypeDescriptorsByFile: Map<string, Map<string, Descriptor>> +) { + const exportedTypes = new Map<string, Descriptor>() + + for (const binding of file.localTypeExportBindings) { + const descriptor = typeRoots.get(binding.localName) + if (!descriptor) { + continue + } + + exportedTypes.set(binding.exportedName, cloneDescriptor(descriptor)) + } + + for (const binding of file.typeReExportBindings) { + if (!binding.resolvedFilePath) { + continue + } + + const sourceTypes = exportedTypeDescriptorsByFile.get(binding.resolvedFilePath) + const descriptor = sourceTypes?.get(binding.importedName) + if (descriptor) { + exportedTypes.set(binding.exportedName, cloneDescriptor(descriptor)) + } + } + + return exportedTypes +} + +function populateProjectFileTypeRoots(projectFiles: Map<string, ProjectFile>) { + const typeRootsByFile = new Map<string, Map<string, Descriptor>>() + const exportedTypeDescriptorsByFile = new Map<string, Map<string, Descriptor>>() + + let changed = true + while (changed) { + changed = false + + for (const [filePath, file] of projectFiles.entries()) { + const nextTypeRoots = buildTypeRootsForFile(file, exportedTypeDescriptorsByFile) + if (!descriptorMapsEqual(typeRootsByFile.get(filePath), nextTypeRoots)) { + typeRootsByFile.set(filePath, nextTypeRoots) + changed = true + } + + const nextExportedTypes = buildExportedTypeDescriptorsForFile( + file, + nextTypeRoots, + exportedTypeDescriptorsByFile + ) + if (!descriptorMapsEqual(exportedTypeDescriptorsByFile.get(filePath), nextExportedTypes)) { + exportedTypeDescriptorsByFile.set(filePath, nextExportedTypes) + changed = true + } + } + } + + for (const [filePath, file] of projectFiles.entries()) { + file.typeRoots = typeRootsByFile.get(filePath) ?? createBuiltinTypeRoots() + } +} + +function inferFileSemantics( + file: ProjectFile, + importedSemantics: Map<string, Descriptor>, + importedCallableDescriptors: Map<string, Descriptor> +) { + const semantics = new Map<string, Descriptor>() + + let changed = true + while (changed) { + changed = false + for (const [name, node] of file.localFunctions.entries()) { + const descriptor = inferFunctionDescriptor( + node, + file, + semantics, + importedSemantics, + importedCallableDescriptors + ) + if (!descriptor) { + continue + } + const existing = semantics.get(name) ?? null + if (!sameDescriptor(existing, descriptor)) { + semantics.set(name, descriptor) + changed = true + } + } + } + + return semantics +} + +function inferFunctionDescriptor( + node: NamedFunctionNode, + file: ProjectFile, + localSemantics: FileSemantics, + importedSemantics: Map<string, Descriptor>, + importedCallableDescriptors: Map<string, Descriptor> +) { + const scope = createFunctionScope(createRootScope(), node) + bindFunctionParameters(scope, node, file, localSemantics, importedSemantics) + + if (!node.body) { + return null + } + + if (!ts.isBlock(node.body)) { + return resolveExpressionDescriptor( + node.body, + file, + scope, + localSemantics, + importedSemantics, + importedCallableDescriptors + ) + } + + const descriptors: Descriptor[] = [] + + const visit = (currentNode: ts.Node) => { + if (currentNode !== node.body && isFunctionLike(currentNode)) { + return + } + + if (ts.isReturnStatement(currentNode) && currentNode.expression) { + const descriptor = resolveExpressionDescriptor( + currentNode.expression, + file, + scope, + localSemantics, + importedSemantics, + importedCallableDescriptors + ) + if (descriptor) { + descriptors.push(descriptor) + } + } + + ts.forEachChild(currentNode, visit) + } + + visit(node.body) + + if (descriptors.length === 0) { + return null + } + + const [firstDescriptor] = descriptors + if (descriptors.every((descriptor) => sameDescriptor(firstDescriptor, descriptor))) { + return cloneDescriptor(firstDescriptor!) + } + + return null +} + +function createRootScope(): Scope { + const scope: Scope = { + kind: 'root', + parent: null, + hoistTarget: null, + bindings: new Map(), + translatorHints: [], + rootHints: [], + currentFunction: null, + inMetadata: false, + } + scope.hoistTarget = scope + return scope +} + +function dedupeNullableStrings(values: Array<string | null>) { + const seen = new Set<string>() + const deduped: Array<string | null> = [] + + for (const value of values) { + const key = value ?? '__null__' + if (seen.has(key)) { + continue + } + seen.add(key) + deduped.push(value) + } + + return deduped +} + +function createFunctionScope(parent: Scope, node: ts.FunctionLikeDeclaration): Scope { + const scope: Scope = { + kind: 'function', + parent, + hoistTarget: null, + bindings: new Map(), + translatorHints: [], + rootHints: [], + currentFunction: node, + inMetadata: + parent.inMetadata || + (ts.isFunctionDeclaration(node) && node.name?.text === 'generateMetadata'), + } + scope.hoistTarget = scope + return scope +} + +function createBlockScope(parent: Scope, options?: { inMetadata?: boolean }): Scope { + return { + kind: 'block', + parent, + hoistTarget: parent.hoistTarget, + bindings: new Map(), + translatorHints: [], + rootHints: [], + currentFunction: parent.currentFunction, + inMetadata: options?.inMetadata ?? parent.inMetadata, + } +} + +function lookupBinding(scope: Scope, name: string): Descriptor | null { + let current: Scope | null = scope + while (current) { + const descriptor = current.bindings.get(name) + if (descriptor) { + return cloneDescriptor(descriptor) + } + current = current.parent + } + return null +} + +function findTranslatorHint( + scope: Scope, + predicate: (hint: TranslatorHint) => boolean = () => true +): TranslatorHint | null { + let current: Scope | null = scope + while (current) { + for (let index = current.translatorHints.length - 1; index >= 0; index -= 1) { + const hint = current.translatorHints[index]! + if (predicate(hint)) { + return { + name: hint.name, + namespace: [...hint.namespace], + } + } + } + current = current.parent + } + return null +} + +function findRootHint( + scope: Scope, + predicate: (hint: RootHint) => boolean = () => true +): RootHint | null { + let current: Scope | null = scope + while (current) { + for (let index = current.rootHints.length - 1; index >= 0; index -= 1) { + const hint = current.rootHints[index]! + if (predicate(hint)) { + return { + name: hint.name, + path: [...hint.path], + } + } + } + current = current.parent + } + return null +} + +function isBlockScopedDeclarationList(node: ts.VariableDeclarationList) { + return (node.flags & ts.NodeFlags.BlockScoped) !== 0 +} + +function bindVariableDeclaration( + scope: Scope, + node: ts.VariableDeclaration, + descriptor: Descriptor +) { + const declarationList = ts.isVariableDeclarationList(node.parent) ? node.parent : null + const targetScope = + declarationList && !isBlockScopedDeclarationList(declarationList) + ? (scope.hoistTarget ?? scope) + : scope + bindPattern(targetScope, node.name, descriptor) +} + +function bindFunctionParameters( + scope: Scope, + node: ts.FunctionLikeDeclaration, + file: ProjectFile, + localSemantics: FileSemantics, + importedSemantics: Map<string, Descriptor>, + argumentDescriptors: ArgumentDescriptor[] = [] +) { + const descriptorByIndex = new Map( + argumentDescriptors.map((argumentDescriptor) => [ + argumentDescriptor.index, + cloneDescriptor(argumentDescriptor.descriptor), + ]) + ) + + for (const [index, parameter] of node.parameters.entries()) { + const descriptor = + descriptorByIndex.get(index) ?? + resolveTypeDescriptor(parameter.type, file, localSemantics, importedSemantics) + if (!descriptor) { + continue + } + bindPattern(scope, parameter.name, descriptor) + } +} + +function resolveTypeQueryDescriptor( + node: ts.TypeQueryNode, + _file: ProjectFile, + localSemantics: FileSemantics, + importedSemantics: Map<string, Descriptor> +): Descriptor | null { + if (!ts.isIdentifier(node.exprName)) { + return null + } + + const localDescriptor = localSemantics.get(node.exprName.text) + if (localDescriptor) { + return cloneDescriptor(localDescriptor) + } + + const importedDescriptor = importedSemantics.get(node.exprName.text) + return importedDescriptor ? cloneDescriptor(importedDescriptor) : null +} + +function resolveInterfaceDescriptor( + node: ts.InterfaceDeclaration, + file: ProjectFile, + localSemantics: FileSemantics, + importedSemantics: Map<string, Descriptor>, + seenTypes: Set<string> +) { + const properties: Record<string, Descriptor> = {} + let resolvedAny = false + + for (const heritageClause of node.heritageClauses ?? []) { + if (heritageClause.token !== ts.SyntaxKind.ExtendsKeyword) { + continue + } + + for (const typeNode of heritageClause.types) { + const baseDescriptor = resolveTypeDescriptor( + typeNode, + file, + localSemantics, + importedSemantics, + seenTypes + ) + if (baseDescriptor?.kind !== 'object') { + continue + } + + resolvedAny = true + for (const [propertyName, propertyDescriptor] of Object.entries(baseDescriptor.properties)) { + properties[propertyName] = cloneDescriptor(propertyDescriptor) + } + } + } + + for (const member of node.members) { + if (!ts.isPropertySignature(member) || !member.type || ts.isComputedPropertyName(member.name)) { + continue + } + + const propertyName = getLiteralPropertyName(member.name) + if (!propertyName) { + continue + } + + const propertyDescriptor = resolveTypeDescriptor( + member.type, + file, + localSemantics, + importedSemantics, + seenTypes + ) + if (!propertyDescriptor) { + continue + } + + properties[propertyName] = propertyDescriptor + resolvedAny = true + } + + return resolvedAny ? objectDescriptor(properties) : null +} + +function resolveTypeLiteralDescriptor( + node: ts.TypeLiteralNode, + file: ProjectFile, + localSemantics: FileSemantics, + importedSemantics: Map<string, Descriptor>, + seenTypes: Set<string> +) { + const properties: Record<string, Descriptor> = {} + let resolvedAny = false + + for (const member of node.members) { + if (!ts.isPropertySignature(member) || !member.type || ts.isComputedPropertyName(member.name)) { + continue + } + + const propertyName = getLiteralPropertyName(member.name) + if (!propertyName) { + continue + } + + const propertyDescriptor = resolveTypeDescriptor( + member.type, + file, + localSemantics, + importedSemantics, + seenTypes + ) + if (!propertyDescriptor) { + continue + } + + properties[propertyName] = propertyDescriptor + resolvedAny = true + } + + return resolvedAny ? objectDescriptor(properties) : null +} + +function resolveLocalTypeDeclarationDescriptor( + name: string, + file: ProjectFile, + localSemantics: FileSemantics, + importedSemantics: Map<string, Descriptor>, + seenTypes: Set<string> +): Descriptor | null { + const declaration = file.localTypeDeclarations.get(name) + if (!declaration) { + return null + } + + const typeKey = `${file.filePath}:${name}` + if (seenTypes.has(typeKey)) { + return null + } + + seenTypes.add(typeKey) + const descriptor = resolveTypeDescriptor( + declaration, + file, + localSemantics, + importedSemantics, + seenTypes + ) + seenTypes.delete(typeKey) + return descriptor +} + +function resolveTypeDescriptor( + typeNode: ts.TypeNode | TypeDeclarationNode | ts.ExpressionWithTypeArguments | undefined, + file: ProjectFile, + localSemantics: FileSemantics, + importedSemantics: Map<string, Descriptor>, + seenTypes = new Set<string>() +): Descriptor | null { + if (!typeNode) { + return null + } + + if (ts.isTypeAliasDeclaration(typeNode)) { + return resolveTypeDescriptor(typeNode.type, file, localSemantics, importedSemantics, seenTypes) + } + + if (ts.isInterfaceDeclaration(typeNode)) { + return resolveInterfaceDescriptor(typeNode, file, localSemantics, importedSemantics, seenTypes) + } + + if (ts.isParenthesizedTypeNode(typeNode)) { + return resolveTypeDescriptor(typeNode.type, file, localSemantics, importedSemantics, seenTypes) + } + + if (ts.isTypeQueryNode(typeNode)) { + return resolveTypeQueryDescriptor(typeNode, file, localSemantics, importedSemantics) + } + + if (ts.isExpressionWithTypeArguments(typeNode)) { + if (!ts.isIdentifier(typeNode.expression)) { + return null + } + + const localDescriptor = resolveLocalTypeDeclarationDescriptor( + typeNode.expression.text, + file, + localSemantics, + importedSemantics, + seenTypes + ) + if (localDescriptor) { + return localDescriptor + } + } + + if (ts.isTypeReferenceNode(typeNode)) { + if ( + ts.isIdentifier(typeNode.typeName) && + typeNode.typeName.text === 'ReturnType' && + typeNode.typeArguments?.length === 1 + ) { + const [typeArgument] = typeNode.typeArguments + if (typeArgument && ts.isTypeQueryNode(typeArgument)) { + return resolveTypeQueryDescriptor(typeArgument, file, localSemantics, importedSemantics) + } + } + + if (ts.isIdentifier(typeNode.typeName)) { + const localDescriptor = resolveLocalTypeDeclarationDescriptor( + typeNode.typeName.text, + file, + localSemantics, + importedSemantics, + seenTypes + ) + if (localDescriptor) { + return localDescriptor + } + } + } + + if (ts.isIndexedAccessTypeNode(typeNode)) { + const objectTypeDescriptor = resolveTypeDescriptor( + typeNode.objectType, + file, + localSemantics, + importedSemantics, + seenTypes + ) + const propertyKey = getTypeLiteralPropertyKey(typeNode.indexType) + + if (!objectTypeDescriptor || !propertyKey) { + return null + } + + return readPropertyDescriptor(objectTypeDescriptor, propertyKey) + } + + if (ts.isTypeLiteralNode(typeNode)) { + return resolveTypeLiteralDescriptor( + typeNode, + file, + localSemantics, + importedSemantics, + seenTypes + ) + } + + return resolveStructuralTypeDescriptor(typeNode, file.typeRoots) +} + +function isPropertyAccessLikeExpression( + node: ts.Node +): node is ts.PropertyAccessExpression | ts.PropertyAccessChain { + return ts.isPropertyAccessExpression(node) || ts.isPropertyAccessChain(node) +} + +function isElementAccessLikeExpression( + node: ts.Node +): node is ts.ElementAccessExpression | ts.ElementAccessChain { + return ts.isElementAccessExpression(node) || ts.isElementAccessChain(node) +} + +function isCallLikeExpression(node: ts.Node): node is ts.CallExpression | ts.CallChain { + return ts.isCallExpression(node) || ts.isCallChain(node) +} + +function getAccessedPropertyName( + node: + | ts.PropertyAccessExpression + | ts.PropertyAccessChain + | ts.ElementAccessExpression + | ts.ElementAccessChain +) { + if (isPropertyAccessLikeExpression(node)) { + return node.name.text + } + + const argument = node.argumentExpression ? unwrapExpression(node.argumentExpression) : null + return argument ? getStaticPropertyKey(argument) : null +} + +function resolveExpressionDescriptor( + expression: ts.Expression, + file: ProjectFile, + scope: Scope, + localSemantics: FileSemantics, + importedSemantics: Map<string, Descriptor>, + importedCallableDescriptors: Map<string, Descriptor> +): Descriptor | null { + const node = unwrapExpression(expression) + const callableNode = extractCallableInitializer(node) + + if (callableNode) { + return callableDescriptor(file.filePath, callableNode, captureClosureScope(scope)) + } + + if (ts.isIdentifier(node)) { + const bindingDescriptor = lookupBinding(scope, node.text) + if (bindingDescriptor) { + return bindingDescriptor + } + + const localFunction = file.localFunctions.get(node.text) + if (localFunction) { + return callableDescriptor(file.filePath, localFunction, captureClosureScope(scope)) + } + + const importedCallableDescriptor = importedCallableDescriptors.get(node.text) + if (importedCallableDescriptor) { + return cloneDescriptor(importedCallableDescriptor) + } + + return null + } + + if (isPropertyAccessLikeExpression(node)) { + const parentDescriptor = resolveExpressionDescriptor( + node.expression, + file, + scope, + localSemantics, + importedSemantics, + importedCallableDescriptors + ) + return parentDescriptor ? readPropertyDescriptor(parentDescriptor, node.name.text) : null + } + + if (isElementAccessLikeExpression(node)) { + const argument = node.argumentExpression ? unwrapExpression(node.argumentExpression) : null + const propertyName = argument ? getStaticPropertyKey(argument) : null + if (!propertyName) { + return null + } + const parentDescriptor = resolveExpressionDescriptor( + node.expression, + file, + scope, + localSemantics, + importedSemantics, + importedCallableDescriptors + ) + return parentDescriptor ? readPropertyDescriptor(parentDescriptor, propertyName) : null + } + + if (isCallLikeExpression(node)) { + return resolveCallDescriptor(node, file, scope, localSemantics, importedSemantics) + } + + if (ts.isObjectLiteralExpression(node)) { + const properties: Record<string, Descriptor> = {} + for (const property of node.properties) { + if (ts.isPropertyAssignment(property)) { + const propertyName = getLiteralPropertyName(property.name) + if (!propertyName) continue + const propertyDescriptor = resolveExpressionDescriptor( + property.initializer, + file, + scope, + localSemantics, + importedSemantics, + importedCallableDescriptors + ) + if (propertyDescriptor) { + properties[propertyName] = propertyDescriptor + } + } + + if (ts.isShorthandPropertyAssignment(property)) { + const propertyDescriptor = resolveExpressionDescriptor( + property.name, + file, + scope, + localSemantics, + importedSemantics, + importedCallableDescriptors + ) + if (propertyDescriptor) { + properties[property.name.text] = propertyDescriptor + } + } + } + return Object.keys(properties).length > 0 ? objectDescriptor(properties) : null + } + + if (ts.isConditionalExpression(node)) { + const whenTrue = resolveExpressionDescriptor( + node.whenTrue, + file, + scope, + localSemantics, + importedSemantics, + importedCallableDescriptors + ) + const whenFalse = resolveExpressionDescriptor( + node.whenFalse, + file, + scope, + localSemantics, + importedSemantics, + importedCallableDescriptors + ) + return sameDescriptor(whenTrue, whenFalse) ? whenTrue : null + } + + return null +} + +function resolveCallDescriptor( + node: ts.CallExpression | ts.CallChain, + file: ProjectFile, + scope: Scope, + localSemantics: FileSemantics, + importedSemantics: Map<string, Descriptor> +) { + const callee = unwrapExpression(node.expression) + + if (ts.isIdentifier(callee)) { + const localBinding = lookupBinding(scope, callee.text) + if (localBinding?.kind === 'translator') { + return localBinding + } + + if (callee.text === 'useMessages' || callee.text === 'getPublicCopy') { + return rootDescriptor([]) + } + + if (callee.text === 'useTranslations' || callee.text === 'getTranslations') { + const firstArgument = node.arguments[0] ? unwrapExpression(node.arguments[0]!) : null + const namespace = firstArgument ? (getStaticTextValue(firstArgument)?.split('.') ?? []) : [] + return translatorDescriptor(namespace) + } + + if (callee.text === 'createTranslator') { + const firstArgument = node.arguments[0] + if (firstArgument && ts.isObjectLiteralExpression(unwrapExpression(firstArgument))) { + const objectExpression = unwrapExpression(firstArgument) as ts.ObjectLiteralExpression + for (const property of objectExpression.properties) { + if ( + !ts.isPropertyAssignment(property) || + getLiteralPropertyName(property.name) !== 'namespace' + ) { + continue + } + const value = getStaticTextValue(unwrapExpression(property.initializer)) + if (value) { + return translatorDescriptor(value.split('.')) + } + } + } + return null + } + + const localFunctionDescriptor = localSemantics.get(callee.text) + if (localFunctionDescriptor) { + return cloneDescriptor(localFunctionDescriptor) + } + + const importedFunctionDescriptor = importedSemantics.get(callee.text) + if (importedFunctionDescriptor) { + return cloneDescriptor(importedFunctionDescriptor) + } + } + + return null +} + +function isFunctionLike(node: ts.Node): node is ts.FunctionLikeDeclaration { + return ( + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node) || + ts.isMethodDeclaration(node) + ) +} + +function isNestedLocalFunction(node: NamedFunctionNode) { + let current: ts.Node | undefined = node.parent + + while (current) { + if (ts.isSourceFile(current)) { + return false + } + + if (isFunctionLike(current)) { + return true + } + + current = current.parent + } + + return false +} + +function captureClosureScope(scope: Scope) { + const closureScope = createRootScope() + const seenNames = new Set<string>() + let current: Scope | null = scope + + while (current) { + for (const [name, descriptor] of current.bindings.entries()) { + if (seenNames.has(name)) { + continue + } + + seenNames.add(name) + closureScope.bindings.set(name, descriptor) + addScopeHints(closureScope, name, descriptor) + } + + current = current.parent + } + + closureScope.inMetadata = scope.inMetadata + return closureScope +} + +function bindPattern(scope: Scope, bindingName: ts.BindingName, descriptor: Descriptor) { + if (ts.isIdentifier(bindingName)) { + scope.bindings.set(bindingName.text, cloneDescriptor(descriptor)) + addScopeHints(scope, bindingName.text, descriptor) + return + } + + if (!ts.isObjectBindingPattern(bindingName) || descriptor.kind !== 'object') { + return + } + + for (const element of bindingName.elements) { + if (element.dotDotDotToken) { + continue + } + const propertyName = + element.propertyName && ts.isIdentifier(element.propertyName) + ? element.propertyName.text + : ts.isIdentifier(element.name) + ? element.name.text + : null + if (!propertyName) { + continue + } + const propertyDescriptor = descriptor.properties[propertyName] + if (!propertyDescriptor) { + continue + } + bindPattern(scope, element.name, propertyDescriptor) + } +} + +function addScopeHints(scope: Scope, name: string, descriptor: Descriptor) { + if (descriptor.kind === 'translator' && descriptor.namespace.length > 0) { + scope.translatorHints.push({ name, namespace: descriptor.namespace }) + return + } + + if (descriptor.kind === 'root' && descriptor.path.length > 0 && ROOT_HINT_NAME.test(name)) { + scope.rootHints.push({ name, path: descriptor.path }) + } +} + +function getNodeLocation(sourceFile: ts.SourceFile, position: number) { + const { line, character } = sourceFile.getLineAndCharacterOfPosition(position) + return { line: line + 1, column: character + 1 } +} + +function isRouteAdjacentCandidateFile( + filePath: string, + activeRoutePath: string | null, + context: ScanContext +) { + if (!activeRoutePath) { + return false + } + + if (!isPathInsideDirectory(filePath, context.entryDiscoveryContext.appRoot)) { + return false + } + + const owningRoutePath = resolveAppRoutePathForFile(context.entryDiscoveryContext, filePath) + return Boolean(owningRoutePath && isRoutePathPrefix(owningRoutePath, activeRoutePath)) +} + +function buildImportedSemanticsMap( + file: ProjectFile, + _projectFiles: Map<string, ProjectFile>, + globalSemantics: Map<string, Map<string, Descriptor>> +) { + const importedSemantics = new Map<string, Descriptor>() + + for (const binding of file.importBindings.values()) { + if (!binding.resolvedFilePath) { + continue + } + const fileSemantics = globalSemantics.get(binding.resolvedFilePath) + const descriptor = fileSemantics?.get(binding.importedName) + if (descriptor) { + importedSemantics.set(binding.localName, cloneDescriptor(descriptor)) + } + + if (!descriptor && binding.importedName === 'default') { + const defaultDescriptor = fileSemantics?.get('default') + if (defaultDescriptor) { + importedSemantics.set(binding.localName, cloneDescriptor(defaultDescriptor)) + } + } + } + + return importedSemantics +} + +function buildImportedCallableDescriptorMap( + file: ProjectFile, + projectFiles: Map<string, ProjectFile> +) { + const importedCallableDescriptors = new Map<string, Descriptor>() + + for (const binding of file.importBindings.values()) { + if (!binding.resolvedFilePath) { + continue + } + + const importedFile = projectFiles.get(binding.resolvedFilePath) + if (!importedFile) { + continue + } + + const target = resolveExportedFunctionTarget(projectFiles, importedFile, binding.importedName) + if (!target) { + continue + } + + importedCallableDescriptors.set( + binding.localName, + callableDescriptor(target.targetFile.filePath, target.targetNode, createRootScope()) + ) + } + + return importedCallableDescriptors +} + +function buildGlobalFunctionSemantics(projectFiles: Map<string, ProjectFile>) { + const semanticsByFile = new Map<string, Map<string, Descriptor>>() + + let changed = true + while (changed) { + changed = false + for (const [filePath, file] of projectFiles.entries()) { + const importedSemantics = buildImportedSemanticsMap(file, projectFiles, semanticsByFile) + const importedCallableDescriptors = buildImportedCallableDescriptorMap(file, projectFiles) + const fileSemantics = inferFileSemantics(file, importedSemantics, importedCallableDescriptors) + const exportedSemantics = new Map<string, Descriptor>() + + for (const name of file.exportedFunctions) { + const descriptor = fileSemantics.get(name) + if (descriptor) { + exportedSemantics.set(name, cloneDescriptor(descriptor)) + } + } + + if (file.defaultExportFunction) { + const defaultDescriptor = inferFunctionDescriptor( + file.defaultExportFunction, + file, + fileSemantics, + importedSemantics, + importedCallableDescriptors + ) + if (defaultDescriptor) { + exportedSemantics.set('default', cloneDescriptor(defaultDescriptor)) + } + } + + for (const binding of file.localExportBindings) { + const descriptor = + fileSemantics.get(binding.localName) ?? importedSemantics.get(binding.localName) ?? null + if (descriptor) { + exportedSemantics.set(binding.exportedName, cloneDescriptor(descriptor)) + } + } + + for (const binding of file.reExportBindings) { + if (!binding.resolvedFilePath) { + continue + } + + const sourceSemantics = semanticsByFile.get(binding.resolvedFilePath) + const descriptor = sourceSemantics?.get(binding.importedName) + if (descriptor) { + exportedSemantics.set(binding.exportedName, cloneDescriptor(descriptor)) + } + } + + for (const exportAllPath of file.exportAllPaths) { + const sourceSemantics = semanticsByFile.get(exportAllPath) + if (!sourceSemantics) { + continue + } + + for (const [name, descriptor] of sourceSemantics.entries()) { + if (name === 'default' || exportedSemantics.has(name)) { + continue + } + exportedSemantics.set(name, cloneDescriptor(descriptor)) + } + } + + const existing = semanticsByFile.get(filePath) ?? new Map<string, Descriptor>() + const nextKey = [...exportedSemantics.entries()] + .map(([name, descriptor]) => `${name}:${descriptorToPathKey(descriptor)}`) + .sort() + .join('|') + const existingKey = [...existing.entries()] + .map(([name, descriptor]) => `${name}:${descriptorToPathKey(descriptor)}`) + .sort() + .join('|') + + if (nextKey !== existingKey) { + semanticsByFile.set(filePath, exportedSemantics) + changed = true + } + } + } + + return semanticsByFile +} + +export function createCatalogProjectContext(projectRoot: string): CatalogProjectContext { + return { + entryDiscoveryContext: createEntryDiscoveryContext(projectRoot), + projectFiles: new Map(), + projectRoot, + } +} + +function fileMayExportName( + filePath: string, + exportName: string, + context: CatalogProjectContext, + seen = new Set<string>() +) { + if (seen.has(filePath)) { + return false + } + + seen.add(filePath) + + const projectFile = getProjectFile(context, filePath) + if (!projectFile) { + return false + } + + if (projectFile.exportedValueNames.has(exportName)) { + return true + } + + for (const exportAllPath of projectFile.exportAllPaths) { + if (fileMayExportName(exportAllPath, exportName, context, seen)) { + return true + } + } + + return false +} + +function collectReachableFiles(entryFiles: string[], context: CatalogProjectContext) { + const visited = new Set<string>() + const requestedByFile = new Map<string, RequestedExports>() + const processedByFile = new Map<string, RequestedExports>() + const pending: PendingReachability[] = [] + + const enqueue = (filePath: string, requestedExports: RequestedExports) => { + if (!getProjectFile(context, filePath)) { + return + } + + const currentRequest = requestedByFile.get(filePath) + if (requestedExportsCover(currentRequest, requestedExports)) { + return + } + + const mergedRequest = mergeRequestedExports(currentRequest, requestedExports) + requestedByFile.set(filePath, mergedRequest) + pending.push({ + filePath, + requestedExports: cloneRequestedExports(mergedRequest), + }) + } + + for (const entryFile of entryFiles) { + enqueue(entryFile, 'all') + } + + while (pending.length > 0) { + const { filePath: currentFilePath } = pending.pop()! + const requestedExports = requestedByFile.get(currentFilePath) + if (!requestedExports) { + continue + } + + const processedRequest = processedByFile.get(currentFilePath) + if (requestedExportsCover(processedRequest, requestedExports)) { + continue + } + + processedByFile.set(currentFilePath, cloneRequestedExports(requestedExports)) + visited.add(currentFilePath) + + const projectFile = getProjectFile(context, currentFilePath) + if (!projectFile) { + continue + } + + for (const runtimeImportEdge of projectFile.runtimeImportEdges) { + enqueue(runtimeImportEdge.resolvedFilePath, runtimeImportEdge.requestedExports) + } + + if (requestedExports === 'all') { + for (const binding of projectFile.reExportBindings) { + if (!binding.resolvedFilePath) { + continue + } + enqueue(binding.resolvedFilePath, requestedExportsForName(binding.importedName)) + } + + for (const exportAllPath of projectFile.exportAllPaths) { + enqueue(exportAllPath, 'all') + } + + continue + } + + const satisfiedNames = new Set(projectFile.exportedValueNames) + if (projectFile.hasDefaultExport) { + satisfiedNames.add('default') + } + + for (const binding of projectFile.reExportBindings) { + if (!requestedExports.has(binding.exportedName)) { + continue + } + + satisfiedNames.add(binding.exportedName) + + if (binding.resolvedFilePath) { + enqueue(binding.resolvedFilePath, requestedExportsForName(binding.importedName)) + } + } + + for (const requestedName of requestedExports) { + if (requestedName === 'default' || satisfiedNames.has(requestedName)) { + continue + } + + for (const exportAllPath of projectFile.exportAllPaths) { + if (!fileMayExportName(exportAllPath, requestedName, context)) { + continue + } + + enqueue(exportAllPath, requestedExportsForName(requestedName)) + satisfiedNames.add(requestedName) + break + } + } + } + + return [...visited].sort() +} + +function buildAnalysisProjectFiles(context: CatalogProjectContext, runtimeFilePaths: string[]) { + const visited = new Set<string>() + const pending = [...runtimeFilePaths] + + while (pending.length > 0) { + const filePath = pending.pop()! + if (visited.has(filePath)) { + continue + } + + const projectFile = getProjectFile(context, filePath) + if (!projectFile) { + continue + } + + visited.add(filePath) + + for (const binding of projectFile.typeImportBindings.values()) { + if (binding.resolvedFilePath) { + pending.push(binding.resolvedFilePath) + } + } + + for (const binding of projectFile.typeReExportBindings) { + if (binding.resolvedFilePath) { + pending.push(binding.resolvedFilePath) + } + } + + for (const binding of projectFile.reExportBindings) { + if (binding.resolvedFilePath) { + pending.push(binding.resolvedFilePath) + } + } + + for (const exportAllPath of projectFile.exportAllPaths) { + pending.push(exportAllPath) + } + } + + return new Map( + [...visited].sort().map((filePath) => [filePath, context.projectFiles.get(filePath)!] as const) + ) +} + +function resolveNamespaceHint( + scope: Scope, + filePath: string, + context: ScanContext, + metadata: boolean, + activeRoutePath: string | null +) { + const translatorHint = findTranslatorHint(scope, (hint) => hint.namespace.length > 0) + if (translatorHint) { + return { + namespace: translatorHint.namespace.join('.'), + source: 'static' as const, + } + } + + const rootHint = findRootHint(scope, (hint) => hint.path.length > 0) + if (rootHint) { + return { + namespace: rootHint.path.join('.'), + source: 'static' as const, + } + } + + const routePath = + activeRoutePath ?? + context.routePath ?? + resolveOwningRoutePathForFile(context.entryDiscoveryContext, filePath) + if (!routePath) { + return null + } + + const namespace = deriveRouteNamespace(routePath, { metadata }) + return { + namespace, + source: + activeRoutePath === null && context.routePath === null + ? ('ownership' as const) + : getRouteOwnedNamespaces(routePath).includes(namespace) + ? ('ownership' as const) + : ('fallback' as const), + } +} + +function isUiStringLiteral(text: string) { + const trimmed = text.trim() + if (!trimmed) { + return false + } + if (!/[\p{L}\p{N}]/u.test(trimmed)) { + return false + } + if (/^(https?:\/\/|mailto:|tel:|\/|#)/.test(trimmed)) { + return false + } + if (/^[A-Z0-9_-]+$/.test(trimmed) && !/\s/.test(trimmed)) { + return false + } + if (/^[a-z0-9_.-]+$/.test(trimmed) && !/\s/.test(trimmed)) { + return false + } + return true +} + +function slugifyCopyText(text: string) { + const words = text + .trim() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .split(/\s+/) + .filter(Boolean) + if (words.length === 0) { + return 'copy' + } + const [firstWord, ...restWords] = words + return [ + firstWord!.toLowerCase(), + ...restWords.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()), + ].join('') +} + +function shouldCapturePropertyAccess(node: ts.Node) { + const parent = node.parent + if (isPropertyAccessLikeExpression(parent) && parent.expression === node) { + return false + } + if (isElementAccessLikeExpression(parent) && parent.expression === node) { + return false + } + return true +} + +function isRuntimeArrayMethodAccess( + node: + | ts.PropertyAccessExpression + | ts.PropertyAccessChain + | ts.ElementAccessExpression + | ts.ElementAccessChain +) { + const propertyName = getAccessedPropertyName(node) + return ( + Boolean(propertyName) && + ARRAY_CONSUMER_METHOD_NAMES.has(propertyName!) && + isCallLikeExpression(node.parent) && + node.parent.expression === node + ) +} + +function isRuntimeLengthAccess( + node: + | ts.PropertyAccessExpression + | ts.PropertyAccessChain + | ts.ElementAccessExpression + | ts.ElementAccessChain +) { + return getAccessedPropertyName(node) === 'length' +} + +function shouldSuppressRuntimePropertyAccess( + node: + | ts.PropertyAccessExpression + | ts.PropertyAccessChain + | ts.ElementAccessExpression + | ts.ElementAccessChain, + descriptor: Descriptor +) { + if (descriptor.kind !== 'root') { + return false + } + + return isRuntimeArrayMethodAccess(node) || isRuntimeLengthAccess(node) +} + +function getFileAnalysis(file: ProjectFile, context: ScanContext): FileAnalysis { + const cached = context.analysisByFile.get(file.filePath) + if (cached) { + return cached + } + + const importedSemantics = buildImportedSemanticsMap( + file, + context.projectFiles, + context.semanticsByFile + ) + const importedCallableDescriptors = buildImportedCallableDescriptorMap(file, context.projectFiles) + const localSemantics = inferFileSemantics(file, importedSemantics, importedCallableDescriptors) + const analysis: FileAnalysis = { + file, + importedCallableDescriptors, + importedSemantics, + localSemantics, + } + + context.analysisByFile.set(file.filePath, analysis) + return analysis +} + +function captureExactCoverage( + state: ScanState, + file: ProjectFile, + node: ts.Node, + descriptor: Descriptor, + source: CoverageRecord['source'] +) { + if (descriptor.kind !== 'root' || descriptor.path.length === 0) { + return + } + + const location = getNodeLocation(file.sourceFile, node.getStart(file.sourceFile)) + state.coverage.push({ + filePath: file.filePath, + line: location.line, + column: location.column, + path: descriptor.path, + pathKey: descriptor.path.join('.'), + mode: 'exact', + source, + }) +} + +function captureSubtreeCoverage( + state: ScanState, + file: ProjectFile, + node: ts.Node, + descriptor: Descriptor, + source: CoverageRecord['source'], + subtreeReason: NonNullable<CoverageRecord['subtreeReason']> +) { + if (descriptor.kind !== 'root' && descriptor.kind !== 'translator') { + return + } + + const pathParts = descriptor.kind === 'root' ? descriptor.path : descriptor.namespace + if (pathParts.length === 0) { + return + } + + const location = getNodeLocation(file.sourceFile, node.getStart(file.sourceFile)) + state.coverage.push({ + filePath: file.filePath, + line: location.line, + column: location.column, + path: pathParts, + pathKey: pathParts.join('.'), + mode: 'subtree', + source, + subtreeReason, + }) +} + +function captureHardcodedCandidate( + state: ScanState, + context: ScanContext, + analysis: FileAnalysis, + activeScope: Scope, + node: ts.Node, + text: string, + options: { kind: HardcodedCandidate['kind']; attributeName?: string; metadata?: boolean }, + activeRoutePath: string | null +) { + const normalizedText = text.replace(/\s+/g, ' ').trim() + if (!isUiStringLiteral(normalizedText)) { + return + } + + const namespaceHint = resolveNamespaceHint( + activeScope, + analysis.file.filePath, + context, + Boolean(options.metadata), + activeRoutePath + ) + if (!namespaceHint) { + return + } + + const { namespace, source } = namespaceHint + if ( + source !== 'static' && + !isRouteAdjacentCandidateFile(analysis.file.filePath, activeRoutePath, context) + ) { + return + } + + const componentSegment = deriveComponentKeySegment(analysis.file.filePath, context.projectRoot) + const fileBasename = path.basename(analysis.file.filePath, path.extname(analysis.file.filePath)) + const namespaceTerminalSegment = namespace.split('.').filter(Boolean).at(-1) ?? '' + const shouldSuppressComponentSegment = + source !== 'static' && + (fileBasename === 'page' || fileBasename === 'layout' || fileBasename === 'index') && + componentSegment === namespaceTerminalSegment + const relativeKeyParts = + source === 'static' + ? [slugifyCopyText(normalizedText)] + : shouldSuppressComponentSegment + ? [slugifyCopyText(normalizedText)] + : [componentSegment, slugifyCopyText(normalizedText)] + const location = getNodeLocation( + analysis.file.sourceFile, + node.getStart(analysis.file.sourceFile) + ) + + state.hardcodedCandidates.push({ + filePath: analysis.file.filePath, + line: location.line, + column: location.column, + text: normalizedText, + kind: options.kind, + namespace, + namespaceSource: source, + relativeKeyParts, + attributeName: options.attributeName, + metadata: Boolean(options.metadata), + }) +} + +function resolveExportedFunctionTarget( + projectFiles: Map<string, ProjectFile>, + file: ProjectFile, + exportName: string, + seen = new Set<string>() +): ExportedFunctionTarget | null { + const cacheKey = `${file.filePath}:${exportName}` + if (seen.has(cacheKey)) { + return null + } + + seen.add(cacheKey) + + if (exportName === 'default') { + if (file.defaultExportFunction) { + return { + targetFile: file, + targetNode: file.defaultExportFunction, + } + } + + if (file.defaultExportBindingName) { + const localTarget = file.localFunctions.get(file.defaultExportBindingName) + if (localTarget) { + return { + targetFile: file, + targetNode: localTarget, + } + } + + const importedBinding = file.importBindings.get(file.defaultExportBindingName) + if (importedBinding?.resolvedFilePath) { + const importedFile = projectFiles.get(importedBinding.resolvedFilePath) + if (importedFile) { + return resolveExportedFunctionTarget( + projectFiles, + importedFile, + importedBinding.importedName, + seen + ) + } + } + } + } + + if (exportName !== 'default' && file.exportedFunctions.has(exportName)) { + const localTarget = file.localFunctions.get(exportName) + if (localTarget) { + return { + targetFile: file, + targetNode: localTarget, + } + } + } + + for (const binding of file.localExportBindings) { + if (binding.exportedName !== exportName) { + continue + } + + const localTarget = file.localFunctions.get(binding.localName) + if (localTarget) { + return { + targetFile: file, + targetNode: localTarget, + } + } + + const importedBinding = file.importBindings.get(binding.localName) + if (!importedBinding?.resolvedFilePath) { + continue + } + + const importedFile = projectFiles.get(importedBinding.resolvedFilePath) + if (!importedFile) { + continue + } + + const importedTarget = resolveExportedFunctionTarget( + projectFiles, + importedFile, + importedBinding.importedName, + seen + ) + if (importedTarget) { + return importedTarget + } + } + + for (const binding of file.reExportBindings) { + if (binding.exportedName !== exportName || !binding.resolvedFilePath) { + continue + } + + const importedFile = projectFiles.get(binding.resolvedFilePath) + if (!importedFile) { + continue + } + + const importedTarget = resolveExportedFunctionTarget( + projectFiles, + importedFile, + binding.importedName, + seen + ) + if (importedTarget) { + return importedTarget + } + } + + for (const exportAllPath of file.exportAllPaths) { + const importedFile = projectFiles.get(exportAllPath) + if (!importedFile) { + continue + } + + const importedTarget = resolveExportedFunctionTarget( + projectFiles, + importedFile, + exportName, + seen + ) + if (importedTarget) { + return importedTarget + } + } + + return null +} + +function resolveCallableTargetFromIdentifier( + identifier: ts.Identifier, + analysis: FileAnalysis, + scope: Scope, + context: ScanContext, + activeRoutePath: string | null +): CallableTarget | null { + const binding = lookupBinding(scope, identifier.text) + if (binding) { + return binding.kind === 'callable' + ? resolveCallableTargetFromDescriptor(binding, context, activeRoutePath) + : null + } + + const localTarget = analysis.file.localFunctions.get(identifier.text) + if (localTarget) { + return { + activeRoutePath, + closureScope: isNestedLocalFunction(localTarget) ? scope : null, + targetFile: analysis.file, + targetNode: localTarget, + targetImportedSemantics: analysis.importedSemantics, + } + } + + const importBinding = analysis.file.importBindings.get(identifier.text) + if (!importBinding?.resolvedFilePath) { + return null + } + + const importedFile = context.projectFiles.get(importBinding.resolvedFilePath) + if (!importedFile) { + return null + } + + const target = resolveExportedFunctionTarget( + context.projectFiles, + importedFile, + importBinding.importedName + ) + if (!target) { + return null + } + + return { + activeRoutePath, + closureScope: null, + ...target, + targetImportedSemantics: getFileAnalysis(target.targetFile, context).importedSemantics, + } +} + +function resolveCallableTargetFromDescriptor( + descriptor: Extract<Descriptor, { kind: 'callable' }>, + context: ScanContext, + activeRoutePath: string | null +): CallableTarget | null { + const targetFile = context.projectFiles.get(descriptor.filePath) + if (!targetFile) { + return null + } + + return { + activeRoutePath, + closureScope: descriptor.closureScope, + targetFile, + targetNode: descriptor.targetNode, + targetImportedSemantics: getFileAnalysis(targetFile, context).importedSemantics, + } +} + +function resolveCallableTargetFromExpression( + expression: ts.Expression, + analysis: FileAnalysis, + scope: Scope, + context: ScanContext, + activeRoutePath: string | null +): CallableTarget | null { + const descriptor = resolveExpressionDescriptor( + expression, + analysis.file, + scope, + analysis.localSemantics, + analysis.importedSemantics, + analysis.importedCallableDescriptors + ) + if (descriptor?.kind === 'callable') { + return resolveCallableTargetFromDescriptor(descriptor, context, activeRoutePath) + } + + const callee = unwrapExpression(expression) + if (ts.isIdentifier(callee)) { + return resolveCallableTargetFromIdentifier(callee, analysis, scope, context, activeRoutePath) + } + + return null +} + +function resolveCallableTargetFromJsx( + tagName: ts.JsxTagNameExpression, + analysis: FileAnalysis, + scope: Scope, + context: ScanContext, + activeRoutePath: string | null +): CallableTarget | null { + if (!ts.isIdentifier(tagName) || /^[a-z]/.test(tagName.text)) { + return null + } + + return resolveCallableTargetFromIdentifier(tagName, analysis, scope, context, activeRoutePath) +} + +function isIntrinsicJsxTagName(tagName: ts.JsxTagNameExpression) { + return ts.isIdentifier(tagName) && /^[a-z]/.test(tagName.text) +} + +function buildArgumentDescriptorList( + args: ts.NodeArray<ts.Expression>, + analysis: FileAnalysis, + scope: Scope +) { + const descriptors: ArgumentDescriptor[] = [] + + for (const [index, argument] of args.entries()) { + const descriptor = resolveExpressionDescriptor( + argument, + analysis.file, + scope, + analysis.localSemantics, + analysis.importedSemantics, + analysis.importedCallableDescriptors + ) + if (descriptor) { + descriptors.push({ index, descriptor }) + } + } + + return descriptors +} + +function buildJsxPropsDescriptor( + attributes: ts.JsxAttributes, + analysis: FileAnalysis, + scope: Scope +): Descriptor | null { + const properties: Record<string, Descriptor> = {} + + for (const property of attributes.properties) { + if (ts.isJsxSpreadAttribute(property)) { + const spreadDescriptor = resolveExpressionDescriptor( + property.expression, + analysis.file, + scope, + analysis.localSemantics, + analysis.importedSemantics, + analysis.importedCallableDescriptors + ) + + if (spreadDescriptor?.kind === 'object') { + for (const [propertyName, propertyDescriptor] of Object.entries( + spreadDescriptor.properties + )) { + properties[propertyName] = cloneDescriptor(propertyDescriptor) + } + } + continue + } + + if (!ts.isIdentifier(property.name) || !property.initializer) { + continue + } + + let descriptor: Descriptor | null = null + if (ts.isJsxExpression(property.initializer) && property.initializer.expression) { + descriptor = resolveExpressionDescriptor( + property.initializer.expression, + analysis.file, + scope, + analysis.localSemantics, + analysis.importedSemantics, + analysis.importedCallableDescriptors + ) + } + + if (descriptor) { + properties[property.name.text] = descriptor + } + } + + return Object.keys(properties).length > 0 ? objectDescriptor(properties) : null +} + +function captureArrayConsumerReference( + node: ts.CallExpression | ts.CallChain, + analysis: FileAnalysis, + scope: Scope, + state: ScanState +) { + const { methodName, receiver } = resolveCallPropertyTarget(node) + + if (!methodName || !receiver || !ARRAY_CONSUMER_METHOD_NAMES.has(methodName)) { + return + } + + const descriptor = resolveExpressionDescriptor( + receiver, + analysis.file, + scope, + analysis.localSemantics, + analysis.importedSemantics, + analysis.importedCallableDescriptors + ) + if (descriptor?.kind === 'root') { + captureSubtreeCoverage(state, analysis.file, receiver, descriptor, 'copy-access', 'array-root') + } +} + +function resolveCallPropertyTarget(node: ts.CallExpression | ts.CallChain) { + const callee = unwrapExpression(node.expression) + + if (isPropertyAccessLikeExpression(callee)) { + return { + methodName: callee.name.text, + receiver: callee.expression, + } + } + + if (isElementAccessLikeExpression(callee) && callee.argumentExpression) { + return { + methodName: getStaticPropertyKey(unwrapExpression(callee.argumentExpression)), + receiver: callee.expression, + } + } + + return { + methodName: null, + receiver: null, + } +} + +function captureArrayLengthReference( + node: + | ts.PropertyAccessExpression + | ts.PropertyAccessChain + | ts.ElementAccessExpression + | ts.ElementAccessChain, + analysis: FileAnalysis, + scope: Scope, + state: ScanState +) { + if (!isRuntimeLengthAccess(node)) { + return + } + + const descriptor = resolveExpressionDescriptor( + node.expression, + analysis.file, + scope, + analysis.localSemantics, + analysis.importedSemantics, + analysis.importedCallableDescriptors + ) + if (descriptor?.kind === 'root') { + captureSubtreeCoverage(state, analysis.file, node, descriptor, 'copy-access', 'array-root') + } +} + +function scanFunctionBody( + node: ts.FunctionLikeDeclaration, + scope: Scope, + analysis: FileAnalysis, + context: ScanContext, + state: ScanState, + activeRoutePath: string | null +) { + if (!node.body) { + return + } + + scanNodeWithScope(node.body, scope, analysis, context, state, activeRoutePath) +} + +function getRuntimeCallbackArgumentIndexes(node: ts.CallExpression | ts.CallChain) { + const callee = unwrapExpression(node.expression) + + if (ts.isIdentifier(callee)) { + if ( + RUNTIME_CALLBACK_HOOK_NAMES.has(callee.text) || + RUNTIME_CALLBACK_FUNCTION_NAMES.has(callee.text) + ) { + return [0] + } + } + + const { methodName } = resolveCallPropertyTarget(node) + if (!methodName) { + return [] + } + + if ( + ARRAY_RUNTIME_CALLBACK_METHOD_NAMES.has(methodName) || + RUNTIME_CALLBACK_HOOK_NAMES.has(methodName) + ) { + return [0] + } + + if (methodName === 'then') { + return [0, 1] + } + + if (methodName === 'catch' || methodName === 'finally') { + return [0] + } + + return [] +} + +function scanRuntimeCallbackArguments( + node: ts.CallExpression | ts.CallChain, + scope: Scope, + analysis: FileAnalysis, + context: ScanContext, + state: ScanState, + activeRoutePath: string | null +) { + for (const index of getRuntimeCallbackArgumentIndexes(node)) { + const callbackExpression = node.arguments[index] + if (!callbackExpression) { + continue + } + + const target = resolveCallableTargetFromExpression( + callbackExpression, + analysis, + scope, + context, + activeRoutePath + ) + if (target) { + scanCallableInvocation(target, [], context, state) + } + } +} + +function scanCallableInvocation( + target: CallableTarget, + argumentDescriptors: ArgumentDescriptor[], + context: ScanContext, + state: ScanState +) { + const descriptorSignature = argumentDescriptors + .map( + (argumentDescriptor) => + `${argumentDescriptor.index}:${descriptorToPathKey(argumentDescriptor.descriptor)}` + ) + .join('|') + const closureSignature = getScopeBindingSignature(target.closureScope) + const invocationKey = `${target.targetFile.filePath}:${target.targetNode.getStart(target.targetFile.sourceFile)}:${target.activeRoutePath ?? ''}:${descriptorSignature}:${closureSignature}` + + if (context.invocationCache.has(invocationKey)) { + return + } + + context.invocationCache.add(invocationKey) + + if (!target.targetNode.body) { + return + } + + const targetAnalysis = getFileAnalysis(target.targetFile, context) + const scope = createFunctionScope(target.closureScope ?? createRootScope(), target.targetNode) + bindFunctionParameters( + scope, + target.targetNode, + target.targetFile, + targetAnalysis.localSemantics, + target.targetImportedSemantics, + argumentDescriptors + ) + scanFunctionBody(target.targetNode, scope, targetAnalysis, context, state, target.activeRoutePath) +} + +function scanExportEntry( + file: ProjectFile, + exportName: string, + context: ScanContext, + state: ScanState, + activeRoutePath: string | null +) { + const target = resolveExportedFunctionTarget(context.projectFiles, file, exportName) + if (!target) { + return + } + + scanCallableInvocation( + { + activeRoutePath, + closureScope: null, + ...target, + targetImportedSemantics: getFileAnalysis(target.targetFile, context).importedSemantics, + }, + [], + context, + state + ) +} + +function scanNodeWithScope( + node: ts.Node, + scope: Scope, + analysis: FileAnalysis, + context: ScanContext, + state: ScanState, + activeRoutePath: string | null +) { + if (isFunctionLike(node)) { + return + } + + if (ts.isBlock(node) && !isFunctionLike(node.parent)) { + const blockScope = createBlockScope(scope) + ts.forEachChild(node, (child) => + scanNodeWithScope(child, blockScope, analysis, context, state, activeRoutePath) + ) + return + } + + if ( + ts.isCaseBlock(node) || + ts.isForStatement(node) || + ts.isForInStatement(node) || + ts.isForOfStatement(node) + ) { + const blockScope = createBlockScope(scope) + ts.forEachChild(node, (child) => + scanNodeWithScope(child, blockScope, analysis, context, state, activeRoutePath) + ) + return + } + + if (ts.isVariableDeclaration(node) && node.initializer) { + const descriptor = resolveExpressionDescriptor( + node.initializer, + analysis.file, + scope, + analysis.localSemantics, + analysis.importedSemantics, + analysis.importedCallableDescriptors + ) + if (descriptor) { + bindVariableDeclaration(scope, node, descriptor) + } + + if ( + ts.isIdentifier(node.name) && + (node.name.text === 'metadata' || node.name.text === 'metadataBase') && + ts.isObjectLiteralExpression(unwrapExpression(node.initializer)) + ) { + const metadataScope = createBlockScope(scope, { inMetadata: true }) + scanNodeWithScope(node.initializer, metadataScope, analysis, context, state, activeRoutePath) + return + } + } + + if (isCallLikeExpression(node)) { + captureArrayConsumerReference(node, analysis, scope, state) + scanRuntimeCallbackArguments(node, scope, analysis, context, state, activeRoutePath) + + const calleeDescriptor = resolveExpressionDescriptor( + node.expression, + analysis.file, + scope, + analysis.localSemantics, + analysis.importedSemantics, + analysis.importedCallableDescriptors + ) + if (calleeDescriptor?.kind === 'translator') { + const firstArgument = node.arguments[0] ? unwrapExpression(node.arguments[0]!) : null + const pathSuffix = firstArgument ? getStaticTextValue(firstArgument) : null + + if (pathSuffix) { + captureExactCoverage( + state, + analysis.file, + node, + rootDescriptor([...calleeDescriptor.namespace, ...pathSuffix.split('.')]), + 'translation' + ) + } else { + captureSubtreeCoverage( + state, + analysis.file, + node, + calleeDescriptor, + 'translation', + 'dynamic-root' + ) + } + } + + const argumentDescriptors = buildArgumentDescriptorList(node.arguments, analysis, scope) + const target = resolveCallableTargetFromExpression( + node.expression, + analysis, + scope, + context, + activeRoutePath + ) + if (target) { + scanCallableInvocation(target, argumentDescriptors, context, state) + } + } + + if ( + (isPropertyAccessLikeExpression(node) || isElementAccessLikeExpression(node)) && + shouldCapturePropertyAccess(node) + ) { + captureArrayLengthReference(node, analysis, scope, state) + + const descriptor = resolveExpressionDescriptor( + node as ts.Expression, + analysis.file, + scope, + analysis.localSemantics, + analysis.importedSemantics, + analysis.importedCallableDescriptors + ) + if (descriptor?.kind === 'root' && !shouldSuppressRuntimePropertyAccess(node, descriptor)) { + captureExactCoverage(state, analysis.file, node, descriptor, 'copy-access') + } + + if (isElementAccessLikeExpression(node)) { + const argument = node.argumentExpression ? unwrapExpression(node.argumentExpression) : null + if (!argument || !getStaticPropertyKey(argument)) { + const parentDescriptor = resolveExpressionDescriptor( + node.expression, + analysis.file, + scope, + analysis.localSemantics, + analysis.importedSemantics, + analysis.importedCallableDescriptors + ) + if (parentDescriptor) { + captureSubtreeCoverage( + state, + analysis.file, + node, + parentDescriptor, + 'copy-access', + 'dynamic-root' + ) + } + } + } + } + + if (ts.isJsxText(node)) { + captureHardcodedCandidate( + state, + context, + analysis, + scope, + node, + node.getText(analysis.file.sourceFile), + { + kind: 'jsx-text', + }, + activeRoutePath + ) + } + + if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) { + const propsDescriptor = buildJsxPropsDescriptor(node.attributes, analysis, scope) + const target = resolveCallableTargetFromJsx( + node.tagName, + analysis, + scope, + context, + activeRoutePath + ) + if (target) { + scanCallableInvocation( + target, + propsDescriptor ? [{ index: 0, descriptor: propsDescriptor }] : [], + context, + state + ) + } + } + + if ( + ts.isJsxAttribute(node) && + node.initializer && + ts.isStringLiteral(node.initializer) && + ts.isIdentifier(node.name) && + HARD_CODED_PROP_NAMES.has(node.name.text) + ) { + captureHardcodedCandidate( + state, + context, + analysis, + scope, + node.initializer, + node.initializer.text, + { + kind: 'jsx-attribute', + attributeName: node.name.text, + }, + activeRoutePath + ) + } + + if ( + ts.isJsxAttribute(node) && + ts.isIdentifier(node.name) && + /^on[A-Z]/.test(node.name.text) && + node.initializer && + ts.isJsxExpression(node.initializer) && + node.initializer.expression && + (ts.isJsxOpeningElement(node.parent.parent) || + ts.isJsxSelfClosingElement(node.parent.parent)) && + isIntrinsicJsxTagName(node.parent.parent.tagName) + ) { + const target = resolveCallableTargetFromExpression( + node.initializer.expression, + analysis, + scope, + context, + activeRoutePath + ) + if (target) { + scanCallableInvocation(target, [], context, state) + } + } + + if ( + scope.inMetadata && + ts.isPropertyAssignment(node) && + node.initializer && + METADATA_PROP_NAMES.has(getLiteralPropertyName(node.name) ?? '') + ) { + const staticText = getStaticTextValue(unwrapExpression(node.initializer)) + if (staticText) { + captureHardcodedCandidate(state, context, analysis, scope, node.initializer, staticText, { + kind: 'metadata', + attributeName: getLiteralPropertyName(node.name) ?? undefined, + metadata: true, + }, activeRoutePath) + } + } + + ts.forEachChild(node, (child) => + scanNodeWithScope(child, scope, analysis, context, state, activeRoutePath) + ) +} + +function scanFile(analysis: FileAnalysis, context: ScanContext, options: ScanFileOptions) { + const state: ScanState = { + coverage: [], + hardcodedCandidates: [], + } + + for (const activeRoutePath of dedupeNullableStrings(options.rootScanRoutePaths)) { + scanNodeWithScope( + analysis.file.sourceFile, + createRootScope(), + analysis, + context, + state, + activeRoutePath + ) + } + + for (const entryInvocation of options.entryInvocations) { + scanExportEntry( + analysis.file, + entryInvocation.exportName, + context, + state, + entryInvocation.activeRoutePath + ) + } + return state +} + +function dedupeHardcodedCandidates(candidates: HardcodedCandidate[]) { + const deduped: HardcodedCandidate[] = [] + const seen = new Set<string>() + + for (const candidate of candidates) { + const key = [ + candidate.filePath, + candidate.line, + candidate.column, + candidate.text, + candidate.namespace, + candidate.kind, + candidate.attributeName ?? '', + candidate.metadata ? '1' : '0', + ].join(':') + + if (seen.has(key)) { + continue + } + + seen.add(key) + deduped.push(candidate) + } + + return deduped +} + +export function scanCatalogProjectWithContext( + context: CatalogProjectContext, + options: ContextScanOptions +): CatalogScanResult { + let routeResolution: ReturnType<typeof resolveRouteEntries> | null = null + const entryDiscovery = + options.mode === 'route' + ? ((routeResolution = resolveRouteEntries( + context.entryDiscoveryContext, + options.routePath + )) as ReturnType<typeof resolveRouteEntries>) + : discoverAllModeEntries(context.entryDiscoveryContext) + + const selectedFiles = collectReachableFiles(entryDiscovery.entryFiles, context) + const analysisProjectFiles = buildAnalysisProjectFiles(context, selectedFiles) + populateProjectFileTypeRoots(analysisProjectFiles) + const semanticsByFile = buildGlobalFunctionSemantics(analysisProjectFiles) + + const routePath = routeResolution?.routePath ?? null + const ownedNamespaces = routePath ? getRouteOwnedNamespaces(routePath) : [] + const scanContext: ScanContext = { + analysisByFile: new Map(), + entryDiscoveryContext: context.entryDiscoveryContext, + projectRoot: context.projectRoot, + projectFiles: analysisProjectFiles, + semanticsByFile, + routePath, + invocationCache: new Set(), + } + const coverage: CoverageRecord[] = [] + const hardcodedCandidates: HardcodedCandidate[] = [] + + for (const filePath of selectedFiles) { + const projectFile = analysisProjectFiles.get(filePath) + if (!projectFile) { + continue + } + + const entryExportNames = entryDiscovery.entryExportNamesByFile.get(filePath) ?? [] + const entryActiveRoutePath = + routePath ?? resolveOwningRoutePathForFile(context.entryDiscoveryContext, filePath) + const entryInvocations = entryExportNames.map((exportName) => ({ + exportName, + activeRoutePath: entryActiveRoutePath, + })) + const rootScanRoutePaths = + routePath !== null + ? [routePath] + : entryInvocations.length > 0 + ? entryInvocations.map((entryInvocation) => entryInvocation.activeRoutePath) + : [null] + + const scanResult = scanFile(getFileAnalysis(projectFile, scanContext), scanContext, { + entryInvocations, + rootScanRoutePaths, + }) + coverage.push(...scanResult.coverage) + hardcodedCandidates.push(...scanResult.hardcodedCandidates) + } + + return { + mode: options.mode, + routePath, + ownedNamespaces, + scannedFiles: selectedFiles.map((filePath) => + toRelativeProjectPath(context.projectRoot, filePath) + ), + coverage, + hardcodedCandidates: dedupeHardcodedCandidates(hardcodedCandidates), + } +} + +export function scanCatalogProject(options: ScanOptions): CatalogScanResult { + const context = createCatalogProjectContext(options.projectRoot) + return scanCatalogProjectWithContext( + context, + options.mode === 'route' ? { mode: 'route', routePath: options.routePath } : { mode: 'all' } + ) +} diff --git a/apps/tradinggoose/tsconfig.typecheck.json b/apps/tradinggoose/tsconfig.typecheck.json new file mode 100644 index 000000000..3e910b0ec --- /dev/null +++ b/apps/tradinggoose/tsconfig.typecheck.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "**/*.ts", + "**/*.mts", + "**/*.tsx", + ".next/types/**/*.ts", + "trigger.config.ts", + "next-env.d.ts" + ], + "exclude": ["node_modules", ".next/dev/types/**/*.ts"] +} From 945538814acc0b7577204f772635800981acacc2 Mon Sep 17 00:00:00 2001 From: agualdron <agualdro@gmail.com> Date: Mon, 6 Jul 2026 16:56:57 -0500 Subject: [PATCH 18/18] style(i18n): align catalog tooling with biome --- .../scripts/i18n-catalog/catalog.ts | 2 +- .../scripts/i18n-catalog/entries.ts | 29 ++++++++++++---- .../scripts/i18n-catalog/index.test.ts | 20 +++++------ .../scripts/i18n-catalog/index.ts | 12 +++++-- .../tradinggoose/scripts/i18n-catalog/scan.ts | 33 ++++++++++++------- 5 files changed, 63 insertions(+), 33 deletions(-) diff --git a/apps/tradinggoose/scripts/i18n-catalog/catalog.ts b/apps/tradinggoose/scripts/i18n-catalog/catalog.ts index 590a41072..725e0c12d 100644 --- a/apps/tradinggoose/scripts/i18n-catalog/catalog.ts +++ b/apps/tradinggoose/scripts/i18n-catalog/catalog.ts @@ -1,6 +1,6 @@ import fs from 'node:fs' import path from 'node:path' -import { defaultLocale, locales, type AppLocale } from '../../i18n/routing' +import { type AppLocale, defaultLocale, locales } from '../../i18n/routing' import { toRelativeProjectPath } from './entries' import type { CatalogScanResult, CoverageRecord, HardcodedCandidate } from './scan' diff --git a/apps/tradinggoose/scripts/i18n-catalog/entries.ts b/apps/tradinggoose/scripts/i18n-catalog/entries.ts index 1f151e087..7578fa017 100644 --- a/apps/tradinggoose/scripts/i18n-catalog/entries.ts +++ b/apps/tradinggoose/scripts/i18n-catalog/entries.ts @@ -126,7 +126,10 @@ export function toRelativeProjectPath(projectRoot: string, filePath: string) { return path.relative(projectRoot, filePath).split(path.sep).join('/') } -export function walkProjectSourceFiles(projectRoot: string, startDirectory = projectRoot): string[] { +export function walkProjectSourceFiles( + projectRoot: string, + startDirectory = projectRoot +): string[] { const pending = [startDirectory] const results: string[] = [] @@ -342,7 +345,10 @@ export function resolveAppRoutePathForFile( return getRoutePathForAppDirectory(context, path.dirname(filePath)) } -function collectNonLocalizedRouteBoundaryEntries(context: EntryDiscoveryContext, routePath: string) { +function collectNonLocalizedRouteBoundaryEntries( + context: EntryDiscoveryContext, + routePath: string +) { const entryExportNamesByFile = new Map<string, EntryExportName[]>() for (const filePath of context.appSourceFiles) { @@ -367,7 +373,10 @@ function collectNonLocalizedRouteBoundaryEntries(context: EntryDiscoveryContext, return entryExportNamesByFile } -function collectExactNonLocalizedRouteOwnedRoots(context: EntryDiscoveryContext, routePath: string) { +function collectExactNonLocalizedRouteOwnedRoots( + context: EntryDiscoveryContext, + routePath: string +) { const routeOwnedRoots = new Set<string>() for (const filePath of context.appSourceFiles) { @@ -376,7 +385,10 @@ function collectExactNonLocalizedRouteOwnedRoots(context: EntryDiscoveryContext, } let directoryPath = path.dirname(filePath) - while (directoryPath !== context.appRoot && isPathInsideDirectory(directoryPath, context.appRoot)) { + while ( + directoryPath !== context.appRoot && + isPathInsideDirectory(directoryPath, context.appRoot) + ) { if (getRoutePathForAppDirectory(context, directoryPath) === routePath) { routeOwnedRoots.add(directoryPath) break @@ -393,7 +405,9 @@ function collectExactNonLocalizedRouteOwnedRoots(context: EntryDiscoveryContext, return [...routeOwnedRoots].sort() } -function toEntryDiscoveryResult(entryExportNamesByFile: Map<string, EntryExportName[]>): EntryDiscoveryResult { +function toEntryDiscoveryResult( + entryExportNamesByFile: Map<string, EntryExportName[]> +): EntryDiscoveryResult { return { entryExportNamesByFile, entryFiles: [...entryExportNamesByFile.keys()].sort(), @@ -452,7 +466,10 @@ export function resolveOwningRoutePathForFile( return null } -export function resolveRouteEntries(input: EntryDiscoveryInput, routePath: string): RouteResolution { +export function resolveRouteEntries( + input: EntryDiscoveryInput, + routePath: string +): RouteResolution { const context = resolveEntryDiscoveryContext(input) const normalizedRoutePath = normalizeRoutePath(routePath) const { filePath: pageFilePath, routePath: matchedRoutePath } = findLocalizedPageFile( diff --git a/apps/tradinggoose/scripts/i18n-catalog/index.test.ts b/apps/tradinggoose/scripts/i18n-catalog/index.test.ts index f629715c1..2b151dca4 100644 --- a/apps/tradinggoose/scripts/i18n-catalog/index.test.ts +++ b/apps/tradinggoose/scripts/i18n-catalog/index.test.ts @@ -7,7 +7,7 @@ import { buildCatalogReport } from './catalog' import { discoverAllModeEntries, resolveRouteEntries } from './entries' import { runCatalogCli } from './index' import { deriveRouteNamespace, getRouteOwnedNamespaces } from './ownership' -import { scanCatalogProject, type CatalogScanResult, type CoverageRecord } from './scan' +import { type CatalogScanResult, type CoverageRecord, scanCatalogProject } from './scan' const tempRoots: string[] = [] @@ -1241,8 +1241,12 @@ function createDynamicLocaleGapProject() { const esMessages = parseLocaleMessages() const zhMessages = parseLocaleMessages() - delete esMessages.workspace.monitor.values.paused - delete zhMessages.workspace.monitor.values.paused + esMessages.workspace.monitor.values = Object.fromEntries( + Object.entries(esMessages.workspace.monitor.values).filter(([key]) => key !== 'paused') + ) + zhMessages.workspace.monitor.values = Object.fromEntries( + Object.entries(zhMessages.workspace.monitor.values).filter(([key]) => key !== 'paused') + ) return createTempProject({ 'i18n/messages/en.json': toJson(enMessages), @@ -2973,9 +2977,7 @@ describe('i18n catalog report derivation', () => { .filter((entry) => entry.pathKey === 'workspace.monitor.values.paused') .map((entry) => entry.locale) .sort() - const expectedTargetLocales = locales - .filter((locale) => locale !== defaultLocale) - .sort() + const expectedTargetLocales = locales.filter((locale) => locale !== defaultLocale).sort() expect(pausedGapLocales).toEqual(expectedTargetLocales) }) @@ -3168,11 +3170,7 @@ describe('i18n catalog report derivation', () => { it('derives owned namespaces for concrete and canonical catch-all routes', () => { expect(getRouteOwnedNamespaces('/blog/hello-world')).toEqual(['blog', 'meta.blog']) expect(deriveRouteNamespace('/blog/hello-world')).toBe('blog') - expect(getRouteOwnedNamespaces('/error')).toEqual([ - 'auth.common', - 'auth.error', - 'auth.sso', - ]) + expect(getRouteOwnedNamespaces('/error')).toEqual(['auth.common', 'auth.error', 'auth.sso']) expect(deriveRouteNamespace('/error')).toBe('auth.error') expect(deriveRouteNamespace('/error/callback')).toBe('auth.error') expect(getRouteOwnedNamespaces('/missing')).toEqual(['notFound']) diff --git a/apps/tradinggoose/scripts/i18n-catalog/index.ts b/apps/tradinggoose/scripts/i18n-catalog/index.ts index a9a36bc51..c3699c14c 100644 --- a/apps/tradinggoose/scripts/i18n-catalog/index.ts +++ b/apps/tradinggoose/scripts/i18n-catalog/index.ts @@ -1,8 +1,8 @@ import { parseArgs } from 'node:util' import { buildCatalogReport, type CatalogReport } from './catalog' import { - createCatalogProjectContext, type CatalogScanResult, + createCatalogProjectContext, scanCatalogProjectWithContext, } from './scan' @@ -56,7 +56,10 @@ export function formatCatalogCliText(scanResult: CatalogScanResult, report: Cata `Mode: ${scanResult.mode}${scanResult.routePath ? ` (${scanResult.routePath})` : ''}`, `Scanned files: ${report.scannedFiles.length}`, formatList('Used keys', report.usedKeys), - formatList('Missing keys', report.missingKeys.map((entry) => entry.pathKey)), + formatList( + 'Missing keys', + report.missingKeys.map((entry) => entry.pathKey) + ), formatList( 'Target locale gaps', report.targetLocaleGaps.map((entry) => `${entry.locale}: ${entry.pathKey}`) @@ -76,7 +79,10 @@ export function formatCatalogCliText(scanResult: CatalogScanResult, report: Cata sections.splice( 4, 0, - formatList('Orphaned keys', report.orphanedKeys.map((entry) => entry.pathKey)) + formatList( + 'Orphaned keys', + report.orphanedKeys.map((entry) => entry.pathKey) + ) ) } if (report.dynamicProtectedRoots) { diff --git a/apps/tradinggoose/scripts/i18n-catalog/scan.ts b/apps/tradinggoose/scripts/i18n-catalog/scan.ts index 344c10830..32fa14327 100644 --- a/apps/tradinggoose/scripts/i18n-catalog/scan.ts +++ b/apps/tradinggoose/scripts/i18n-catalog/scan.ts @@ -1,14 +1,11 @@ import fs from 'node:fs' import path from 'node:path' import ts from 'typescript' -import { - deriveComponentKeySegment, - deriveRouteNamespace, - getRouteOwnedNamespaces, -} from './ownership' import { createEntryDiscoveryContext, discoverAllModeEntries, + type EntryDiscoveryContext, + type EntryExportName, isIgnoredProjectPath, isPathInsideDirectory, isRoutePathPrefix, @@ -17,9 +14,12 @@ import { resolveRouteEntries, SOURCE_EXTENSIONS, toRelativeProjectPath, - type EntryDiscoveryContext, - type EntryExportName, } from './entries' +import { + deriveComponentKeySegment, + deriveRouteNamespace, + getRouteOwnedNamespaces, +} from './ownership' const HARD_CODED_PROP_NAMES = new Set([ 'title', @@ -3428,11 +3428,20 @@ function scanNodeWithScope( ) { const staticText = getStaticTextValue(unwrapExpression(node.initializer)) if (staticText) { - captureHardcodedCandidate(state, context, analysis, scope, node.initializer, staticText, { - kind: 'metadata', - attributeName: getLiteralPropertyName(node.name) ?? undefined, - metadata: true, - }, activeRoutePath) + captureHardcodedCandidate( + state, + context, + analysis, + scope, + node.initializer, + staticText, + { + kind: 'metadata', + attributeName: getLiteralPropertyName(node.name) ?? undefined, + metadata: true, + }, + activeRoutePath + ) } }