Skip to content

Commit 30e039b

Browse files
authored
Merge branch 'staging' into fix/loading-followups2
2 parents 80ec0ee + 86dbd0a commit 30e039b

38 files changed

Lines changed: 1570 additions & 175 deletions

File tree

apps/docs/openapi-v2-files-audit.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,9 +1036,9 @@
10361036
"name": "resourceType",
10371037
"in": "query",
10381038
"required": false,
1039-
"description": "Filter by exact resource type.",
1039+
"description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.",
10401040
"schema": {
1041-
"description": "Filter by exact resource type.",
1041+
"description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.",
10421042
"type": "string"
10431043
}
10441044
},

apps/docs/openapi-v2-knowledge.json

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2743,9 +2743,22 @@
27432743
},
27442744
"operator": {
27452745
"default": "eq",
2746-
"description": "Comparison operator; valid operators depend on the field type.",
2746+
"description": "Comparison operator; valid operators depend on the field type. Text tags accept eq, neq, contains, not_contains, starts_with, ends_with; number and date tags accept eq, neq, gt, gte, lt, lte, between; boolean tags accept eq, neq. An operator the tag's field type does not implement is rejected, never ignored.",
27472747
"examples": ["eq"],
2748-
"type": "string"
2748+
"type": "string",
2749+
"enum": [
2750+
"eq",
2751+
"neq",
2752+
"contains",
2753+
"not_contains",
2754+
"starts_with",
2755+
"ends_with",
2756+
"gt",
2757+
"gte",
2758+
"lt",
2759+
"lte",
2760+
"between"
2761+
]
27492762
},
27502763
"value": {
27512764
"anyOf": [
@@ -2763,7 +2776,7 @@
27632776
"examples": ["billing"]
27642777
},
27652778
"valueTo": {
2766-
"description": "Upper bound for the `between` operator.",
2779+
"description": "Upper bound for the `between` operator, and required whenever that operator is used.",
27672780
"anyOf": [
27682781
{
27692782
"type": "string"
@@ -2775,6 +2788,7 @@
27752788
}
27762789
},
27772790
"required": ["tagName", "value"],
2791+
"additionalProperties": false,
27782792
"title": "Knowledge search tag filter",
27792793
"description": "A structured tag filter applied to knowledge search."
27802794
},

apps/docs/openapi-v2-logs.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,20 +133,24 @@
133133
"name": "minCost",
134134
"in": "query",
135135
"required": false,
136-
"description": "Minimum execution cost in USD.",
136+
"description": "Minimum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run.",
137137
"schema": {
138138
"type": "number",
139-
"description": "Minimum execution cost in USD."
139+
"minimum": 0,
140+
"maximum": 1000000,
141+
"description": "Minimum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run."
140142
}
141143
},
142144
{
143145
"name": "maxCost",
144146
"in": "query",
145147
"required": false,
146-
"description": "Maximum execution cost in USD.",
148+
"description": "Maximum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run.",
147149
"schema": {
148150
"type": "number",
149-
"description": "Maximum execution cost in USD."
151+
"minimum": 0,
152+
"maximum": 1000000,
153+
"description": "Maximum execution cost in USD, from 0 to 1000000. A run is never charged a negative amount, so a negative bound is rejected rather than treated as a filter that matches every run."
150154
}
151155
},
152156
{

apps/realtime/src/handlers/file-doc-store.test.ts

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ interface Backing {
2020
readerClosed: boolean
2121
/** Failed reads served, so a test can prove the loop is not spinning at the read cadence. */
2222
reads: number
23-
/** When each read was attempted, so a test can assert the BACKOFF rather than a count in a window. */
24-
readTimes: number[]
23+
/** When each FAILED read was attempted, so a test can measure one backoff interval exactly. */
24+
failedReadTimes: number[]
25+
/** Reads that returned (the idle steady state) — the event that ends a failure streak. */
26+
idleReads: number
2527
/** `connect()` calls, so a test can prove a closed reader is re-opened rather than abandoned. */
2628
connects: number
2729
}
@@ -66,8 +68,8 @@ function makeClient(): any {
6668
},
6769
xRead: async (streams: { key: string; id: string }[]) => {
6870
b().reads++
69-
b().readTimes.push(Date.now())
7071
if (b().readerClosed) {
72+
b().failedReadTimes.push(Date.now())
7173
client.isOpen = false
7274
throw new Error('The client is closed')
7375
}
@@ -77,8 +79,12 @@ function makeClient(): any {
7779
const after = (b().streams.get(key) ?? []).filter((e) => seqOf(e.id) > seqOf(id))
7880
if (after.length) res.push({ name: key, messages: after.map((e) => ({ ...e })) })
7981
}
80-
if (res.length) return res
82+
if (res.length) {
83+
b().idleReads++
84+
return res
85+
}
8186
await sleep(5)
87+
b().idleReads++
8288
return null
8389
},
8490
set: async (key: string, val: string, opts?: { NX?: boolean }) => {
@@ -155,7 +161,8 @@ describe('FileDocStore', () => {
155161
failXAdd: 0,
156162
readerClosed: false,
157163
reads: 0,
158-
readTimes: [],
164+
failedReadTimes: [],
165+
idleReads: 0,
159166
connects: 0,
160167
}
161168
stores = []
@@ -201,42 +208,46 @@ describe('FileDocStore', () => {
201208
const doc = new Y.Doc()
202209
await store.attachRoom(NAME, doc)
203210

204-
// Build a streak of two failures. Waited for rather than slept through: on a loaded machine a
205-
// fixed window can pass with fewer failures than the streak this test needs.
211+
// Build a streak of two failures (the retries back off ~0.5s, then ~1s).
206212
state.backing!.readerClosed = true
207-
const beforeStreak = state.backing!.reads
208-
await vi.waitFor(() => expect(state.backing!.reads).toBeGreaterThanOrEqual(beforeStreak + 2), {
209-
timeout: 5000,
210-
interval: 25,
211-
})
213+
await vi.waitFor(
214+
() => expect(state.backing!.failedReadTimes.length).toBeGreaterThanOrEqual(2),
215+
{
216+
timeout: 5000,
217+
interval: 25,
218+
}
219+
)
212220

213-
// Redis comes back. Wait for a read to actually LAND — `xRead` only throws while the reader is
214-
// closed, so the next one to arrive is the idle read whose return ends the streak. Sleeping a
215-
// fixed 1s instead lets a slow machine finish the window with the pending backoff still
216-
// outstanding, leaving the streak alive and the assertion below measuring a delay this test
217-
// never meant to produce.
221+
// Redis comes back. Wait for a read to actually RETURN — waiting a fixed span instead is a race:
222+
// the pending backoff can outlast it, no idle read lands, and the streak survives into the phase
223+
// below, which then measures the wrong backoff and fails. That is an event, so wait on the event.
218224
state.backing!.readerClosed = false
219-
const beforeIdle = state.backing!.reads
220-
await vi.waitFor(() => expect(state.backing!.reads).toBeGreaterThan(beforeIdle), {
225+
const idleBefore = state.backing!.idleReads
226+
await vi.waitFor(() => expect(state.backing!.idleReads).toBeGreaterThan(idleBefore), {
221227
timeout: 5000,
222228
interval: 25,
223229
})
224230

225231
// A fresh blip must retry at the START of the backoff curve, not partway up it. Assert the DELAY
226232
// itself: counting attempts inside a fixed window cannot tell the two apart, because the jittered
227233
// delay for a carried streak (1.6–2.4s) overlaps any window wide enough to catch a reset one.
234+
// Measure FAILURE to FAILURE so the sample is exactly one backoff — a straggler successful read
235+
// landing just after the flag flips would otherwise become the first sample and pass trivially.
228236
state.backing!.readerClosed = true
229-
state.backing!.readTimes.length = 0
230-
await vi.waitFor(() => expect(state.backing!.readTimes.length).toBeGreaterThanOrEqual(2), {
231-
timeout: 5000,
232-
interval: 50,
233-
})
234-
const [first, second] = state.backing!.readTimes
237+
state.backing!.failedReadTimes.length = 0
238+
await vi.waitFor(
239+
() => expect(state.backing!.failedReadTimes.length).toBeGreaterThanOrEqual(2),
240+
{
241+
timeout: 6000,
242+
interval: 25,
243+
}
244+
)
245+
const [first, second] = state.backing!.failedReadTimes
235246

236-
// Streak reset ⇒ the first delay is 500ms ±20% ⇒ 400–600ms. Streak carried over ⇒ it is the third
237-
// delay, 2000ms ±20% ⇒ 1600–2400ms. Disjoint ranges, so this cannot pass on the wrong one without
238-
// the machine stalling the shorter sleep by 65%.
239-
expect(second - first).toBeLessThan(1000)
247+
// Streak reset ⇒ the first delay is 500ms ±20% ⇒ at most 600ms. Streak carried over ⇒ it is the
248+
// third delay, 2000ms ±20% ⇒ at least 1600ms. The bound sits between them with room on both
249+
// sides, so a loaded machine stretching the short sleep does not flip the verdict.
250+
expect(second - first).toBeLessThan(1200)
240251
doc.destroy()
241252
})
242253

apps/sim/app/api/v2/audit-logs/route.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,55 @@ describe('v2 audit-log routes', () => {
179179
})
180180
})
181181

182+
/**
183+
* `resourceType` is split into an `inArray` downstream, so its spelling is a
184+
* set the query acts on rather than the exact string the caller sent. The
185+
* cursor must bind the members, not the text.
186+
*/
187+
it.each([
188+
['reordered', 'workflow,file'],
189+
['respaced', 'file,%20workflow'],
190+
['repeated', 'file,workflow,file'],
191+
])('resumes a cursor whose resourceType set is %s', async (_label, respelled) => {
192+
const minted = await listLogs(
193+
new NextRequest(
194+
'http://localhost:3000/api/v2/audit-logs?organizationId=org-1&resourceType=file,workflow'
195+
)
196+
)
197+
const { nextCursor } = await minted.json()
198+
expect(nextCursor).toEqual(expect.any(String))
199+
200+
mocks.list.mockClear()
201+
const resumed = await listLogs(
202+
new NextRequest(
203+
`http://localhost:3000/api/v2/audit-logs?organizationId=org-1&resourceType=${respelled}&cursor=${encodeURIComponent(nextCursor)}`
204+
)
205+
)
206+
207+
expect(resumed.status).toBe(200)
208+
expect(mocks.list).toHaveBeenCalled()
209+
})
210+
211+
it('still refuses a cursor replayed under a different resourceType set', async () => {
212+
const minted = await listLogs(
213+
new NextRequest(
214+
'http://localhost:3000/api/v2/audit-logs?organizationId=org-1&resourceType=file,workflow'
215+
)
216+
)
217+
const { nextCursor } = await minted.json()
218+
219+
mocks.list.mockClear()
220+
const replayed = await listLogs(
221+
new NextRequest(
222+
`http://localhost:3000/api/v2/audit-logs?organizationId=org-1&resourceType=file,knowledge&cursor=${encodeURIComponent(nextCursor)}`
223+
)
224+
)
225+
226+
expect(replayed.status).toBe(400)
227+
expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE)
228+
expect(mocks.list).not.toHaveBeenCalled()
229+
})
230+
182231
it('projects typed admin-policy failures without leaking internals', async () => {
183232
mocks.list.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Admin required'))
184233

apps/sim/app/api/v2/audit-logs/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs'
2-
import { cursorScopeKey, instantScopePart } from '@/lib/api/cursor-binding'
2+
import { cursorScopeKey, instantScopePart, unorderedScopePart } from '@/lib/api/cursor-binding'
33
import {
44
defineV2JsonRoute,
55
v2ApiKeyAuth,
@@ -27,7 +27,7 @@ function auditLogCursorFilters(query: {
2727
organizationId: query.organizationId,
2828
includeDeparted: query.includeDeparted,
2929
action: query.action,
30-
resourceType: query.resourceType,
30+
resourceType: unorderedScopePart(query.resourceType),
3131
resourceId: query.resourceId,
3232
workspaceId: query.workspaceId,
3333
actorEmail: query.actorEmail,

apps/sim/app/api/v2/uploads/[uploadId]/parts/[partNumber]/route.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,7 @@ import {
1212
type UploadSessionRecord,
1313
verifyUploadSessionToken,
1414
} from '@/lib/uploads/upload-session/service'
15-
import {
16-
v2Error,
17-
v2HttpError,
18-
v2UploadDataPlaneError,
19-
v2ValidationError,
20-
} from '@/app/api/v2/lib/response'
15+
import { v2Error, v2HttpError, v2UploadDataPlaneError } from '@/app/api/v2/lib/response'
2116

2217
interface LocalPartRouteParams {
2318
params: Promise<{ uploadId: string; partNumber: string }>

apps/sim/app/api/v2/uploads/[uploadId]/route.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,7 @@ import {
88
getOwnedUploadSession,
99
uploadSessionObjectMetadata,
1010
} from '@/lib/uploads/upload-session/service'
11-
import {
12-
v2Error,
13-
v2HttpError,
14-
v2UploadDataPlaneError,
15-
v2ValidationError,
16-
} from '@/app/api/v2/lib/response'
11+
import { v2Error, v2HttpError, v2UploadDataPlaneError } from '@/app/api/v2/lib/response'
1712

1813
interface LocalPutRouteParams {
1914
params: Promise<{ uploadId: string }>

apps/sim/app/api/v2/workflows/[id]/runs/route.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ vi.mock('@/lib/workflows/application/list-workflow-runs', () => ({
2828
},
2929
}))
3030

31-
import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding'
31+
import { REFILTERED_CURSOR_MESSAGE, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding'
3232
import { NoWorkspaceAccessError, PersonalApiKeysDisabledError } from '@/lib/core/application'
3333
import { GET } from '@/app/api/v2/workflows/[id]/runs/route'
3434

@@ -170,6 +170,35 @@ describe('GET /api/v2/workflows/[id]/runs', () => {
170170
expect(mocks.listRuns).not.toHaveBeenCalled()
171171
})
172172

173+
/**
174+
* This list orders by the single `order` param — its query schema is
175+
* `.strict()` and declares no `sortBy` — so the sort-mismatch wording would
176+
* answer one 400 with advice that earns a second.
177+
*/
178+
it('names a cursor with unusable keys unreadable rather than blaming sortBy', async () => {
179+
mocks.listRuns.mockResolvedValueOnce({
180+
data: EXECUTIONS,
181+
nextCursor: { startedAt: EXECUTIONS[1].startedAt, rowId: 'row-1' },
182+
workflowId: 'workflow-1',
183+
order: 'desc',
184+
})
185+
186+
const { nextCursor } = await (await callGet()).json()
187+
const payload = JSON.parse(Buffer.from(nextCursor, 'base64').toString())
188+
const tampered = Buffer.from(
189+
JSON.stringify({ ...payload, keys: ['not-a-date', 'row-1'] })
190+
).toString('base64')
191+
192+
mocks.listRuns.mockClear()
193+
const response = await callGet(`?cursor=${encodeURIComponent(tampered)}`)
194+
195+
expect(response.status).toBe(400)
196+
const { error } = await response.json()
197+
expect(error.message).toBe(UNREADABLE_CURSOR_MESSAGE)
198+
expect(error.message).not.toMatch(/sortBy/)
199+
expect(mocks.listRuns).not.toHaveBeenCalled()
200+
})
201+
173202
it('rejects an invalid cursor after API-key admission without calling the use case', async () => {
174203
const response = await callGet('?cursor=not-a-cursor')
175204

apps/sim/app/api/v2/workflows/[id]/runs/route.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ import {
33
v2ListWorkflowRunsContract,
44
v2WorkflowRunListStatusValueSchema,
55
} from '@/lib/api/contracts/v2/workflows'
6-
import { cursorScopeKey, instantScopePart } from '@/lib/api/cursor-binding'
7-
import { INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query'
6+
import {
7+
cursorScopeKey,
8+
instantScopePart,
9+
UNREADABLE_CURSOR_MESSAGE,
10+
} from '@/lib/api/cursor-binding'
811
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
912
import { OrchestrationError } from '@/lib/core/orchestration/types'
1013
import { v2WorkflowErrorPolicies } from '@/lib/workflows/api'
@@ -53,7 +56,7 @@ export const GET = defineV2JsonRoute({
5356
Number.isNaN(cursorDate.getTime()) ||
5457
typeof cursorRowId !== 'string')
5558
) {
56-
throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE)
59+
throw new OrchestrationError('validation', UNREADABLE_CURSOR_MESSAGE)
5760
}
5861

5962
return {

0 commit comments

Comments
 (0)