Skip to content

Commit 0ee0b5c

Browse files
committed
Merge remote-tracking branch 'origin/staging' into fix/chat-code-not-copied-inline
# Conflicts: # scripts/check-tool-registry-boundary.baseline.json
2 parents 010a8f6 + 6b7fd1a commit 0ee0b5c

7 files changed

Lines changed: 205 additions & 64 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it, vi } from 'vitest'
5+
import { getBlockSchema } from '@/executor/utils/block-data'
6+
import { resolveBlockReference } from '@/executor/utils/block-reference'
7+
import type { SerializedBlock } from '@/serializer/types'
8+
9+
/**
10+
* These assertions are about what the real block registry publishes, so the global stub — which
11+
* returns one mock block with no outputs — would make every case here pass vacuously.
12+
*/
13+
vi.unmock('@/blocks/registry')
14+
15+
function triggerBlock(type: string, params: Record<string, unknown> = {}): SerializedBlock {
16+
return {
17+
id: 'trigger-1',
18+
metadata: { id: type, name: 'webhook1', category: 'triggers' },
19+
position: { x: 0, y: 0 },
20+
config: { tool: '', params },
21+
inputs: {},
22+
outputs: {},
23+
enabled: true,
24+
} as unknown as SerializedBlock
25+
}
26+
27+
function resolve(
28+
pathParts: string[],
29+
schema: ReturnType<typeof getBlockSchema>
30+
): ReturnType<typeof resolveBlockReference> {
31+
return resolveBlockReference(
32+
'webhook1',
33+
pathParts,
34+
{
35+
blockNameMapping: { webhook1: 'trigger-1' },
36+
blockData: { 'trigger-1': { query: { env: 'prod' } } },
37+
blockOutputSchemas: schema ? { 'trigger-1': schema } : {},
38+
} as never,
39+
{} as never
40+
)
41+
}
42+
43+
describe('generic webhook output schema', () => {
44+
/**
45+
* A generic webhook receives whatever the caller sends, so it must publish no schema at all.
46+
* `collectBlockData` registers any non-empty output declaration as exhaustive, which turns
47+
* every unlisted field into a hard `InvalidFieldError` rather than an absent value.
48+
*/
49+
it('publishes no output schema, leaving the block shape open', () => {
50+
expect(getBlockSchema(triggerBlock('generic_webhook'))).toBeUndefined()
51+
})
52+
53+
it.each([
54+
[{}, 'no flags set'],
55+
[{ acceptOtherMethods: true, exposeRequestHeaders: true }, 'both request-metadata flags on'],
56+
])('stays open with %o (%s)', (params) => {
57+
expect(getBlockSchema(triggerBlock('generic_webhook', params))).toBeUndefined()
58+
})
59+
60+
/**
61+
* The production regression this pins: a Slack interactive payload reaching a workflow that
62+
* reads `actions.0.selected_option.value`. When a delivery omits the field the reference must
63+
* resolve to `undefined` so the condition simply evaluates falsy — not abort the run.
64+
*/
65+
it('resolves an absent body field to undefined instead of throwing', () => {
66+
const schema = getBlockSchema(triggerBlock('generic_webhook'))
67+
68+
expect(() => resolve(['actions', '0', 'selected_option', 'value'], schema)).not.toThrow()
69+
expect(resolve(['actions', '0', 'selected_option', 'value'], schema)?.value).toBeUndefined()
70+
})
71+
72+
it('still resolves request metadata the provider merges into the input', () => {
73+
const schema = getBlockSchema(triggerBlock('generic_webhook'))
74+
75+
expect(resolve(['query', 'env'], schema)?.value).toBe('prod')
76+
})
77+
})

apps/sim/lib/knowledge/connectors/sync-limits.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@ export const CONNECTOR_SYNC_MAX_DURATION_SECONDS = 3600
1616
* lock for another sync, so a TTL at or below the run ceiling would start a second
1717
* sync while the first is still writing, both racing the same documents.
1818
*
19-
* Measured against `updatedAt`, which a running sync refreshes every
20-
* {@link SYNC_LOCK_HEARTBEAT_INTERVAL_MS}. That is what makes the TTL mean
19+
* Measured against `COALESCE(syncLockLeaseAt, updatedAt)`, the lease a running
20+
* sync refreshes every {@link SYNC_LOCK_HEARTBEAT_INTERVAL_MS}. The lease is a
21+
* dedicated column precisely so an unrelated write to the row — a config edit on
22+
* a wedged connector — can no longer pass for a heartbeat; `updatedAt` remains
23+
* only as the fallback for a row locked before that column existed. That is what makes the TTL mean
2124
* "nobody is working on this" rather than "this started a long time ago" — the
2225
* distinction the in-process fallback path needs. A Trigger.dev run is killed at
2326
* {@link CONNECTOR_SYNC_MAX_DURATION_SECONDS} and so is provably dead well before

