Skip to content

Commit 3a1b1a8

Browse files
authored
v0.6.43: mothership billing idempotency, env var resolution fixes
2 parents fc07922 + 3d6660b commit 3a1b1a8

File tree

14 files changed

+388
-73
lines changed

14 files changed

+388
-73
lines changed

apps/sim/app/api/billing/update-cost/route.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { recordUsage } from '@/lib/billing/core/usage-log'
66
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
77
import { checkInternalApiKey } from '@/lib/copilot/request/http'
88
import { isBillingEnabled } from '@/lib/core/config/feature-flags'
9+
import { type AtomicClaimResult, billingIdempotency } from '@/lib/core/idempotency/service'
910
import { generateRequestId } from '@/lib/core/utils/request'
1011

1112
const logger = createLogger('BillingUpdateCostAPI')
@@ -19,6 +20,7 @@ const UpdateCostSchema = z.object({
1920
source: z
2021
.enum(['copilot', 'workspace-chat', 'mcp_copilot', 'mothership_block'])
2122
.default('copilot'),
23+
idempotencyKey: z.string().min(1).optional(),
2224
})
2325

2426
/**
@@ -28,6 +30,8 @@ const UpdateCostSchema = z.object({
2830
export async function POST(req: NextRequest) {
2931
const requestId = generateRequestId()
3032
const startTime = Date.now()
33+
let claim: AtomicClaimResult | null = null
34+
let usageCommitted = false
3135

3236
try {
3337
logger.info(`[${requestId}] Update cost request started`)
@@ -75,9 +79,30 @@ export async function POST(req: NextRequest) {
7579
)
7680
}
7781

78-
const { userId, cost, model, inputTokens, outputTokens, source } = validation.data
82+
const { userId, cost, model, inputTokens, outputTokens, source, idempotencyKey } =
83+
validation.data
7984
const isMcp = source === 'mcp_copilot'
8085

86+
claim = idempotencyKey
87+
? await billingIdempotency.atomicallyClaim('update-cost', idempotencyKey)
88+
: null
89+
90+
if (claim && !claim.claimed) {
91+
logger.warn(`[${requestId}] Duplicate billing update rejected`, {
92+
idempotencyKey,
93+
userId,
94+
source,
95+
})
96+
return NextResponse.json(
97+
{
98+
success: false,
99+
error: 'Duplicate request: idempotency key already processed',
100+
requestId,
101+
},
102+
{ status: 409 }
103+
)
104+
}
105+
81106
logger.info(`[${requestId}] Processing cost update`, {
82107
userId,
83108
cost,
@@ -113,6 +138,7 @@ export async function POST(req: NextRequest) {
113138
],
114139
additionalStats,
115140
})
141+
usageCommitted = true
116142

117143
logger.info(`[${requestId}] Recorded usage`, {
118144
userId,
@@ -149,6 +175,22 @@ export async function POST(req: NextRequest) {
149175
duration,
150176
})
151177

178+
if (claim?.claimed && !usageCommitted) {
179+
await billingIdempotency
180+
.release(claim.normalizedKey, claim.storageMethod)
181+
.catch((releaseErr) => {
182+
logger.warn(`[${requestId}] Failed to release idempotency claim`, {
183+
error: releaseErr instanceof Error ? releaseErr.message : String(releaseErr),
184+
normalizedKey: claim?.normalizedKey,
185+
})
186+
})
187+
} else if (claim?.claimed && usageCommitted) {
188+
logger.warn(
189+
`[${requestId}] Error occurred after usage committed; retaining idempotency claim to prevent double-billing`,
190+
{ normalizedKey: claim.normalizedKey }
191+
)
192+
}
193+
152194
return NextResponse.json(
153195
{
154196
success: false,

apps/sim/app/api/tools/google_drive/download/route.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
ALL_REVISION_FIELDS,
1414
DEFAULT_EXPORT_FORMATS,
1515
GOOGLE_WORKSPACE_MIME_TYPES,
16+
VALID_EXPORT_FORMATS,
1617
} from '@/tools/google_drive/utils'
1718

1819
export const dynamic = 'force-dynamic'
@@ -65,10 +66,12 @@ export async function POST(request: NextRequest) {
6566
const {
6667
accessToken,
6768
fileId,
68-
mimeType: exportMimeType,
69+
mimeType: rawExportMimeType,
6970
fileName,
7071
includeRevisions,
7172
} = validatedData
73+
const exportMimeType =
74+
rawExportMimeType && rawExportMimeType !== 'auto' ? rawExportMimeType : null
7275
const authHeader = `Bearer ${accessToken}`
7376

7477
logger.info(`[${requestId}] Getting file metadata from Google Drive`, { fileId })
@@ -112,6 +115,24 @@ export async function POST(request: NextRequest) {
112115

113116
if (GOOGLE_WORKSPACE_MIME_TYPES.includes(fileMimeType)) {
114117
const exportFormat = exportMimeType || DEFAULT_EXPORT_FORMATS[fileMimeType] || 'text/plain'
118+
119+
const validFormats = VALID_EXPORT_FORMATS[fileMimeType]
120+
if (validFormats && !validFormats.includes(exportFormat)) {
121+
logger.warn(`[${requestId}] Unsupported export format requested`, {
122+
fileId,
123+
fileMimeType,
124+
requestedFormat: exportFormat,
125+
validFormats,
126+
})
127+
return NextResponse.json(
128+
{
129+
success: false,
130+
error: `Export format "${exportFormat}" is not supported for this file type. Supported formats: ${validFormats.join(', ')}`,
131+
},
132+
{ status: 400 }
133+
)
134+
}
135+
115136
finalMimeType = exportFormat
116137

117138
logger.info(`[${requestId}] Exporting Google Workspace file`, {

apps/sim/app/api/tools/jira/update/route.ts

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { z } from 'zod'
44
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
55
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
6-
import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils'
6+
import { getJiraCloudId, parseAtlassianErrorMessage, toAdf } from '@/tools/jira/utils'
77

88
export const dynamic = 'force-dynamic'
99

@@ -15,14 +15,14 @@ const jiraUpdateSchema = z.object({
1515
issueKey: z.string().min(1, 'Issue key is required'),
1616
summary: z.string().optional(),
1717
title: z.string().optional(),
18-
description: z.string().optional(),
18+
description: z.union([z.string(), z.record(z.unknown())]).optional(),
1919
priority: z.string().optional(),
2020
assignee: z.string().optional(),
2121
labels: z.array(z.string()).optional(),
2222
components: z.array(z.string()).optional(),
2323
duedate: z.string().optional(),
2424
fixVersions: z.array(z.string()).optional(),
25-
environment: z.string().optional(),
25+
environment: z.union([z.string(), z.record(z.unknown())]).optional(),
2626
customFieldId: z.string().optional(),
2727
customFieldValue: z.string().optional(),
2828
notifyUsers: z.boolean().optional(),
@@ -91,21 +91,7 @@ export async function PUT(request: NextRequest) {
9191
}
9292

9393
if (description !== undefined && description !== null && description !== '') {
94-
fields.description = {
95-
type: 'doc',
96-
version: 1,
97-
content: [
98-
{
99-
type: 'paragraph',
100-
content: [
101-
{
102-
type: 'text',
103-
text: description,
104-
},
105-
],
106-
},
107-
],
108-
}
94+
fields.description = toAdf(description)
10995
}
11096

11197
if (priority !== undefined && priority !== null && priority !== '') {
@@ -136,21 +122,7 @@ export async function PUT(request: NextRequest) {
136122
}
137123

138124
if (environment !== undefined && environment !== null && environment !== '') {
139-
fields.environment = {
140-
type: 'doc',
141-
version: 1,
142-
content: [
143-
{
144-
type: 'paragraph',
145-
content: [
146-
{
147-
type: 'text',
148-
text: environment,
149-
},
150-
],
151-
},
152-
],
153-
}
125+
fields.environment = toAdf(environment)
154126
}
155127

156128
if (

apps/sim/app/api/tools/jira/write/route.ts

Lines changed: 3 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
44
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
5-
import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils'
5+
import { getJiraCloudId, parseAtlassianErrorMessage, toAdf } from '@/tools/jira/utils'
66

77
export const dynamic = 'force-dynamic'
88

@@ -85,21 +85,7 @@ export async function POST(request: NextRequest) {
8585
}
8686

8787
if (description !== undefined && description !== null && description !== '') {
88-
fields.description = {
89-
type: 'doc',
90-
version: 1,
91-
content: [
92-
{
93-
type: 'paragraph',
94-
content: [
95-
{
96-
type: 'text',
97-
text: description,
98-
},
99-
],
100-
},
101-
],
102-
}
88+
fields.description = toAdf(description)
10389
}
10490

10591
if (parent !== undefined && parent !== null && parent !== '') {
@@ -144,21 +130,7 @@ export async function POST(request: NextRequest) {
144130
}
145131

146132
if (environment !== undefined && environment !== null && environment !== '') {
147-
fields.environment = {
148-
type: 'doc',
149-
version: 1,
150-
content: [
151-
{
152-
type: 'paragraph',
153-
content: [
154-
{
155-
type: 'text',
156-
text: environment,
157-
},
158-
],
159-
},
160-
],
161-
}
133+
fields.environment = toAdf(environment)
162134
}
163135

164136
if (
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockResolveWebhookRecordProviderConfig } = vi.hoisted(() => ({
8+
mockResolveWebhookRecordProviderConfig: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/webhooks/env-resolver', () => ({
12+
resolveWebhookRecordProviderConfig: mockResolveWebhookRecordProviderConfig,
13+
}))
14+
15+
import { resolveWebhookExecutionProviderConfig } from './webhook-execution'
16+
17+
describe('resolveWebhookExecutionProviderConfig', () => {
18+
beforeEach(() => {
19+
vi.clearAllMocks()
20+
})
21+
22+
it('returns the resolved webhook record when provider config resolution succeeds', async () => {
23+
const webhookRecord = {
24+
id: 'webhook-1',
25+
providerConfig: {
26+
botToken: '{{SLACK_BOT_TOKEN}}',
27+
},
28+
}
29+
const resolvedWebhookRecord = {
30+
...webhookRecord,
31+
providerConfig: {
32+
botToken: 'xoxb-resolved',
33+
},
34+
}
35+
36+
mockResolveWebhookRecordProviderConfig.mockResolvedValue(resolvedWebhookRecord)
37+
38+
await expect(
39+
resolveWebhookExecutionProviderConfig(webhookRecord, 'slack', 'user-1', 'workspace-1')
40+
).resolves.toEqual(resolvedWebhookRecord)
41+
42+
expect(mockResolveWebhookRecordProviderConfig).toHaveBeenCalledWith(
43+
webhookRecord,
44+
'user-1',
45+
'workspace-1'
46+
)
47+
})
48+
49+
it('throws a contextual error when provider config resolution fails', async () => {
50+
mockResolveWebhookRecordProviderConfig.mockRejectedValue(new Error('env lookup failed'))
51+
52+
await expect(
53+
resolveWebhookExecutionProviderConfig(
54+
{
55+
id: 'webhook-1',
56+
providerConfig: {
57+
botToken: '{{SLACK_BOT_TOKEN}}',
58+
},
59+
},
60+
'slack',
61+
'user-1',
62+
'workspace-1'
63+
)
64+
).rejects.toThrow(
65+
'Failed to resolve webhook provider config for slack webhook webhook-1: env lookup failed'
66+
)
67+
})
68+
})

apps/sim/background/webhook-execution.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { preprocessExecution } from '@/lib/execution/preprocessing'
1111
import { LoggingSession } from '@/lib/logs/execution/logging-session'
1212
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
1313
import { WebhookAttachmentProcessor } from '@/lib/webhooks/attachment-processor'
14+
import { resolveWebhookRecordProviderConfig } from '@/lib/webhooks/env-resolver'
1415
import { getProviderHandler } from '@/lib/webhooks/providers'
1516
import {
1617
executeWorkflowCore,
@@ -168,6 +169,24 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) {
168169
)
169170
}
170171

172+
export async function resolveWebhookExecutionProviderConfig<
173+
T extends { id: string; providerConfig?: unknown },
174+
>(
175+
webhookRecord: T,
176+
provider: string,
177+
userId: string,
178+
workspaceId?: string
179+
): Promise<T & { providerConfig: Record<string, unknown> }> {
180+
try {
181+
return await resolveWebhookRecordProviderConfig(webhookRecord, userId, workspaceId)
182+
} catch (error) {
183+
const errorMessage = error instanceof Error ? error.message : String(error)
184+
throw new Error(
185+
`Failed to resolve webhook provider config for ${provider} webhook ${webhookRecord.id}: ${errorMessage}`
186+
)
187+
}
188+
}
189+
171190
async function resolveCredentialAccountUserId(credentialId: string): Promise<string | undefined> {
172191
const resolved = await resolveOAuthAccountId(credentialId)
173192
if (!resolved) {
@@ -300,9 +319,16 @@ async function executeWebhookJobInternal(
300319
throw new Error(`Webhook record not found: ${payload.webhookId}`)
301320
}
302321

322+
const resolvedWebhookRecord = await resolveWebhookExecutionProviderConfig(
323+
webhookRecord,
324+
payload.provider,
325+
workflowRecord.userId,
326+
workspaceId
327+
)
328+
303329
if (handler.formatInput) {
304330
const result = await handler.formatInput({
305-
webhook: webhookRecord,
331+
webhook: resolvedWebhookRecord,
306332
workflow: { id: payload.workflowId, userId: payload.userId },
307333
body: payload.body,
308334
headers: payload.headers,

0 commit comments

Comments
 (0)