Skip to content

Commit 3510a76

Browse files
committed
fix(plane): harden webhook cutover and API contracts
1 parent be9cea3 commit 3510a76

38 files changed

Lines changed: 709 additions & 182 deletions

‎apps/docs/app/llms-full.txt/route.ts‎

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,41 @@ import { source } from '@/lib/source'
44

55
const logger = createLogger('DocsLlmsFullText')
66

7-
export const revalidate = false
7+
export const dynamic = 'force-dynamic'
88

99
export async function GET() {
1010
try {
1111
const pages = source.getPages().filter((page) => Boolean(page?.data && page.url))
12-
13-
const scan = pages.map((page) => getLLMText(page))
14-
const scanned = await Promise.all(scan)
15-
16-
const filtered = scanned.filter((text) => text && text.length > 0)
17-
18-
return new Response(filtered.join('\n\n---\n\n'), {
19-
headers: {
20-
'Content-Type': 'text/plain; charset=utf-8',
12+
const encoder = new TextEncoder()
13+
let nextPage = 0
14+
let hasContent = false
15+
let cancelled = false
16+
const body = new ReadableStream<Uint8Array>({
17+
async pull(controller) {
18+
try {
19+
while (nextPage < pages.length) {
20+
const page = pages[nextPage]
21+
nextPage += 1
22+
const text = await getLLMText(page)
23+
if (cancelled) return
24+
if (!text) continue
25+
controller.enqueue(encoder.encode(`${hasContent ? '\n\n---\n\n' : ''}${text}`))
26+
hasContent = true
27+
return
28+
}
29+
controller.close()
30+
} catch (error) {
31+
if (cancelled) return
32+
logger.error('Error streaming LLM full text:', error)
33+
controller.error(error)
34+
}
2135
},
36+
cancel() {
37+
cancelled = true
38+
},
39+
})
40+
return new Response(body, {
41+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
2242
})
2343
} catch (error) {
2444
logger.error('Error generating LLM full text:', error)

‎apps/docs/content/docs/integrations/plane.mdx‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ Create a work item attachment upload in Plane. Supports API v1 compatibility.
185185
| `size` | number | Yes | Attachment size in bytes. For signed form uploads, allow room for multipart overhead in the storage policy. |
186186
| `external_id` | string | No | Your system's identifier for this record, for sync and import correlation. Nullable. |
187187
| `external_source` | string | No | The system `external_id` came from, for example `github` or `jira`. Nullable. |
188-
| `type` | string | No | The type. |
188+
| `type` | string | No | Required with API v1: attachment type, for example work_item_attachment. Optional with API v2. |
189189
| `fields` | string | No | Comma-separated list of fields to return. Unrequested keys are **omitted**, not returned as `null`. `id` always comes back. Pass `all` for every requestable field. An unknown name is a `400`. See \[Sparse fields\]\(https://developers.plane.so/api-reference/v2/sparse-fields). Requestable here: `asset_url`, `attributes`, `content_type`, `created_at`, `created_by_id`, `external_id`, `external_source`, `id`, `is_uploaded`, `name`, `size`, `work_item_id`. |
190190

191191
#### Output
@@ -795,7 +795,7 @@ Create a webhook in Plane. Requires API v2.
795795
| `is_active` | boolean | No | Whether the record is active. |
796796
| `name` | string | No | Display name. Maximum 255 characters. Nullable. |
797797
| `scopes` | json | No | The scopes. |
798-
| `url` | string | No | Target URL. |
798+
| `url` | string | Yes | Target URL. |
799799
| `version` | string | No | Version string, for example `1.4.0`. Maximum 10 characters. |
800800
| `fields` | string | No | Comma-separated list of fields to return. Unrequested keys are **omitted**, not returned as `null`. `id` always comes back. Pass `all` for every requestable field. An unknown name is a `400`. See \[Sparse fields\]\(https://developers.plane.so/api-reference/v2/sparse-fields). Requestable here: `content_type`, `created_at`, `created_by_id`, `id`, `is_active`, `name`, `scopes`, `url`, `version`. |
801801

@@ -1444,6 +1444,7 @@ Get a link in Plane. Supports API v1 compatibility.
14441444
| ↳ `title` | json | Title |
14451445
| ↳ `url` | json | Url |
14461446
| ↳ `work_item_id` | string | Work item id |
1447+
| ↳ `name` | string | Legacy link name. |
14471448
| ↳ `updated_at` | string | Updated at |
14481449
| ↳ `deleted_at` | string | Deleted at |
14491450
| ↳ `created_by` | string | Created by |
@@ -1682,7 +1683,7 @@ Get a project summary in Plane. Supports API v1 compatibility.
16821683
| `baseUrl` | string | No | Plane instance origin, optionally with a reverse-proxy path prefix. Defaults to https://api.plane.so. Do not include /api/v1 or /api/v2. |
16831684
| `apiVersion` | string | No | API version: v2 \(default\) for Plane Cloud and current commercial instances; v1 for Community Edition or older self-hosted instances. Unsupported operations or fields fail explicitly. |
16841685
| `workspace_slug` | string | Yes | Workspace slug from the Plane URL \(for example, my-team\). |
1685-
| `pk` | string | Yes | The project summary id. |
1686+
| `pk` | string | Yes | Project ID or bare project identifier whose summary to retrieve. |
16861687
| `counts` | string | No | Comma-separated count keys to include. Allowed: members, states, labels, cycles, modules, issues, intakes, work_item_types, work_item_properties, pages. Omitting returns all. Unknown keys are a 400. |
16871688
| `fields` | string | No | v1 compatibility only. Comma-separated list of fields. |
16881689

@@ -2274,6 +2275,7 @@ List labels in Plane. Supports API v1 compatibility.
22742275
| `fields` | string | No | Comma-separated list of fields to return on each row. Unrequested keys are **omitted** from the response, not returned as `null`, so absent means "not requested" and `null` means "actually null". `id` always comes back whether or not you name it. Pass `all` for every requestable field. An unknown name is a `400` that lists the valid set and suggests the closest match, so a typo can't silently cost you the saving. Requestable here: `color`, `created_at`, `created_by_id`, `description`, `external_id`, `external_source`, `id`, `name`, `parent_id`, `sort_order`. See \[Sparse fields\]\(https://developers.plane.so/api-reference/v2/sparse-fields). |
22752276
| `cursor` | string | No | Cursor from next_cursor. Repeat the original filters, per_page, and order_by when following it. |
22762277
| `expand` | string | No | v1 compatibility only. Comma-separated list of related fields to expand in response Expanded relations appear beside their ID fields and survive sparse field filtering. Use only the relation names listed above. |
2278+
| `parent_id__isnull` | boolean | No | API v2: true selects top-level labels, false selects labels with a parent. |
22772279

22782280
#### Output
22792281

@@ -2987,6 +2989,11 @@ List work items in Plane. Supports API v1 compatibility.
29872989
| `cursor` | string | No | Cursor from next_cursor. Repeat the original filters, per_page, and order_by when following it. |
29882990
| `pql` | string | No | v1 compatibility only. Plane Query Language expression. Human-readable alternative to `filters`. Example: `priority = "urgent" AND assignee = currentUser\(\)`. Requires a Plane edition with work item query filtering; Community Edition rejects this parameter. |
29892991
| `filters` | json | No | v1 compatibility only. Structured filter expression. Supports nested `and`/`or`/`not` groups and field comparisons with operators like `__in`, `__gte`, `__range`, `__isnull`, `__icontains`, etc. JSON-encoded into the `filters=` query param by the client. Requires a Plane edition with work item query filtering; Community Edition rejects this parameter. |
2992+
| `assignee_id__isnull` | boolean | No | API v2: select records with a missing assignee when true, or a present assignee when false. |
2993+
| `label_id__isnull` | boolean | No | API v2: select records with a missing label when true, or a present label when false. |
2994+
| `parent_id__isnull` | boolean | No | API v2: select records with a missing parent when true, or a present parent when false. |
2995+
| `cycle_id__isnull` | boolean | No | API v2: select records with a missing cycle when true, or a present cycle when false. |
2996+
| `module_id__isnull` | boolean | No | API v2: select records with a missing module when true, or a present module when false. |
29902997

29912998
#### Output
29922999

@@ -3437,7 +3444,7 @@ Transfer work items between cycles in Plane. Supports API v1 compatibility.
34373444
| `bodyOverrides` | json | No | JSON overrides using the canonical input names for the selected API version. Preserves empty strings, null, and empty arrays; unsupported fields are rejected. |
34383445
| `workspace_slug` | string | Yes | Workspace slug from the Plane URL \(for example, my-team\). |
34393446
| `project_id` | string | Yes | The project the resource belongs to. Accepts the project UUID or its bare identifier, for example `ENG`. |
3440-
| `pk` | string | Yes | The cycle transfer id. |
3447+
| `pk` | string | Yes | Source cycle ID. The source cycle must be completed. |
34413448
| `new_cycle_id` | string | Yes | Id of the related new cycle. |
34423449

34433450
#### Output

‎apps/sim/app/api/webhooks/route.test.ts‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,41 @@ describe('POST /api/webhooks credential references', () => {
469469

470470
describe('POST /api/webhooks subscription replacement recovery', () => {
471471
beforeEach(setupUpsertMocks)
472+
it.each(['create', 'save'])(
473+
'preserves the live subscription when replacement fails at %s',
474+
async (failure) => {
475+
queueUpdatePathRows(true, {
476+
autoRegister: true,
477+
externalId: 'external-old',
478+
webhookSecret: 'old-secret',
479+
})
480+
mocks.getProviderHandler.mockReturnValue({ activateSubscription: async () => undefined })
481+
mocks.shouldRecreateExternalWebhookSubscription.mockReturnValue(true)
482+
const external = new Set(['external-old'])
483+
mocks.createExternalWebhookSubscription.mockImplementation(async () => {
484+
if (failure === 'create') throw new Error('creation unavailable')
485+
external.add('external-new')
486+
return {
487+
updatedProviderConfig: {
488+
autoRegister: true,
489+
externalId: 'external-new',
490+
webhookSecret: 'new-secret',
491+
subscriptionActivationPending: true,
492+
},
493+
externalSubscriptionCreated: true,
494+
}
495+
})
496+
if (failure === 'save')
497+
dbChainMockFns.set.mockImplementation(() => {
498+
throw new Error('database unavailable')
499+
})
500+
mocks.cleanupExternalWebhook.mockImplementation(async (row) => {
501+
external.delete(String(toRecord(row.providerConfig).externalId))
502+
})
503+
expect((await POST(upsertRequest({ autoRegister: true }))).status).toBe(500)
504+
expect([...external]).toEqual(['external-old'])
505+
}
506+
)
472507
it.each([false, true])(
473508
'retains recoverable state when prior cleanup fails: %s',
474509
async (priorCleanupFails) => {
@@ -513,6 +548,7 @@ describe('POST /api/webhooks subscription replacement recovery', () => {
513548
...toRecord(row.providerConfig),
514549
externalId,
515550
webhookSecret: 'new-secret',
551+
subscriptionActivationPending: true,
516552
},
517553
externalSubscriptionCreated: true,
518554
}
@@ -553,16 +589,18 @@ describe('POST /api/webhooks subscription replacement recovery', () => {
553589
}
554590
queueCurrentRows()
555591
expect((await POST(replacementRequest())).status).toBe(500)
556-
expect(external.size).toBe(1)
592+
expect(external.size).toBe(priorCleanupFails ? 2 : 1)
557593
const retainedConfig = toRecord(persisted.providerConfig)
558594
if (priorCleanupFails) {
559-
expect(retainedConfig).toEqual({
595+
expect(retainedConfig.projectId).toBe('next')
596+
expect(toRecord(retainedConfig.previousSubscription).providerConfig).toEqual({
560597
autoRegister: true,
561598
projectId: 'prior',
562599
externalId: 'external-old',
563600
webhookSecret: 'old-secret',
564601
})
565602
expect(external.get('external-old')?.active).toBe(true)
603+
expect(external.get(String(retainedConfig.externalId))?.active).toBe(false)
566604
} else {
567605
expect(retainedConfig.projectId).toBe('next')
568606
expect(retainedConfig.webhookSecret).toBe('new-secret')

‎apps/sim/app/api/webhooks/route.ts‎

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
384384
* from the client nor carried forward from a stored row; Gmail and Outlook
385385
* polling setup derive it again from the credential after the save.
386386
*/
387-
const originalProviderConfig: Record<string, unknown> = omit(providerConfig || {}, ['userId'])
387+
const originalProviderConfig: Record<string, unknown> = omit(providerConfig || {}, [
388+
'userId',
389+
'previousSubscription',
390+
'subscriptionActivationPending',
391+
])
388392
let resolvedProviderConfig = await resolveEnvVarsInObject(
389393
originalProviderConfig,
390394
userId,
@@ -472,6 +476,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
472476
const credentialIds = [
473477
originalProviderConfig.credentialId,
474478
usesStoredCredential ? existingWebhook.providerConfig?.credentialId : undefined,
479+
existingWebhook
480+
? toRecord(toRecord(existingWebhook.providerConfig?.previousSubscription).providerConfig)
481+
.credentialId
482+
: undefined,
475483
].filter((id) => id != null && id !== '')
476484

477485
/** The row stores the unresolved text, so only a literal credential id can be authorized. */
@@ -501,19 +509,38 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
501509
}
502510
}
503511

504-
const cleanupBeforeCreate = Boolean(
512+
if (existingWebhook && shouldRecreateSubscription) {
513+
const pendingPrevious = toRecord(existingWebhook.providerConfig?.previousSubscription)
514+
if (typeof pendingPrevious.provider === 'string') {
515+
await cleanupExternalWebhook(
516+
{
517+
...existingWebhook,
518+
provider: pendingPrevious.provider,
519+
providerConfig: pendingPrevious.providerConfig,
520+
},
521+
workflowRecord,
522+
requestId,
523+
{ throwOnError: true }
524+
)
525+
const currentConfig = omit(toRecord(existingWebhook.providerConfig), [
526+
'previousSubscription',
527+
])
528+
await db
529+
.update(webhook)
530+
.set({ providerConfig: currentConfig })
531+
.where(eq(webhook.id, existingWebhook.id))
532+
existingWebhook.providerConfig = currentConfig
533+
}
534+
}
535+
536+
const deferPreviousCleanup = Boolean(
505537
existingWebhook &&
506538
shouldRecreateSubscription &&
507539
(getProviderHandler(provider).activateSubscription ||
508540
getProviderHandler(existingWebhook.provider).activateSubscription)
509541
)
510542
if (!existingWebhook || shouldRecreateSubscription) {
511543
try {
512-
if (cleanupBeforeCreate) {
513-
await cleanupExternalWebhook(existingWebhook, workflowRecord, requestId, {
514-
throwOnError: true,
515-
})
516-
}
517544
const result = await createExternalWebhookSubscription(
518545
request,
519546
createTempWebhookData(),
@@ -525,6 +552,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
525552
mergeNonUserFields(configToSave, updatedConfig, userProvided)
526553
resolvedProviderConfig = updatedConfig
527554
externalSubscriptionCreated = result.externalSubscriptionCreated
555+
if (deferPreviousCleanup && existingWebhook) {
556+
configToSave.previousSubscription = {
557+
provider: existingWebhook.provider,
558+
providerConfig: existingWebhook.providerConfig,
559+
}
560+
}
528561
} catch (err) {
529562
logger.error(`[${requestId}] Error creating external webhook subscription`, err)
530563
return NextResponse.json(
@@ -595,7 +628,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
595628
logger.error(`[${requestId}] DB save failed, cleaning up external subscription`, dbError)
596629
try {
597630
await cleanupExternalWebhook(
598-
createTempWebhookData(configToSave),
631+
createTempWebhookData(omit(configToSave, ['previousSubscription'])),
599632
workflowRecord,
600633
requestId
601634
)
@@ -610,16 +643,49 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
610643
}
611644

612645
if (savedWebhook) {
613-
await activateExternalWebhookSubscription(
614-
request,
615-
savedWebhook,
616-
workflowRecord,
617-
userId,
618-
requestId
619-
)
646+
const previousSubscription = toRecord(configToSave.previousSubscription)
647+
if (typeof previousSubscription.provider === 'string') {
648+
await cleanupExternalWebhook(
649+
{
650+
...savedWebhook,
651+
provider: previousSubscription.provider,
652+
providerConfig: previousSubscription.providerConfig,
653+
},
654+
workflowRecord,
655+
requestId,
656+
{ throwOnError: true }
657+
)
658+
configToSave.previousSubscription = undefined
659+
await db
660+
.update(webhook)
661+
.set({ providerConfig: configToSave })
662+
.where(eq(webhook.id, savedWebhook.id))
663+
savedWebhook.providerConfig = configToSave
664+
}
665+
if (
666+
!existingWebhook ||
667+
shouldRecreateSubscription ||
668+
configToSave.subscriptionActivationPending === true
669+
) {
670+
await activateExternalWebhookSubscription(
671+
request,
672+
savedWebhook,
673+
workflowRecord,
674+
userId,
675+
requestId
676+
)
677+
if (configToSave.subscriptionActivationPending === true) {
678+
configToSave.subscriptionActivationPending = false
679+
await db
680+
.update(webhook)
681+
.set({ providerConfig: configToSave })
682+
.where(eq(webhook.id, savedWebhook.id))
683+
savedWebhook.providerConfig = configToSave
684+
}
685+
}
620686
}
621687

622-
if (existingWebhook && shouldRecreateSubscription && !cleanupBeforeCreate) {
688+
if (existingWebhook && shouldRecreateSubscription && !deferPreviousCleanup) {
623689
try {
624690
await cleanupExternalWebhook(existingWebhook, workflowRecord, requestId)
625691
} catch (cleanupError) {

0 commit comments

Comments
 (0)