apps/sim/lib/knowledge/documents/processing-queue.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,3 +254,73 @@ describe('processDocumentsWithQueue dispatch backend', () => {
254254
expect(mockBatchTrigger).not.toHaveBeenCalled()
255255
})
256256
})
257+
258+
/**
259+
* The processing-attempt budget exists to stop re-billing a document that keeps
260+
* failing the same way *in processing*. A dispatch that never reached a worker
261+
* teaches it nothing, so the charge is given back on the one path that proves
262+
* nothing was dispatched. Without the refund a Trigger.dev outage burns the
263+
* allowance without a single run, and after `MAX_PROCESSING_ATTEMPTS` of them
264+
* the connector sweep — which skips documents at the cap — permanently stops
265+
* recovering them.
266+
*/
267+
describe('processDocumentsWithQueue attempt refund', () => {
268+
beforeEach(() => {
269+
vi.clearAllMocks()
270+
resetDbChainMock()
271+
dbChainMockFns.returning.mockResolvedValue([{ id: 'document-1' }])
272+
for (const key of Object.keys(env)) {
273+
delete (env as Record<string, unknown>)[key]
274+
}
275+
Object.assign(env, { ...defaultMockEnv, TRIGGER_SECRET_KEY: 'trigger-secret' })
276+
dbChainMockFns.limit.mockResolvedValue([
277+
{ userId: 'knowledge-owner', workspaceId: 'workspace-1' },
278+
])
279+
})
280+
281+
it('refunds the attempt in the same write that withdraws the queue stamp', async () => {
282+
mockBatchTrigger.mockRejectedValue(new Error('trigger.dev region unavailable'))
283+
284+
await expect(
285+
processDocumentsWithQueue(
286+
[DOCUMENT],
287+
'knowledge-base-1',
288+
{},
289+
'request-1',
290+
BILLING_ATTRIBUTION
291+
)
292+
).rejects.toThrow('document processing dispatches failed')
293+
294+
const withdrawCall = dbChainMockFns.set.mock.calls.find(
295+
(call) => (call[0] as Record<string, unknown> | undefined)?.processingQueuedAt === null
296+
)
297+
expect(withdrawCall).toBeDefined()
298+
299+
const values = withdrawCall?.[0] as Record<string, unknown>
300+
const attempts = values.processingAttempts as { toSQL: () => { sql: string } } | undefined
301+
expect(attempts).toBeDefined()
302+
// Given back as a SQL decrement in the same guarded statement as the stamp,
303+
// so it can only ever undo the charge this call made.
304+
expect(attempts?.toSQL().sql).toContain('- 1')
305+
// Floored, so a refund can never drive the count below zero.
306+
expect(attempts?.toSQL().sql).toContain('GREATEST')
307+
})
308+
309+
it('leaves the attempt spent when a dispatch did get through', async () => {
310+
mockBatchTrigger.mockResolvedValue({ batchId: 'batch-1' })
311+
312+
await processDocumentsWithQueue(
313+
[DOCUMENT],
314+
'knowledge-base-1',
315+
{},
316+
'request-1',
317+
BILLING_ATTRIBUTION
318+
)
319+
320+
expect(
321+
dbChainMockFns.set.mock.calls.some(
322+
(call) => (call[0] as Record<string, unknown> | undefined)?.processingQueuedAt === null
323+
)
324+
).toBe(false)
325+
})
326+
})

