Skip to content

Commit 9f34676

Browse files
authored
feat(granola): complete API coverage, note triggers, and connector validation (#6880)
* feat(granola): complete API coverage, note triggers, and validation fixes Granola's public API exposes nine endpoints; Sim implemented three. Adds the remaining six and wires the new programmatic webhook-endpoint lifecycle into a managed trigger. Tools (6 new, 9 total): - get_transcript, list_audit_events - create/list/update/delete_webhook_endpoint Triggers: note.generated, note.edited, note.access_granted, plus an all-events trigger. The provider handler registers the Granola endpoint on deploy and deletes it on undeploy, scoped to the trigger's own event names, and verifies every delivery with the Standard Webhooks HMAC-SHA256 signature Granola returns on creation. event_id is the idempotency key, which Granola reuses across retries. Validation fixes to the shipped tools: - get_note dropped speaker.attribution ("me"/"them"); now surfaced - a 413 on get_note now explains that the transcript is too large inline and points at get_transcript, instead of surfacing a bare status code - note IDs are URL-encoded rather than interpolated raw - base URL, auth headers, and status-aware error handling are shared runtime helpers; params/outputs stay literal per file so the docs generator still reads them Tests cover signature verification (including replay and body-tamper rejection), event matching, subscription create/delete, and the block/tool contract — plus a guard that ids shared between the tool and trigger surfaces seed the same default, since block state is keyed by id and last-wins. The knowledge-base connector was validated against the spec and needed no changes. * fix(granola): correct array output schemas, listing-truncation signal, and docs Findings from validation passes over the tools, trigger, and connector. Tools — array outputs were declared as `type: 'json'` with `properties`, which describes an object, not an array. Agents and the output picker therefore saw `notes.title` instead of `notes[i].title`. All 15 array outputs (including the pre-existing three tools) now use `type: 'array'` with `items`, matching the 2000+ other tool files. The audit event `data` field stays `json`; it is genuinely free-form per the spec. Connector — `hasMore` was ANDed with the cursor, so a `hasMore: true` response with no cursor was reported as a complete listing. The sync engine treats exactly that shape as truncated and sets `listingTruncated` to block deletion reconciliation; masking it meant a partial first page could be taken for the whole corpus and reconciliation would hard-delete every note past it. Granola would have to violate its own contract to emit that shape, but the engine already handles it and the connector was hiding the signal. Also aligns mimeType with the `.txt`/text-plain bytes the engine actually writes (it was the only connector of 101 claiming text/markdown). Trigger — the setup instructions named a Granola settings path that does not exist; the help center says Settings > Connectors > API keys in the desktop app. Both list parsers now split commas inside array entries, so an array-wrapped free-text value cannot be sent as one malformed identifier. Block — `id`, `events`, and `hasMore` are produced by several operations but their descriptions named only one, unlike `folders` which already documented both meanings. Adds connector tests pinning all four listingCapped quadrants and the truncation signal, and tool tests for the list parser and the PATCH body's per-field "omit means unchanged" semantics. * fix(granola): clean up webhook endpoints created by a failed registration Raised independently by both reviewers. The registration service only rolls external state back when createSubscription *returns* — its rollback is guarded on `preparedProviderConfig`, so a handler that throws is assumed to have left nothing behind. Granola's handler broke that contract: when Granola accepted the POST but the success body was missing `id` or `signing_secret` (including a body that failed to parse and became `{}`), it threw with the endpoint already live. Nothing then recorded an external id, so undeploy could not remove it, and Granola kept delivering to a callback whose signature could never be verified — duplicating on every deploy retry. The handler now removes what it created before rethrowing, matching the pattern grain's multi-hook create already uses. It deletes by id when Granola returned one, and otherwise recovers the endpoint by matching the callback URL, which also covers a connection that fails after the request reached Granola. Endpoints whose URL was redacted to its origin are never matched — that comparison could delete another workflow's endpoint on the same host. Cleanup is best effort and never masks the original failure. A non-2xx is left alone, since no endpoint was created. Also folds the delete call shared with deleteSubscription into one helper. * fix(granola): never recover an orphaned endpoint by callback URL The previous commit's URL-based recovery was unsafe. A redeploy reuses the live registration's `path`, so the candidate and the currently serving endpoint share a callback URL — listing by that URL and deleting every match would remove the live deployment's endpoint and silently stop a working trigger, which is worse than the leak it was trying to prevent. Cleanup is now keyed solely on the id Granola returned. When the success body carries no id there is no way to tell the candidate's endpoint from the live one, so it is left in place: a leaked endpoint produces unverifiable deliveries that Granola disables on its own, whereas deleting the wrong one takes down live traffic with no signal. The 2xx-missing-signing-secret case this originally fixed still cleans up, since that response does carry an id. Adds a test asserting no lookup or delete is attempted when the response has no id, so URL matching cannot be reintroduced unnoticed.
1 parent 02ae2b4 commit 9f34676

33 files changed

Lines changed: 3265 additions & 99 deletions

apps/docs/content/docs/en/integrations/granola.mdx

Lines changed: 282 additions & 8 deletions
Large diffs are not rendered by default.
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* Guards the block/tool contract: the operation dropdown, `tools.access`, the tool params, and the
3+
* declared inputs all describe the same set of operations, and drift in any one of them fails here.
4+
*
5+
* Also guards the seeded-default rule for duplicate subBlock ids — block state is keyed by id and
6+
* the last definition in file order wins, so ids shared between the tool surface and trigger mode
7+
* (`apiKey`, `scopes`, `folderIds`) must agree on the value they seed.
8+
*
9+
* @vitest-environment node
10+
*/
11+
import { describe, expect, it } from 'vitest'
12+
import { GranolaBlock } from '@/blocks/blocks/granola'
13+
import type { SubBlockConfig } from '@/blocks/types'
14+
import * as granolaTools from '@/tools/granola'
15+
import type { ToolConfig } from '@/tools/types'
16+
17+
const TRIGGER_FIELDS = new Set(['operation', 'selectedTriggerId'])
18+
19+
const toolsById = new Map<string, ToolConfig>(
20+
Object.values(granolaTools).map((tool) => [tool.id, tool])
21+
)
22+
23+
const subBlocks: SubBlockConfig[] = GranolaBlock.subBlocks
24+
const access: string[] = GranolaBlock.tools.access ?? []
25+
const declaredInputs = Object.keys(GranolaBlock.inputs ?? {})
26+
27+
const operationSubBlock = subBlocks.find((subBlock) => subBlock.id === 'operation')
28+
const operationOptions =
29+
typeof operationSubBlock?.options === 'function'
30+
? operationSubBlock.options()
31+
: (operationSubBlock?.options ?? [])
32+
const operations = operationOptions.map((option) => option.id as string)
33+
34+
/** The block maps an operation onto its tool by prefixing the service name. */
35+
const toolIdFor = (operation: string) => `granola_${operation}`
36+
37+
/**
38+
* Trigger mode re-declares its own credential fields keyed on `selectedTriggerId`, so only the
39+
* operation-gated subblocks describe the tool surface.
40+
*/
41+
const operationSubBlocks = subBlocks.filter((subBlock) => {
42+
const condition = subBlock.condition
43+
if (typeof condition === 'function') return false
44+
return subBlock.id === 'operation' || !condition || condition.field === 'operation'
45+
})
46+
47+
function visibleFor(operation: string): string[] {
48+
return operationSubBlocks
49+
.filter((subBlock) => {
50+
const condition = subBlock.condition
51+
if (typeof condition === 'function' || !condition) return true
52+
return Array.isArray(condition.value)
53+
? condition.value.includes(operation)
54+
: condition.value === operation
55+
})
56+
.map((subBlock) => subBlock.id)
57+
}
58+
59+
describe('granola block/tool alignment', () => {
60+
it('maps every operation to a registered tool and back', () => {
61+
expect(operations.filter((operation) => !access.includes(toolIdFor(operation)))).toEqual([])
62+
expect(access.filter((tool) => !operations.map(toolIdFor).includes(tool))).toEqual([])
63+
expect(access.filter((tool) => !toolsById.has(tool))).toEqual([])
64+
})
65+
66+
it('keeps operation-gated subBlock ids unique', () => {
67+
const ids = operationSubBlocks.map((subBlock) => subBlock.id)
68+
expect(ids.filter((id, index) => ids.indexOf(id) !== index)).toEqual([])
69+
})
70+
71+
it('exposes a subBlock for every required tool param', () => {
72+
const missing: string[] = []
73+
74+
for (const operation of operations) {
75+
const tool = toolsById.get(toolIdFor(operation))
76+
if (!tool) continue
77+
const visible = visibleFor(operation)
78+
79+
for (const [name, param] of Object.entries(tool.params)) {
80+
if (param.visibility === 'hidden' || !param.required) continue
81+
if (!visible.includes(name)) missing.push(`${operation}.${name}`)
82+
}
83+
}
84+
85+
expect(missing).toEqual([])
86+
})
87+
88+
it('backs every visible subBlock with a param on its operation tool', () => {
89+
const stray: string[] = []
90+
91+
for (const operation of operations) {
92+
const tool = toolsById.get(toolIdFor(operation))
93+
if (!tool) continue
94+
95+
for (const id of visibleFor(operation)) {
96+
if (TRIGGER_FIELDS.has(id)) continue
97+
if (!tool.params[id]) stray.push(`${operation}.${id}`)
98+
}
99+
}
100+
101+
expect(stray).toEqual([])
102+
})
103+
104+
it('declares every operation-gated subBlock in block inputs', () => {
105+
const undeclared = operationSubBlocks
106+
.filter((subBlock) => !TRIGGER_FIELDS.has(subBlock.id) && subBlock.condition)
107+
.map((subBlock) => subBlock.id)
108+
.filter((id) => !declaredInputs.includes(id))
109+
110+
expect(undeclared).toEqual([])
111+
})
112+
})
113+
114+
describe('granola duplicate subBlock defaults', () => {
115+
it('seeds one value per subBlock id across the tool and trigger surfaces', () => {
116+
const seeded = new Map<string, unknown[]>()
117+
118+
for (const subBlock of subBlocks) {
119+
const value =
120+
typeof subBlock.value === 'function' ? subBlock.value({}) : (subBlock.value ?? null)
121+
const existing = seeded.get(subBlock.id) ?? []
122+
existing.push(value ?? null)
123+
seeded.set(subBlock.id, existing)
124+
}
125+
126+
const divergent = [...seeded.entries()]
127+
.filter(([, values]) => new Set(values.map((v) => JSON.stringify(v ?? null))).size > 1)
128+
.map(([id, values]) => `${id}: ${JSON.stringify(values)}`)
129+
130+
expect(divergent).toEqual([])
131+
})
132+
})
133+
134+
describe('granola trigger wiring', () => {
135+
it('registers every available trigger and renders its subBlocks', () => {
136+
const available = GranolaBlock.triggers?.available ?? []
137+
138+
expect(GranolaBlock.triggers?.enabled).toBe(true)
139+
expect(available).toEqual([
140+
'granola_note_generated',
141+
'granola_note_edited',
142+
'granola_note_access_granted',
143+
'granola_webhook',
144+
])
145+
146+
/* Each trigger contributes a webhook URL display gated on its own id. */
147+
for (const triggerId of available) {
148+
const rendered = subBlocks.some((subBlock) => {
149+
const condition = subBlock.condition
150+
if (typeof condition === 'function' || !condition) return false
151+
return (
152+
condition.field === 'selectedTriggerId' &&
153+
(Array.isArray(condition.value)
154+
? condition.value.includes(triggerId)
155+
: condition.value === triggerId)
156+
)
157+
})
158+
expect(rendered, `no subBlocks rendered for ${triggerId}`).toBe(true)
159+
}
160+
})
161+
})

0 commit comments

Comments
 (0)