Skip to content

Commit 1f7f5bd

Browse files
committed
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.
1 parent f17938c commit 1f7f5bd

27 files changed

Lines changed: 2715 additions & 45 deletions

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

Lines changed: 276 additions & 2 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)