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/14] 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/14] 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/14] =?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/14] 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 0601a05f02ce2be4aad2d87e4535c37040d5fc37 Mon Sep 17 00:00:00 2001 From: agualdron Date: Fri, 19 Jun 2026 15:32:36 -0500 Subject: [PATCH 14/14] 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. --- apps/tradinggoose/i18n/messages/en.json | 47 ++- apps/tradinggoose/i18n/messages/es.json | 19 +- apps/tradinggoose/i18n/messages/zh.json | 17 + .../components/mention-menu.test.tsx | 296 ++++++++++++++++++ .../user-input/components/mention-menu.tsx | 161 ++++++---- .../components/user-input/constants.ts | 14 +- .../use-user-input-mention-sources.test.tsx | 237 ++++++++++++++ .../hooks/use-user-input-mention-sources.ts | 97 ++++-- .../hooks/use-user-input-mentions.test.tsx | 287 +++++++++++++++++ .../hooks/use-user-input-mentions.ts | 93 ++++-- .../components/user-input/mention-copy.ts | 139 ++++++++ .../user-input/mention-utils.test.ts | 114 ++++++- .../components/user-input/mention-utils.ts | 126 +++++--- .../copilot/components/user-input/types.ts | 14 +- .../user-input/workspace-entity-mentions.ts | 29 +- .../widgets/copilot/workspace-entities.ts | 27 +- 16 files changed, 1452 insertions(+), 265 deletions(-) create mode 100644 apps/tradinggoose/widgets/widgets/copilot/components/user-input/components/mention-menu.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/copilot/components/user-input/hooks/use-user-input-mention-sources.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/copilot/components/user-input/hooks/use-user-input-mentions.test.tsx create mode 100644 apps/tradinggoose/widgets/widgets/copilot/components/user-input/mention-copy.ts diff --git a/apps/tradinggoose/i18n/messages/en.json b/apps/tradinggoose/i18n/messages/en.json index 2e9540e51..bfa86d33f 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", @@ -21443,6 +21423,23 @@ "older": "Older" } }, + "mentions": { + "workflowBlocks": "Workflow Blocks", + "untitledChat": "Untitled chat", + "untitled": "Untitled", + "matches": "Matches", + "noMatches": "No matches", + "noPastChats": "No past chats", + "noWorkflows": "No workflows found", + "noSkills": "No skills found", + "noIndicators": "No indicators found", + "noCustomTools": "No custom tools found", + "noMcpServers": "No MCP servers found", + "noKnowledgeBases": "No knowledge bases found", + "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..90ca61b2e 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", @@ -21423,6 +21423,23 @@ "older": "Anteriores" } }, + "mentions": { + "workflowBlocks": "Bloques del flujo de trabajo", + "untitledChat": "Chat sin título", + "untitled": "Sin título", + "matches": "Coincidencias", + "noMatches": "No hay coincidencias", + "noPastChats": "No hay chats anteriores", + "noWorkflows": "No se encontraron flujos de trabajo", + "noSkills": "No se encontraron habilidades", + "noIndicators": "No se encontraron indicadores", + "noCustomTools": "No se encontraron herramientas personalizadas", + "noMcpServers": "No se encontraron servidores MCP", + "noKnowledgeBases": "No se encontraron bases de conocimiento", + "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..98b8a11e5 100644 --- a/apps/tradinggoose/i18n/messages/zh.json +++ b/apps/tradinggoose/i18n/messages/zh.json @@ -21410,6 +21410,23 @@ "older": "更早" } }, + "mentions": { + "workflowBlocks": "工作流模块", + "untitledChat": "未命名聊天", + "untitled": "未命名", + "matches": "匹配项", + "noMatches": "未找到匹配项", + "noPastChats": "没有历史聊天", + "noWorkflows": "未找到工作流", + "noSkills": "未找到技能", + "noIndicators": "未找到指标", + "noCustomTools": "未找到自定义工具", + "noMcpServers": "未找到 MCP 服务器", + "noKnowledgeBases": "未找到知识库", + "noBlocksFound": "未找到模块", + "noBlocksInWorkflow": "此工作流中没有模块", + "noExecutionsFound": "未找到执行记录" + }, "accessLevel": { "limited": { "label": "受限", diff --git a/apps/tradinggoose/widgets/widgets/copilot/components/user-input/components/mention-menu.test.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/components/mention-menu.test.tsx new file mode 100644 index 000000000..6906149bb --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/components/mention-menu.test.tsx @@ -0,0 +1,296 @@ +/** + * @vitest-environment jsdom + */ + +import { act, createRef } from 'react' +import { NextIntlClientProvider } from 'next-intl' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + getLocalizedBlockNameWithCopy, + getLocalizedDefaultBlockNameWithCopy, +} from '@/i18n/workflow-inspector-core' +import esMessages from '../../../../../../i18n/messages/es.json' +import zhMessages from '../../../../../../i18n/messages/zh.json' +import type { MentionSources, MentionSubmenu } from '../types' +import { MentionMenu } from './mention-menu' + +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean +} + +const stripAccents = (value: string) => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '') + +const createMentionSources = (): MentionSources => ({ + pastChats: [], + workspaceEntities: { + workflow: [], + skill: [], + indicator: [], + custom_tool: [], + mcp_server: [], + }, + knowledgeBases: [], + blocksList: [], + logsList: [], + workflowBlocks: [], +}) + +const loadingState: Record = { + chats: false, + workflow: false, + skill: false, + indicator: false, + custom_tool: false, + mcp_server: false, + workflow_blocks: false, + blocks: false, + knowledge: false, + logs: false, +} + +describe('MentionMenu i18n', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false + }) + + const renderMenu = async ({ + locale, + messages, + openSubmenuFor = null, + sources = createMentionSources(), + mentionQuery = '', + submenuQuery = '', + }: { + locale: 'es' | 'zh' + messages: unknown + openSubmenuFor?: MentionSubmenu | null + sources?: MentionSources + mentionQuery?: string + submenuQuery?: string + }) => { + await act(async () => { + root.render( + + ()} + mentionPortalRef={createRef()} + mentionPortalStyle={{ + top: 48, + left: 24, + width: 320, + maxHeight: 240, + showBelow: true, + }} + mentionQuery={mentionQuery} + menuListRef={createRef()} + onAggregatedItemHover={() => {}} + onMainOptionHover={() => {}} + onSelectAggregatedItem={() => {}} + onSelectMainOption={() => {}} + onSelectSubmenuItem={() => {}} + onSubmenuItemHover={() => {}} + openSubmenuFor={openSubmenuFor} + showMentionMenu + sources={sources} + submenuActiveIndex={0} + submenuQuery={submenuQuery} + /> + + ) + }) + } + + it('renders localized main menu labels in spanish', async () => { + await renderMenu({ + locale: 'es', + messages: esMessages, + }) + + expect(document.body.textContent).toContain( + (esMessages as any).workspace.widgets.workflowLabels.workflows + ) + expect(document.body.textContent).not.toContain( + (esMessages as any).workspace.widgets.workflowLabels.allWorkflows + ) + expect(document.body.textContent).toContain('Bloques del flujo de trabajo') + expect(document.body.textContent).toContain('Documentación') + }) + + it('renders localized empty workflow state in spanish', async () => { + await renderMenu({ + locale: 'es', + messages: esMessages, + openSubmenuFor: 'workflow', + }) + + expect(document.body.textContent).toContain( + (esMessages as any).workspace.widgets.workflowLabels.allWorkflows + ) + expect(document.body.textContent).toContain('No se encontraron flujos de trabajo') + }) + + it('filters unnamed workflows using the localized spanish fallback label', async () => { + const untitledWorkflowLabel = (esMessages as any).workspace.widgets.workflowDropdown + .untitledWorkflow + const sources = createMentionSources() + sources.workspaceEntities.workflow = [ + { + entityKind: 'workflow', + id: 'workflow-1', + name: '', + color: '#3972F6', + }, + ] + + await renderMenu({ + locale: 'es', + messages: esMessages, + openSubmenuFor: 'workflow', + sources, + submenuQuery: stripAccents(untitledWorkflowLabel.toLowerCase()), + }) + + expect(document.body.textContent).toContain(untitledWorkflowLabel) + expect(document.body.textContent).not.toContain('No se encontraron flujos de trabajo') + }) + + it('renders localized block labels in the spanish blocks submenu', async () => { + const localizedBlockName = getLocalizedBlockNameWithCopy( + (esMessages as any).workspace.widgets, + 'condition' + ) + const sources = createMentionSources() + sources.blocksList = [ + { + id: 'condition', + name: localizedBlockName, + }, + ] + + await renderMenu({ + locale: 'es', + messages: esMessages, + openSubmenuFor: 'blocks', + sources, + }) + + expect(document.body.textContent).toContain(localizedBlockName) + }) + + it('filters and renders logs using localized chinese trigger labels', async () => { + const sources = createMentionSources() + sources.logsList = [ + { + id: 'log-1', + level: 'info', + trigger: 'schedule', + startedAt: '2026-04-17T00:00:00.000Z', + entityName: 'Alpha Workflow', + }, + ] + + await renderMenu({ + locale: 'zh', + messages: zhMessages, + openSubmenuFor: 'logs', + sources, + submenuQuery: '计划', + }) + + expect(document.body.textContent).toContain('Alpha Workflow') + expect(document.body.textContent).toContain('计划') + expect(document.body.textContent).not.toContain('未找到执行记录') + }) + + it('renders localized workflow block labels in the spanish workflow blocks submenu', async () => { + const localizedWorkflowBlockName = getLocalizedDefaultBlockNameWithCopy( + (esMessages as any).workspace.widgets, + 'condition', + 'Condition 2' + ) + const sources = createMentionSources() + sources.workflowBlocks = [ + { + id: 'workflow-block-1', + type: 'condition', + name: localizedWorkflowBlockName, + }, + ] + + await renderMenu({ + locale: 'es', + messages: esMessages, + openSubmenuFor: 'workflow_blocks', + sources, + submenuQuery: 'condicion 2', + }) + + expect(document.body.textContent).toContain(localizedWorkflowBlockName) + }) + + it('filters blocks using localized chinese block names', async () => { + const localizedBlockName = getLocalizedBlockNameWithCopy( + (zhMessages as any).workspace.widgets, + 'condition' + ) + const sources = createMentionSources() + sources.blocksList = [ + { + id: 'condition', + name: localizedBlockName, + }, + ] + + await renderMenu({ + locale: 'zh', + messages: zhMessages, + openSubmenuFor: 'blocks', + sources, + submenuQuery: localizedBlockName, + }) + + expect(document.body.textContent).toContain(localizedBlockName) + }) + + it('filters blocks using accentless spanish queries', async () => { + const localizedBlockName = getLocalizedBlockNameWithCopy( + (esMessages as any).workspace.widgets, + 'condition' + ) + const sources = createMentionSources() + sources.blocksList = [ + { + id: 'condition', + name: localizedBlockName, + }, + ] + + await renderMenu({ + locale: 'es', + messages: esMessages, + openSubmenuFor: 'blocks', + sources, + submenuQuery: 'condicion', + }) + + expect(document.body.textContent).toContain(localizedBlockName) + }) +}) 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..5be76b5f9 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,11 +20,22 @@ 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 { + getKnowledgeBaseMentionLabel, + getLogMentionTriggerLabel, + getMentionOptionLabel, + getMentionSubmenuTitle, + getPastChatMentionLabel, + getWorkspaceEntityMentionEmptyState, + getWorkspaceEntityMentionLabel, + useCopilotMentionCopy, +} from '../mention-copy' import { buildAggregatedMentionItems, filterBlocks, @@ -34,7 +45,6 @@ import { filterPastChats, filterWorkflowBlocks, filterWorkspaceEntitiesForOption, - getMentionSubmenuTitle, } from '../mention-utils' import type { AggregatedMentionItem, @@ -50,7 +60,6 @@ import type { WorkflowBlockItem, WorkspaceEntityItem, } from '../types' -import { getWorkspaceEntityMentionEmptyState } from '../workspace-entity-mentions' interface MentionMenuProps { inAggregated: boolean @@ -198,30 +207,30 @@ 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} + {label} {entity.functionName ? ( <> · @@ -230,10 +239,10 @@ const WORKSPACE_ENTITY_ITEM_RENDERERS: Record< ) : null} ), - mcp_server: (entity) => ( + mcp_server: (entity, label) => ( <> {renderMcpServerBadge(entity.connectionStatus)} - {entity.name} + {label} {entity.transport ? ( <> · @@ -245,7 +254,7 @@ const WORKSPACE_ENTITY_ITEM_RENDERERS: Record< } const renderMainOptionIcon = (option: MentionOption) => { - if (option === 'Chats') { + if (option === 'chats') { return } @@ -255,58 +264,66 @@ const renderMainOptionIcon = (option: MentionOption) => { ) } - 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, + mentionCopy: ReturnType, + monitorCopy: ReturnType['copy'] +) => { + 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(mentionCopy, knowledgeBase)} ) } - if (type === 'Blocks') { + if (type === 'blocks') { const block = item as BlockItem return ( <> @@ -316,7 +333,7 @@ const renderMentionItemContent = (type: MentionSubmenu, item: MentionItem) => { ) } - if (type === 'Workflow Blocks') { + if (type === 'workflow_blocks') { const block = item as WorkflowBlockItem return ( <> @@ -326,7 +343,7 @@ const renderMentionItemContent = (type: MentionSubmenu, item: MentionItem) => { ) } - if (type === 'Logs') { + if (type === 'logs') { const log = item as LogItem return ( <> @@ -339,7 +356,7 @@ const renderMentionItemContent = (type: MentionSubmenu, item: MentionItem) => { · {formatTimestamp(log.startedAt)} · - {(log.trigger || 'manual').toLowerCase()} + {getLogMentionTriggerLabel(monitorCopy, log)} ) } @@ -350,55 +367,61 @@ const renderMentionItemContent = (type: MentionSubmenu, item: MentionItem) => { const getSubmenuItems = ( submenu: MentionSubmenu, query: string, - sources: MentionSources + sources: MentionSources, + mentionCopy: ReturnType, + monitorCopy: ReturnType['copy'] ): MentionItem[] => { - if (submenu === 'Chats') { - return filterPastChats(sources.pastChats, query) + if (submenu === 'chats') { + return filterPastChats(sources.pastChats, query, mentionCopy) } if (isCopilotWorkspaceEntityMentionOption(submenu)) { - return filterWorkspaceEntitiesForOption(submenu, sources, query) + return filterWorkspaceEntitiesForOption(submenu, sources, query, mentionCopy) } - if (submenu === 'Knowledge') { - return filterKnowledgeBases(sources.knowledgeBases, query) + if (submenu === 'knowledge') { + return filterKnowledgeBases(sources.knowledgeBases, query, mentionCopy) } - if (submenu === 'Blocks') { + if (submenu === 'blocks') { return filterBlocks(sources.blocksList, query) } - if (submenu === 'Workflow Blocks') { + if (submenu === 'workflow_blocks') { return filterWorkflowBlocks(sources.workflowBlocks, query) } - return filterLogs(sources.logsList, query) + return filterLogs(sources.logsList, query, monitorCopy) } -const getSubmenuEmptyState = (submenu: MentionSubmenu) => { - if (submenu === 'Chats') { - return 'No past chats' +const getSubmenuEmptyState = ( + submenu: MentionSubmenu, + mentionCopy: ReturnType +) => { + if (submenu === 'chats') { + return mentionCopy.emptyStates.chats } if (isCopilotWorkspaceEntityMentionOption(submenu)) { return getWorkspaceEntityMentionEmptyState( + mentionCopy, getCopilotWorkspaceEntityKindFromMentionOption(submenu) ) } - if (submenu === 'Knowledge') { - return 'No knowledge bases' + if (submenu === 'knowledge') { + return mentionCopy.emptyStates.knowledge } - if (submenu === 'Blocks') { - return 'No blocks found' + if (submenu === 'blocks') { + return mentionCopy.emptyStates.blocks } - if (submenu === 'Workflow Blocks') { - return 'No blocks in this workflow' + if (submenu === 'workflow_blocks') { + return mentionCopy.emptyStates.workflow_blocks } - return 'No executions found' + return mentionCopy.emptyStates.logs } const isSubmenuLoading = (submenu: MentionSubmenu, loading: MentionMenuProps['loading']) => { @@ -430,14 +453,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, + mentionCopy, + monitorCopy + ) const showAggregatedSearch = mentionQuery.length > 0 && filteredOptions.length === 0 - const submenuItems = openSubmenuFor ? getSubmenuItems(openSubmenuFor, submenuQuery, sources) : [] + const submenuItems = openSubmenuFor + ? getSubmenuItems(openSubmenuFor, submenuQuery, sources, mentionCopy, monitorCopy) + : [] return createPortal(
- {getMentionSubmenuTitle(openSubmenuFor)} + {getMentionSubmenuTitle(mentionCopy, openSubmenuFor)}
{isSubmenuLoading(openSubmenuFor, loading) ? ( -
Loading...
+
{mentionCopy.loading}
) : submenuItems.length === 0 ? (
- {getSubmenuEmptyState(openSubmenuFor)} + {getSubmenuEmptyState(openSubmenuFor, mentionCopy)}
) : ( submenuItems.map((item, index) => ( @@ -493,7 +526,7 @@ export function MentionMenu({ onMouseEnter={() => onSubmenuItemHover(index)} onClick={() => onSelectSubmenuItem(openSubmenuFor, item)} > - {renderMentionItemContent(openSubmenuFor, item)} + {renderMentionItemContent(openSubmenuFor, item, mentionCopy, monitorCopy)}
)) )} @@ -502,7 +535,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, mentionCopy, monitorCopy)}
)) )} @@ -541,13 +574,9 @@ export function MentionMenu({ >
{renderMainOptionIcon(option)} - - {isCopilotWorkspaceEntityMentionOption(option) - ? getMentionSubmenuTitle(option) - : option} - + {getMentionOptionLabel(mentionCopy, option)}
- {option !== 'Docs' && ( + {option !== 'docs' && ( )}
@@ -556,7 +585,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, mentionCopy, monitorCopy)}
))} 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.test.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/hooks/use-user-input-mention-sources.test.tsx new file mode 100644 index 000000000..74287ae78 --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/hooks/use-user-input-mention-sources.test.tsx @@ -0,0 +1,237 @@ +/** + * @vitest-environment jsdom + */ + +import { act, useEffect } from 'react' +import { NextIntlClientProvider } from 'next-intl' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getLocalizedBlockNameWithCopy, + getLocalizedDefaultBlockNameWithCopy, +} from '@/i18n/workflow-inspector-core' +import esMessages from '../../../../../../i18n/messages/es.json' +import zhMessages from '../../../../../../i18n/messages/zh.json' +import { useUserInputMentionSources } from './use-user-input-mention-sources' + +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean +} + +const mockBlocks = [ + { + type: 'condition', + name: 'Condition', + category: 'blocks', + hideFromToolbar: false, + bgColor: '#3972F6', + }, +] +const mockWorkflowBlocks: Record = {} +let mockWorkflowId: string | null = null + +const mockGetAllBlocks = vi.fn(() => mockBlocks) +const mockGetBlock = vi.fn((blockType: string) => + mockBlocks.find((block) => block.type === blockType) +) +const mockFetch = vi.fn(async (input: string | URL | Request) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + + if (url.startsWith('/api/workflows?')) { + return { + ok: true, + json: async () => ({ + data: [ + { + id: 'workflow-1', + name: 'Workflow One', + color: '#3972F6', + }, + ], + }), + } as any + } + + throw new Error(`Unexpected fetch in mention sources test: ${url}`) +}) + +vi.mock('@/blocks', () => ({ + getAllBlocks: () => mockGetAllBlocks(), + getBlock: (blockType: string) => mockGetBlock(blockType), +})) + +vi.mock('@/blocks/registry', () => ({ + registry: { + condition: { + bgColor: '#3972F6', + icon: null, + name: 'Condition', + }, + }, +})) + +vi.mock('@/lib/yjs/use-workflow-doc', () => ({ + useWorkflowBlocks: () => mockWorkflowBlocks, +})) + +vi.mock('@/lib/yjs/workflow-session-host', () => ({ + useOptionalWorkflowSession: () => + mockWorkflowId + ? { + workflowId: mockWorkflowId, + } + : null, +})) + +type MentionSourcesHookResult = ReturnType + +function MentionSourcesHarness({ + onRender, + workspaceId, +}: { + onRender: (value: MentionSourcesHookResult) => void + workspaceId: string +}) { + const result = useUserInputMentionSources({ workspaceId }) + + useEffect(() => { + onRender(result) + }, [onRender, result]) + + return null +} + +describe('useUserInputMentionSources', () => { + let container: HTMLDivElement + let root: Root + let latestResult: MentionSourcesHookResult | null + + const renderHarness = async ({ + locale, + messages, + }: { + locale: 'es' | 'zh' + messages: unknown + }) => { + await act(async () => { + root.render( + + { + latestResult = value + }} + /> + + ) + }) + } + + beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal('fetch', mockFetch) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + latestResult = null + mockWorkflowId = null + for (const key of Object.keys(mockWorkflowBlocks)) { + delete mockWorkflowBlocks[key] + } + mockGetAllBlocks.mockClear() + mockGetBlock.mockClear() + mockFetch.mockClear() + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false + vi.unstubAllGlobals() + }) + + it('reloads localized block mention labels after a locale change', async () => { + const spanishBlockName = getLocalizedBlockNameWithCopy( + (esMessages as any).workspace.widgets, + mockBlocks[0] + ) + const chineseBlockName = getLocalizedBlockNameWithCopy( + (zhMessages as any).workspace.widgets, + mockBlocks[0] + ) + + await renderHarness({ + locale: 'es', + messages: esMessages, + }) + + await act(async () => { + await latestResult?.ensureBlocksLoaded() + }) + + expect(latestResult?.blocksList.map((item) => item.name)).toEqual([spanishBlockName]) + + await renderHarness({ + locale: 'zh', + messages: zhMessages, + }) + + expect(latestResult?.blocksList).toEqual([]) + + await act(async () => { + await latestResult?.ensureBlocksLoaded() + }) + + expect(latestResult?.blocksList.map((item) => item.name)).toEqual([chineseBlockName]) + expect(mockGetAllBlocks).toHaveBeenCalledTimes(2) + }) + + it('reloads localized workflow block mention labels after a locale change', async () => { + mockWorkflowId = 'workflow-1' + mockWorkflowBlocks['workflow-block-1'] = { + id: 'workflow-block-1', + type: 'condition', + name: 'Condition 2', + } + + const spanishWorkflowBlockName = getLocalizedDefaultBlockNameWithCopy( + (esMessages as any).workspace.widgets, + 'condition', + 'Condition 2' + ) + const chineseWorkflowBlockName = getLocalizedDefaultBlockNameWithCopy( + (zhMessages as any).workspace.widgets, + 'condition', + 'Condition 2' + ) + + await renderHarness({ + locale: 'es', + messages: esMessages, + }) + + await act(async () => { + await latestResult?.ensureWorkflowBlocksLoaded() + }) + + expect(latestResult?.workflowBlocks.map((item) => item.name)).toEqual([ + spanishWorkflowBlockName, + ]) + + await renderHarness({ + locale: 'zh', + messages: zhMessages, + }) + + await act(async () => { + await latestResult?.ensureWorkflowBlocksLoaded() + }) + + expect(latestResult?.workflowBlocks.map((item) => item.name)).toEqual([ + chineseWorkflowBlockName, + ]) + }) +}) 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..3168a2857 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,11 +1,17 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, 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, @@ -50,6 +56,7 @@ const createEmptyWorkspaceEntityLoading = (): Record([]) const [isLoadingPastChats, setIsLoadingPastChats] = useState(false) const [workspaceEntities, setWorkspaceEntities] = useState(createEmptyWorkspaceEntities) @@ -67,6 +74,18 @@ export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionS const workflowSession = useOptionalWorkflowSession() const workflowId = workflowSession?.workflowId ?? null const workflowStoreBlocks = useWorkflowBlocks() + const workflowInspectorCopy = useWorkflowInspectorMessages() + const latestBlocksLocaleRef = useRef(locale) + const latestWorkflowBlocksKeyRef = useRef(`${locale}:${workflowId ?? ''}`) + const workflowBlocksLoadingRef = useRef(false) + + useEffect(() => { + latestBlocksLocaleRef.current = locale + }, [locale]) + + useEffect(() => { + latestWorkflowBlocksKeyRef.current = `${locale}:${workflowId ?? ''}` + }, [locale, workflowId]) const ensurePastChatsLoaded = useCallback(async () => { if (isLoadingPastChats || pastChats.length > 0) { @@ -141,7 +160,7 @@ export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionS setKnowledgeBases( sorted.map((item: any) => ({ id: item.id, - name: item.name || 'Untitled', + name: item.name || '', })) ) } catch { @@ -155,6 +174,8 @@ export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionS return } + const loadLocale = locale + try { setIsLoadingBlocks(true) const { getAllBlocks } = await import('@/blocks') @@ -163,7 +184,7 @@ 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), })) @@ -173,18 +194,24 @@ export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionS .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)) + if (latestBlocksLocaleRef.current !== loadLocale) { + return + } + setBlocksList([...regularBlocks, ...toolBlocks]) } catch { } finally { - setIsLoadingBlocks(false) + if (latestBlocksLocaleRef.current === loadLocale) { + setIsLoadingBlocks(false) + } } - }, [blocksList.length, isLoadingBlocks]) + }, [blocksList.length, isLoadingBlocks, locale, workflowInspectorCopy]) const ensureLogsLoaded = useCallback(async () => { if (isLoadingLogs || logsList.length > 0) { @@ -226,7 +253,7 @@ export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionS }, [isLoadingLogs, logsList.length, workspaceId]) const ensureWorkflowBlocksLoaded = useCallback(async () => { - if (isLoadingWorkflowBlocks) { + if (workflowBlocksLoadingRef.current) { return } @@ -235,7 +262,10 @@ export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionS return } + const loadKey = `${locale}:${workflowId ?? ''}` + try { + workflowBlocksLoadingRef.current = true setIsLoadingWorkflowBlocks(true) const { registry: blockRegistry } = await import('@/blocks/registry') const mapped = Object.values(workflowStoreBlocks).map((block: any) => { @@ -245,24 +275,35 @@ 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', } }) + if (latestWorkflowBlocksKeyRef.current !== loadKey) { + return + } + setWorkflowBlocks(mapped) } catch (error) { logger.error('Failed to sync workflow blocks:', error) } finally { - setIsLoadingWorkflowBlocks(false) + workflowBlocksLoadingRef.current = false + if (latestWorkflowBlocksKeyRef.current === loadKey) { + setIsLoadingWorkflowBlocks(false) + } } - }, [isLoadingWorkflowBlocks, workflowId, workflowStoreBlocks]) + }, [locale, workflowId, workflowInspectorCopy, workflowStoreBlocks]) const ensureSubmenuLoaded = useCallback( async (submenu: MentionSubmenu) => { - if (submenu === 'Chats') { + if (submenu === 'chats') { await ensurePastChatsLoaded() return } @@ -272,17 +313,17 @@ export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionS 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 } @@ -301,12 +342,18 @@ export function useUserInputMentionSources({ workspaceId }: UseUserInputMentionS useEffect(() => { setWorkflowBlocks([]) + workflowBlocksLoadingRef.current = false setIsLoadingWorkflowBlocks(false) }, [workflowId]) + useEffect(() => { + setBlocksList([]) + setIsLoadingBlocks(false) + }, [locale]) + useEffect(() => { void ensureWorkflowBlocksLoaded() - }, [ensureWorkflowBlocksLoaded]) + }, [locale, workflowId, workflowStoreBlocks]) useEffect(() => { if (workflowId && workspaceEntities.workflow.length === 0) { @@ -335,16 +382,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.test.tsx b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/hooks/use-user-input-mentions.test.tsx new file mode 100644 index 000000000..b22a58e6d --- /dev/null +++ b/apps/tradinggoose/widgets/widgets/copilot/components/user-input/hooks/use-user-input-mentions.test.tsx @@ -0,0 +1,287 @@ +/** + * @vitest-environment jsdom + */ + +import { act, useEffect, useRef, useState } from 'react' +import { NextIntlClientProvider } from 'next-intl' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import enMessages from '../../../../../../i18n/messages/en.json' +import esMessages from '../../../../../../i18n/messages/es.json' +import { MENTION_SUBMENUS } from '../constants' +import type { MentionSources } from '../types' +import { useUserInputMentions } from './use-user-input-mentions' + +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean +} + +type MentionHookSnapshot = ReturnType & { + message: string + textarea: HTMLTextAreaElement | null +} + +const createMentionSources = (): MentionSources => ({ + pastChats: [], + workspaceEntities: { + workflow: [], + skill: [], + indicator: [], + custom_tool: [], + mcp_server: [], + }, + knowledgeBases: [], + blocksList: [], + logsList: [], + workflowBlocks: [], +}) + +function MentionsHarness({ + mentionSources, + onRender, + workspaceId, + ensureSubmenuLoaded, +}: { + mentionSources: MentionSources + onRender: (value: MentionHookSnapshot) => void + workspaceId: string + ensureSubmenuLoaded: (submenu: any) => Promise +}) { + const [message, setMessage] = useState('') + const menuListRef = useRef(null) + const textareaRef = useRef(null) + const result = useUserInputMentions({ + disabled: false, + isLoading: false, + menuListRef, + message, + mentionSources, + setMessage, + textareaRef, + workspaceId, + loaders: { + ensureSubmenuLoaded, + }, + }) + + useEffect(() => { + onRender({ + ...result, + message, + textarea: textareaRef.current, + }) + }, [message, onRender, result]) + + return ( + <> +