apps/sim/lib/knowledge/documents/service.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,8 @@ async function markDocumentsQueued(documentIds: string[], queuedAt: Date): Promi
717717
// Spent here because this is the one write every dispatch passes through,
718718
// and it is already guarded — so the budget cannot be charged twice for a
719719
// single dispatch, nor skipped by a caller that dispatches another way.
720+
// Refunded by `clearDocumentsQueued` when the dispatch provably never
721+
// happened, so only attempts a worker could have seen are ever spent.
720722
processingAttempts: sql`${document.processingAttempts} + 1`,
721723
})
722724
.where(and(inArray(document.id, documentIds), eq(document.processingStatus, 'pending')))
@@ -733,6 +735,18 @@ async function markDocumentsQueued(documentIds: string[], queuedAt: Date): Promi
733735
* failure is the one case where nothing was dispatched, so the stamp can be
734736
* taken back and the next sweep is free to reclaim them immediately.
735737
*
738+
* The attempt {@link markDocumentsQueued} charged is refunded in the same
739+
* statement. The budget exists to stop re-billing a document that keeps failing
740+
* the same way *in processing*; an attempt that never reached a worker teaches
741+
* it nothing. Leaving it spent let an infrastructure outage — a Trigger.dev
742+
* region error, an exhausted quota — burn the allowance without a single run,
743+
* and {@link MAX_PROCESSING_ATTEMPTS} such outages dead-letter a document the
744+
* connector sweep then permanently excludes (`processingAttempts <
745+
* MAX_PROCESSING_ATTEMPTS`), stranding it with no automatic recovery left.
746+
* Floored at zero so a refund can never drive the count negative, and scoped by
747+
* the same guard as the stamp, so it can only ever give back the charge this
748+
* call made.
749+
*
736750
* Scoped three ways so it can only ever undo its own write: to the ids in this
737751
* batch, to rows still `pending` (a worker that has since claimed one keeps its
738752
* timestamps — see {@link markDocumentsQueued}), and to the exact stamp this
@@ -742,7 +756,10 @@ async function markDocumentsQueued(documentIds: string[], queuedAt: Date): Promi
742756
async function clearDocumentsQueued(documentIds: string[], queuedAt: Date): Promise<void> {
743757
await db
744758
.update(document)
745-
.set({ processingQueuedAt: null })
759+
.set({
760+
processingQueuedAt: null,
761+
processingAttempts: sql`GREATEST(${document.processingAttempts} - 1, 0)`,
762+
})
746763
.where(
747764
and(
748765
inArray(document.id, documentIds),

apps/sim/lib/knowledge/documents/types.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
* that fails deterministically (a corrupt file, an unsupported encoding) was
88
* billed once per sync indefinitely. Five is chosen against the unit that is
99
* actually consumed: one attempt per *dispatch*, not per Trigger.dev retry, so
10-
* a short-interval connector can burn several inside one transient outage.
11-
* Three left too little room for that; five still bounds the spend well inside
12-
* `RETRY_WINDOW_DAYS`.
10+
* a short-interval connector can still burn several inside one transient
11+
* outage. A dispatch that provably reached nothing is refunded — see
12+
* `clearDocumentsQueued` — which covers the total-failure shape, but a partial
13+
* batch failure and an accepted dispatch whose run never starts both stay
14+
* charged. Three left too little room for those; five still bounds the spend
15+
* well inside `RETRY_WINDOW_DAYS`.
1316
*
1417
* Reaching it is a dead letter, not a deletion: the document keeps its `failed`
1518
* status and stays user-retryable, it simply stops being swept automatically.

apps/sim/triggers/generic/webhook.test.ts

Lines changed: 16 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ function setupInstructions(): string {
1313
}
1414

1515
describe('genericWebhookTrigger', () => {
16-
it('declares the request metadata so it can be referenced from later blocks', () => {
17-
expect(Object.keys(genericWebhookTrigger.outputs)).toEqual(['method', 'query', 'headers'])
18-
expect(genericWebhookTrigger.outputs.method.type).toBe('string')
19-
expect(genericWebhookTrigger.outputs.query.type).toBe('object')
20-
expect(genericWebhookTrigger.outputs.headers.type).toBe('object')
16+
/**
17+
* Declaring outputs here does not add editor completions — the executor reads the same list as
18+
* an exhaustive schema and rejects every field outside it. See
19+
* `executor/utils/block-data.test.ts` for the behavior this protects.
20+
*/
21+
it('declares no outputs, because the caller decides the payload shape', () => {
22+
expect(genericWebhookTrigger.outputs).toEqual({})
2123
})
2224

2325
/**
@@ -40,40 +42,21 @@ describe('genericWebhookTrigger', () => {
4042
expect(instructions).toContain('GET, PUT, PATCH and DELETE')
4143
})
4244

43-
it('names every reserved key the input can carry', () => {
44-
const instructions = setupInstructions()
45-
46-
for (const key of Object.keys(genericWebhookTrigger.outputs)) {
47-
expect(instructions).toContain(`"${key}"`)
45+
/**
46+
* Named explicitly rather than derived from `outputs`, which is intentionally empty — deriving
47+
* it would make this assertion vacuous.
48+
*/
49+
it.each(['method', 'query', 'headers'])(
50+
'names the reserved "%s" key the input can carry',
51+
(key) => {
52+
expect(setupInstructions()).toContain(`"${key}"`)
4853
}
49-
})
54+
)
5055

5156
it('names the switch that exposes headers rather than promising them', () => {
5257
expect(setupInstructions()).toContain('"Expose Request Headers"')
5358
})
5459

55-
/**
56-
* Two of the three outputs only exist once a switch is on, so they are conditioned on it: the
57-
* reference dropdown must not offer a field the running webhook will not send.
58-
*/
59-
it.each([
60-
['method', 'acceptOtherMethods'],
61-
['headers', 'exposeRequestHeaders'],
62-
])('gates the %s output on the switch that produces it', (key, field) => {
63-
expect(genericWebhookTrigger.outputs[key].condition).toEqual({
64-
field,
65-
value: [true, 'true'],
66-
})
67-
})
68-
69-
/**
70-
* Query parameters are the one key that is not opt-in, so offering them unconditionally is
71-
* correct — gating them on a switch that does not exist would hide them entirely.
72-
*/
73-
it('offers query unconditionally', () => {
74-
expect(genericWebhookTrigger.outputs.query.condition).toBeUndefined()
75-
})
76-
7760
/**
7861
* Auth is header-based, so a plain link cannot carry it. Saying so is the difference between a
7962
* user disabling auth knowingly and discovering it after publishing an open trigger URL.

apps/sim/triggers/generic/webhook.ts

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -152,33 +152,21 @@ export const genericWebhookTrigger: TriggerConfig = {
152152
],
153153

154154
/**
155-
* Body fields stay undeclared because a generic webhook receives whatever JSON the caller
156-
* sends. The request metadata below is known ahead of time, so it can be offered for reference.
155+
* Deliberately empty, and it must stay that way.
157156
*
158-
* `method` and `headers` are conditioned on the switch that produces them, so the reference
159-
* dropdown never offers a field the running webhook will not send. Both truthy forms are
160-
* matched because a YAML- or Copilot-authored workflow can write the string rather than the
161-
* boolean — the same tolerance `isProviderConfigFlagEnabled` applies at delivery time.
157+
* A generic webhook receives whatever the caller sends, so its output shape is unknowable. The
158+
* executor treats any non-empty output declaration as an exhaustive schema: `collectBlockData`
159+
* registers it, and `resolveBlockReference` then throws `InvalidFieldError` for any reference
160+
* outside it that resolves to `undefined`. Declaring `method`, `query` and `headers` here
161+
* therefore did not add three completions — it made those three the *only* legal fields, and
162+
* every workflow reading a body field failed the moment a delivery omitted it.
163+
*
164+
* The metadata is still merged into the input at delivery time by the generic provider's
165+
* `formatInput`; it is only undeclared, which is what keeps the block's shape open. Offering
166+
* these as editor completions needs a way to mark outputs as hints rather than a closed schema,
167+
* which is a change to `getRegistrySchema`, not to this list.
162168
*/
163-
outputs: {
164-
method: {
165-
type: 'string',
166-
description:
167-
'HTTP method of the request. Yields to a body field of the same name if the caller sends one.',
168-
condition: { field: 'acceptOtherMethods', value: [true, 'true'] },
169-
},
170-
query: {
171-
type: 'object',
172-
description:
173-
'Query parameters from the request URL, when it has any. Yields to a body field of the same name if the caller sends one.',
174-
},
175-
headers: {
176-
type: 'object',
177-
description:
178-
'Request headers, excluding the ones that carry credentials. Yields to a body field of the same name if the caller sends one.',
179-
condition: { field: 'exposeRequestHeaders', value: [true, 'true'] },
180-
},
181-
},
169+
outputs: {},
182170

183171
webhook: {
184172
method: 'POST',

0 commit comments

Comments
 (0)