Skip to content

Commit 482f146

Browse files
authored
fix(plane): correct tile styling and capped connector syncs (#8690)
* fix(plane): use a white integration background * fix(plane): preserve distinct records in capped connector syncs * fix(desktop): preserve legacy inbox timestamp precision
1 parent 5d86e42 commit 482f146

9 files changed

Lines changed: 128 additions & 45 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
77

88
<BlockInfoCard
99
type="plane"
10-
color="#3F76FF"
10+
color="#FFFFFF"
1111
/>
1212

1313
{/* MANUAL-CONTENT-START:intro */}

‎apps/sim/blocks/blocks/plane.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -931,7 +931,7 @@ export const PlaneBlock: BlockConfig = {
931931
category: 'tools',
932932
integrationType: IntegrationType.Productivity,
933933
icon: PlaneIcon,
934-
bgColor: '#3F76FF',
934+
bgColor: '#FFFFFF',
935935
canvasPresentation: {
936936
defaultTitle: 'Plane',
937937
sentences: {

‎apps/sim/connectors/plane/plane.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export const planeConnector: ConnectorConfig = {
111111
}
112112
const response = await requestPlane(token, config, resourcePath(config), {
113113
cursor,
114+
// Plane v1 cursor offsets depend on per_page, so it must stay fixed across pages.
114115
per_page: PAGE_SIZE,
115116
fields: config.contentType === 'pages' ? PAGE_FIELDS : WORK_ITEM_FIELDS,
116117
...(config.contentType === 'work_items'
@@ -131,7 +132,9 @@ export const planeConnector: ConnectorConfig = {
131132
const total = fetched + documents.length
132133
if (syncContext) syncContext.totalDocsFetched = total
133134
const capped =
134-
config.maxDocuments > 0 && total >= config.maxDocuments && page.nextCursor !== undefined
135+
config.maxDocuments > 0 &&
136+
total >= config.maxDocuments &&
137+
(page.nextCursor !== undefined || page.results.length > remaining)
135138
if (capped && syncContext) syncContext.listingCapped = true
136139
logger.info('Listed Plane documents', {
137140
count: documents.length,

‎apps/sim/lib/desktop/application/executor.integration.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
import { createDeferred } from '@sim/testing/helpers/deferred'
3232
import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock'
3333
import { generateId } from '@sim/utils/id'
34+
import { compareStrings } from '@sim/utils/string'
3435
import { eq, inArray, sql } from 'drizzle-orm'
3536
import { closeRedisConnection, getRedisClient } from '@/lib/core/config/redis'
3637
import type { DbTransaction } from '@/lib/db/types'
@@ -887,7 +888,7 @@ describe.runIf(Boolean(redisUrl))('desktop background executor protocol', () =>
887888
expect((await inbox(desktop)).items.map((item) => item.toolCallId)).toEqual(persisted)
888889
})
889890

890-
it('lists pending calls in persistence order even when their doorbell was never heard', async () => {
891+
it('preserves legacy sub-millisecond order even when the doorbell was never heard', async () => {
891892
const desktop = await signedInDesktop()
892893
const run = await boundRun(desktop)
893894
const first = await pendingCall(run.runId, 'browser_navigate', { url: 'https://sim.ai' })
@@ -896,13 +897,24 @@ describe.runIf(Boolean(redisUrl))('desktop background executor protocol', () =>
896897
path: 'user-local/Project--mount-1',
897898
pattern: 'TODO',
898899
})
900+
const persisted = [first, second, vfs].sort(compareStrings).reverse()
901+
const createdAt = new Date(Date.now() - 1000).toISOString()
902+
for (const [index, toolCallId] of persisted.entries()) {
903+
await db
904+
.update(copilotAsyncToolCalls)
905+
.set({
906+
persistSeq: null,
907+
createdAt: sql`${createdAt}::timestamp + ${index} * interval '100 microseconds'`,
908+
})
909+
.where(eq(copilotAsyncToolCalls.toolCallId, toolCallId))
910+
}
899911
await pendingCall(run.runId, 'run_workflow', {})
900912
/** Rung while no stream was open: nobody heard it. */
901913
ringDesktopInbox(desktop.deviceId, 'call')
902914

903915
expect(
904916
(await inbox(desktop)).items.map((item) => item.kind === 'call' && item.toolCallId)
905-
).toEqual([first, second, vfs])
917+
).toEqual(persisted)
906918
})
907919

908920
it('counts the device online after a pull or a stream open, and keeps it online when the stream closes', async () => {

‎apps/sim/lib/desktop/executor/repository.ts‎

Lines changed: 26 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -89,22 +89,12 @@ const INBOX_ROW_LIMIT = 500
8989
* can share a millisecond, so the timestamp alone cannot order them. Rows persisted before the
9090
* sequence existed have none and come first, as they are the oldest.
9191
*/
92-
const persistOrder = [
93-
sql`${copilotAsyncToolCalls.persistSeq} ASC NULLS FIRST`,
94-
asc(copilotAsyncToolCalls.createdAt),
95-
asc(copilotAsyncToolCalls.toolCallId),
96-
]
97-
98-
function comparePersistOrder(
99-
a: { persistSeq: number | null; createdAt: Date; toolCallId: string },
100-
b: { persistSeq: number | null; createdAt: Date; toolCallId: string }
101-
): number {
102-
if (a.persistSeq !== b.persistSeq) {
103-
if (a.persistSeq === null) return -1
104-
if (b.persistSeq === null) return 1
105-
return a.persistSeq - b.persistSeq
106-
}
107-
return a.createdAt.getTime() - b.createdAt.getTime() || a.toolCallId.localeCompare(b.toolCallId)
92+
function persistOrder() {
93+
return [
94+
sql`${copilotAsyncToolCalls.persistSeq} ASC NULLS FIRST`,
95+
asc(copilotAsyncToolCalls.createdAt),
96+
asc(copilotAsyncToolCalls.toolCallId),
97+
]
10898
}
10999

110100
export interface DesktopDeviceRegistration {
@@ -232,27 +222,27 @@ export async function listDesktopInboxRows(identity: Omit<DesktopDeviceIdentity,
232222
state
233223
)
234224
)
235-
.orderBy(...persistOrder)
225+
.orderBy(...persistOrder())
236226
.limit(INBOX_ROW_LIMIT)
237-
const [waiting, cancelled] = await Promise.all([
238-
rowsWhere(
239-
and(
240-
eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.pending),
241-
isNull(copilotAsyncToolCalls.executionOwnerToken),
242-
or(sql`${copilotAsyncToolCalls.pickupDeadlineAt} > clock_timestamp()`, awaitingPermission),
243-
inArray(copilotRuns.status, LIVE_RUN_STATUSES),
244-
isNull(copilotRuns.toolAdmissionClosedAt)
245-
)
246-
),
247-
rowsWhere(
248-
and(
249-
isNotNull(copilotAsyncToolCalls.executionOwnerToken),
250-
isNull(copilotAsyncToolCalls.executionSettledAt),
251-
ne(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.running)
227+
return rowsWhere(
228+
and(
229+
eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.pending),
230+
isNull(copilotAsyncToolCalls.executionOwnerToken),
231+
or(sql`${copilotAsyncToolCalls.pickupDeadlineAt} > clock_timestamp()`, awaitingPermission),
232+
inArray(copilotRuns.status, LIVE_RUN_STATUSES),
233+
isNull(copilotRuns.toolAdmissionClosedAt)
234+
)
235+
)
236+
.unionAll(
237+
rowsWhere(
238+
and(
239+
isNotNull(copilotAsyncToolCalls.executionOwnerToken),
240+
isNull(copilotAsyncToolCalls.executionSettledAt),
241+
ne(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.running)
242+
)
252243
)
253-
),
254-
])
255-
return [...waiting, ...cancelled].sort(comparePersistOrder)
244+
)
245+
.orderBy(...persistOrder())
256246
}
257247

258248
export type DesktopInboxRow = Awaited<ReturnType<typeof listDesktopInboxRows>>[number]
@@ -448,7 +438,7 @@ export async function listOverdueDesktopToolCalls(input: { slackMs: number; limi
448438
)
449439
)
450440
)
451-
.orderBy(...persistOrder)
441+
.orderBy(...persistOrder())
452442
.limit(input.limit)
453443
return rows.map((row) => row.toolCallId)
454444
}

‎apps/sim/scripts/test-plane-e2e.ts‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -600,7 +600,14 @@ try {
600600
(await planeConnector.validateConfig(apiKey, { ...config, maxDocuments: true })).valid,
601601
false
602602
)
603-
const capped = await planeConnector.listDocuments(apiKey, { ...config, maxDocuments: 1 })
603+
const cappedContext: Record<string, unknown> = { totalDocsFetched: 0 }
604+
const capped = await planeConnector.listDocuments(
605+
apiKey,
606+
{ ...config, maxDocuments: 1 },
607+
undefined,
608+
cappedContext
609+
)
610+
assert.equal(cappedContext.listingCapped, true)
604611
assert.equal(capped.documents.length, 1)
605612
assert.equal(capped.hasMore, false)
606613
assert.equal(capped.reconciliationSafe, false)
@@ -976,6 +983,55 @@ try {
976983
}
977984
})
978985
}
986+
await check(
987+
'v1: knowledge connector preserves distinct capped cursor continuations',
988+
async () => {
989+
const projectId = id(
990+
(
991+
await call('plane_create_project', 'v1', {
992+
name: 'Disposable cursor verification',
993+
identifier: `P${generateId().replaceAll('-', '').slice(0, 7).toUpperCase()}`,
994+
})
995+
).result
996+
)
997+
const itemIds: string[] = []
998+
for (let index = 0; index < 102; index++) {
999+
itemIds.push(
1000+
id(
1001+
(
1002+
await call('plane_create_work_item', 'v1', {
1003+
project_id: projectId,
1004+
name: `Cursor verification ${index}`,
1005+
})
1006+
).result
1007+
)
1008+
)
1009+
}
1010+
for (const maxDocuments of [101, 102]) {
1011+
const config = { workspaceSlug, projectId, baseUrl, maxDocuments }
1012+
const context: Record<string, unknown> = { totalDocsFetched: 0 }
1013+
const externalIds: string[] = []
1014+
let cursor: string | undefined
1015+
let hasMore = true
1016+
for (let page = 0; page < itemIds.length && hasMore; page++) {
1017+
const listing = await planeConnector.listDocuments(apiKey, config, cursor, context)
1018+
externalIds.push(...listing.documents.map((document) => document.externalId))
1019+
cursor = listing.nextCursor
1020+
hasMore = listing.hasMore
1021+
if (hasMore) assert(cursor, 'A continuing listing must provide its next cursor')
1022+
}
1023+
assert.equal(hasMore, false)
1024+
assert.equal(cursor, undefined)
1025+
assert.deepEqual(
1026+
externalIds,
1027+
itemIds.slice(0, maxDocuments).map((itemId) => `work_item:${itemId}`)
1028+
)
1029+
assert.equal(context.totalDocsFetched, maxDocuments)
1030+
if (maxDocuments < itemIds.length) assert.equal(context.listingCapped, true)
1031+
else assert.notEqual(context.listingCapped, true)
1032+
}
1033+
}
1034+
)
9791035
if (!publicCallback)
9801036
checks.push({
9811037
name: 'Signed public webhook delivery',

‎apps/sim/tools/plane/coverage.json‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -779,7 +779,7 @@
779779
"checked": 102,
780780
"failures": 0
781781
},
782-
"liveVerification": "Disposable-project HTTP checks exercise v2 and v1 compatibility on Plane Cloud. A real signed webhook delivery exercises registration, signature verification, filtering and cleanup. Community Edition is source-audited; no separate self-hosted server was live-tested.",
782+
"liveVerification": "The recorded Plane Cloud validation covers v2 and v1 compatibility and a real signed webhook delivery. These Cloud checks precede the connector cap fixes and have not been rerun for this follow-up. Official Community Edition v1.4.2 was also tested over real HTTP with production Sim actions and the knowledge connector, reverse-proxy paths, signed attachment storage, actual Celery webhook deliveries, and capped cursor continuations without duplicate source records.",
783783
"liveResponses": {
784784
"checked": 145,
785785
"failures": 0
@@ -791,10 +791,32 @@
791791
"successfulV2Operations": 67,
792792
"successfulV1CompatibilityVariants": 56,
793793
"verifiedSignedDeliveries": 1
794+
},
795+
"communityChecks": {
796+
"release": "v1.4.2",
797+
"reference": "https://github.com/makeplane/plane/releases/tag/v1.4.2",
798+
"passed": 23,
799+
"failed": 0,
800+
"skipped": 1,
801+
"requests": 180,
802+
"successfulV1Operations": 51,
803+
"additionalReverseProxyChecksPassed": 8,
804+
"verifiedSignedDeliveries": 354,
805+
"webhookProcessingErrors": 0,
806+
"cleanupVerified": true,
807+
"boundary": "Production action transport and knowledge connector over real provider HTTP; actual provider webhook deliveries through the production handler. Full Sim route/workflow execution is covered separately by synthetic HTTP and database integration checks.",
808+
"cursorPagination": {
809+
"sourceWorkItems": 102,
810+
"verifiedCaps": [101, 102],
811+
"distinctSourceOrderPreserved": true,
812+
"exactExhaustionNotMarkedCapped": true,
813+
"independentGuardReversionsVerified": true
814+
}
794815
}
795816
},
796817
"documentedLimitations": [
797818
"API availability depends on the Plane edition and version. Community Edition compatibility uses v1.",
819+
"Community Edition v1.4.2 does not expose the public project pages API or the v2 webhook management API. Work-item knowledge sync and manual webhook subscriptions were verified against the real Community server.",
798820
"Signed object-storage transfers are performed with the returned upload URL and form fields; asset initialization and confirmation are exposed as Plane actions.",
799821
"Expanded v2 objects without a published nested schema are preserved as JSON rather than assigning guessed fields.",
800822
"The knowledge connector uses v1 to access description content, which v2 work-item reads omit. Offset listings disable automatic deletion reconciliation.",

‎packages/deployment-config/src/integration-metadata.ts‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎packages/deployment-config/src/integrations.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18175,7 +18175,7 @@
1817518175
"name": "Plane",
1817618176
"description": "Manage projects and work items in Plane",
1817718177
"longDescription": "Connect Plane Cloud or a self-hosted Plane instance. API v2 is the default; choose API v1 for Community Edition or older instances. Actions with a current successor are consolidated. Unsupported version-specific inputs fail explicitly. Manage work items, projects, cycles, modules, states, labels, comments, links, attachments, pages, and webhooks. API v1 compatibility is available within supported current actions.",
18178-
"bgColor": "#3F76FF",
18178+
"bgColor": "#FFFFFF",
1817918179
"iconName": "PlaneIcon",
1818018180
"docsUrl": "https://docs.sim.ai/integrations/plane",
1818118181
"operations": [

0 commit comments

Comments
 (0)