Skip to content

Commit c9c2c4e

Browse files
authored
feat(plane): add core integration with self-hosted support (#8660)
* feat(plane): add current API integration and self-hosted compatibility * fix(plane): narrow actions and recover webhook activation failures * fix(plane): harden webhook cutover and API contracts * fix(plane): preserve webhook delivery during replacement * fix(plane): align webhook cutover and pagination limits * fix(plane): preserve expanded compatibility relations
1 parent 7308e45 commit c9c2c4e

165 files changed

Lines changed: 38352 additions & 295 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎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/components/icons.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10124,3 +10124,11 @@ export function YouComIcon(props: SVGProps<SVGSVGElement>) {
1012410124
</svg>
1012510125
)
1012610126
}
10127+
10128+
export function PlaneIcon(props: SVGProps<SVGSVGElement>) {
10129+
return (
10130+
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 85 52' fill='currentColor' {...props}>
10131+
<path d='M44.322 2.926C44.322 0.755 46.608 -0.658 48.551 0.313L80.455 16.265C82.929 17.502 84.492 20.032 84.492 22.799V48.249C84.492 50.42 82.207 51.833 80.265 50.862L62.328 41.893V22.797C62.328 20.031 60.765 17.502 58.291 16.264L44.322 9.28V2.926ZM0 2.925C0 0.754 2.286 -0.659 4.229 0.312L22.158 9.277V28.377C22.158 31.143 23.721 33.672 26.195 34.909L40.17 41.895V48.249C40.17 50.42 37.885 51.832 35.942 50.861L4.037 34.909C1.563 33.672 0 31.143 0 28.377V2.925ZM22.158 2.925C22.158 0.754 24.444 -0.659 26.387 0.312L44.322 9.28V28.378C44.322 31.144 45.886 33.673 48.36 34.91L62.328 41.893V48.249C62.328 50.42 60.043 51.832 58.101 50.861L40.17 41.895V22.797C40.17 20.031 38.607 17.502 36.133 16.264L22.158 9.277V2.925Z' />
10132+
</svg>
10133+
)
10134+
}

‎apps/docs/components/ui/icon-mapping.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ import {
212212
PineconeIcon,
213213
PipedriveIcon,
214214
PitchBookIcon,
215+
PlaneIcon,
215216
PlanetScaleIcon,
216217
PolymarketIcon,
217218
PostgresIcon,
@@ -558,6 +559,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
558559
pinecone: PineconeIcon,
559560
pipedrive: PipedriveIcon,
560561
pitchbook: PitchBookIcon,
562+
plane: PlaneIcon,
561563
planetscale: PlanetScaleIcon,
562564
polymarket: PolymarketIcon,
563565
postgresql: PostgresIcon,

‎apps/docs/content/docs/integrations/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@
199199
"pipedrive",
200200
"pipedrive-service-account",
201201
"pitchbook",
202+
"plane",
202203
"planetscale",
203204
"polymarket",
204205
"postgresql",

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

Lines changed: 5985 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ import {
1212
telemetryMock,
1313
workflowAuthzMockFns,
1414
} from '@sim/testing'
15+
import { toRecord } from '@sim/utils/object'
1516
import { beforeEach, describe, expect, it, vi } from 'vitest'
1617

1718
const mocks = vi.hoisted(() => ({
19+
activateExternalWebhookSubscription: vi.fn(),
20+
cleanupExternalWebhook: vi.fn(),
1821
authorizeCredentialUseForAuth: vi.fn(),
1922
configurePolling: vi.fn(),
2023
createExternalWebhookSubscription: vi.fn(),
@@ -35,7 +38,8 @@ vi.mock('@/lib/webhooks/env-resolver', () => ({
3538
resolveEnvVarsInObject: mocks.resolveEnvVarsInObject,
3639
}))
3740
vi.mock('@/lib/webhooks/provider-subscriptions', () => ({
38-
cleanupExternalWebhook: vi.fn(),
41+
activateExternalWebhookSubscription: mocks.activateExternalWebhookSubscription,
42+
cleanupExternalWebhook: mocks.cleanupExternalWebhook,
3943
createExternalWebhookSubscription: mocks.createExternalWebhookSubscription,
4044
shouldRecreateExternalWebhookSubscription: mocks.shouldRecreateExternalWebhookSubscription,
4145
}))
@@ -462,3 +466,156 @@ describe('POST /api/webhooks credential references', () => {
462466
expect(mocks.authorizeCredentialUseForAuth).not.toHaveBeenCalled()
463467
})
464468
})
469+
470+
describe('POST /api/webhooks subscription replacement recovery', () => {
471+
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+
)
507+
it.each([false, true])(
508+
'retains recoverable state when prior cleanup fails: %s',
509+
async (priorCleanupFails) => {
510+
let cleanupUnavailable = priorCleanupFails
511+
let activationMustFail = !priorCleanupFails
512+
let nextExternalId = 0
513+
let persisted: Record<string, unknown> = {
514+
id: 'webhook-1',
515+
workflowId: 'workflow-1',
516+
blockId: 'block-1',
517+
path: 'inbound-orders',
518+
provider: 'plane',
519+
isActive: true,
520+
archivedAt: null,
521+
providerConfig: {
522+
autoRegister: true,
523+
projectId: 'prior',
524+
externalId: 'external-old',
525+
webhookSecret: 'old-secret',
526+
},
527+
}
528+
const external = new Map<string, { active: boolean }>([['external-old', { active: true }]])
529+
const desired = { autoRegister: true, projectId: 'next' }
530+
mocks.getProviderHandler.mockReturnValue({
531+
createSubscription: async () => undefined,
532+
activateSubscription: async () => undefined,
533+
})
534+
mocks.shouldRecreateExternalWebhookSubscription.mockImplementation(
535+
({ previousConfig, nextConfig }) => previousConfig.projectId !== nextConfig.projectId
536+
)
537+
const defaultSet = dbChainMockFns.set.getMockImplementation()
538+
dbChainMockFns.set.mockImplementation((...args: unknown[]) => {
539+
persisted = { ...persisted, ...structuredClone(toRecord(args[0])) }
540+
return defaultSet?.(...args)
541+
})
542+
dbChainMockFns.returning.mockImplementation(async () => [structuredClone(persisted)])
543+
mocks.createExternalWebhookSubscription.mockImplementation(async (_request, row) => {
544+
const externalId = `external-${++nextExternalId}`
545+
external.set(externalId, { active: false })
546+
return {
547+
updatedProviderConfig: {
548+
...toRecord(row.providerConfig),
549+
externalId,
550+
webhookSecret: 'new-secret',
551+
subscriptionActivationPending: true,
552+
},
553+
externalSubscriptionCreated: true,
554+
}
555+
})
556+
mocks.activateExternalWebhookSubscription.mockImplementation(async (_request, row) => {
557+
const resource = external.get(String(toRecord(row.providerConfig).externalId))
558+
if (!resource) throw new Error('provider subscription missing')
559+
if (activationMustFail) {
560+
activationMustFail = false
561+
throw new Error('activation unavailable')
562+
}
563+
resource.active = true
564+
})
565+
mocks.cleanupExternalWebhook.mockImplementation(
566+
async (row, _workflow, _requestId, options) => {
567+
const externalId = String(toRecord(row.providerConfig).externalId)
568+
if (cleanupUnavailable && externalId === 'external-old') {
569+
if (options?.throwOnError) throw new Error('cleanup unavailable')
570+
return
571+
}
572+
external.delete(externalId)
573+
}
574+
)
575+
function queueCurrentRows(): void {
576+
queueTableRows(workflow, [
577+
{ id: 'workflow-1', userId: 'actor-1', workspaceId: 'workspace-1' },
578+
])
579+
queueTableRows(webhook, [{ id: 'webhook-1' }])
580+
queueTableRows(webhook, [structuredClone(persisted)])
581+
}
582+
function replacementRequest() {
583+
return createMockRequest('POST', {
584+
workflowId: 'workflow-1',
585+
path: 'inbound-orders',
586+
provider: 'plane',
587+
providerConfig: desired,
588+
})
589+
}
590+
queueCurrentRows()
591+
expect((await POST(replacementRequest())).status).toBe(500)
592+
expect(external.size).toBe(2)
593+
const retainedConfig = toRecord(persisted.providerConfig)
594+
if (priorCleanupFails) {
595+
expect(retainedConfig.projectId).toBe('next')
596+
expect(toRecord(retainedConfig.previousSubscription).providerConfig).toEqual({
597+
autoRegister: true,
598+
projectId: 'prior',
599+
externalId: 'external-old',
600+
webhookSecret: 'old-secret',
601+
})
602+
expect(external.get('external-old')?.active).toBe(true)
603+
expect(external.get(String(retainedConfig.externalId))?.active).toBe(true)
604+
} else {
605+
expect(retainedConfig.projectId).toBe('next')
606+
expect(retainedConfig.webhookSecret).toBe('new-secret')
607+
expect(external.get('external-old')?.active).toBe(true)
608+
expect(external.get(String(retainedConfig.externalId))?.active).toBe(false)
609+
}
610+
cleanupUnavailable = false
611+
queueCurrentRows()
612+
expect((await POST(replacementRequest())).status).toBe(200)
613+
expect(external.size).toBe(1)
614+
const recoveredConfig = toRecord(persisted.providerConfig)
615+
expect(recoveredConfig.projectId).toBe('next')
616+
expect(recoveredConfig.webhookSecret).toBe('new-secret')
617+
expect(external.get(String(recoveredConfig.externalId))?.active).toBe(true)
618+
if (!priorCleanupFails) expect(recoveredConfig.externalId).toBe(retainedConfig.externalId)
619+
}
620+
)
621+
})

0 commit comments

Comments
 (0)