diff --git a/packages/core/src/triggers/catalog.generated.json b/packages/core/src/triggers/catalog.generated.json index 32a279a1..c7825f1f 100644 --- a/packages/core/src/triggers/catalog.generated.json +++ b/packages/core/src/triggers/catalog.generated.json @@ -153,6 +153,7 @@ "calendar.sync" ], "granola": [ + "file.created", "recording.created" ], "hubspot": [ diff --git a/packages/core/src/triggers/catalog.generated.ts b/packages/core/src/triggers/catalog.generated.ts index cd0d327d..f4f68956 100644 --- a/packages/core/src/triggers/catalog.generated.ts +++ b/packages/core/src/triggers/catalog.generated.ts @@ -161,6 +161,7 @@ export const KNOWN_TRIGGER_CATALOG = { "calendar.sync" ], "granola": [ + "file.created", "recording.created" ], "hubspot": [ diff --git a/packages/granola/granola.mapping.yaml b/packages/granola/granola.mapping.yaml index 5d4934bf..bcb65898 100644 --- a/packages/granola/granola.mapping.yaml +++ b/packages/granola/granola.mapping.yaml @@ -11,3 +11,9 @@ webhooks: - created_at - updated_at - web_url + # Storage-bridge event: emitted when any pipeline (e.g. NB-Whisper) writes + # a note directly to the granola VFS mount (/granola/notes/.json). + # This is NOT a Granola API webhook — it fires via the storage-change + # dispatcher the same way gmail/google-drive file.created events do. + file.created: + description: A note was written to the granola VFS mount (storage-bridge path, e.g. from the NB-Whisper transcript pipeline). diff --git a/packages/slack/discovery/slack/.adapter.md b/packages/slack/discovery/slack/.adapter.md index a0c7af1a..737ad791 100644 --- a/packages/slack/discovery/slack/.adapter.md +++ b/packages/slack/discovery/slack/.adapter.md @@ -1,25 +1,23 @@ # Slack adapter -The Slack adapter exposes channels, users, messages, threads, replies, files, and reactions under `/slack`, with writeback routes for posting channel messages, direct messages, replies, and reactions. It also advertises history-independent discovery lookup indexes for Slack channel and user ids under `/discovery/slack`. +The Slack adapter exposes channels, users, messages, threads, replies, files, and reactions under `/slack`, with writeback routes for posting channel messages, direct messages, replies, and reactions. Direct messages use `/slack/users//messages` as the product contract for both reads and writes; Slack internal `D...` IM channel ids stay in record payload metadata. It also advertises history-independent discovery lookup indexes for Slack channel and user ids under `/discovery/slack`. Read-only mounts: - `/slack/channels/.json` - Channel records. - `/slack/channels//messages//meta.json` - Message records. -- `/slack/channels//messages//replies/.json` - Thread reply records. +- `/slack/channels//threads//replies//meta.json` - Thread reply records (directory records; legacy flat `/messages//replies/.json` writeback paths still route for edits/deletes). - `/slack/users/.json` - User records. - `/slack/users//messages//meta.json` - 1:1 direct message records addressed by bare Slack user id. -- `/slack/users//messages//replies/.json` - Threaded replies in a 1:1 direct message. +- `/slack/users//messages//replies//meta.json` - Threaded replies in a 1:1 direct message (directory records; legacy flat `.json` still readable). - `/discovery/slack/channels/_index.json` - History-independent channel id/name lookup rows for writeback context, materialized from Slack channel discovery syncs. - `/discovery/slack/users/_index.json` - History-independent user id/name lookup rows for direct-message writeback context, materialized from Slack user discovery syncs. -Direct messages use `/slack/users//messages` as the product contract for both reads and writes. Slack's internal `D...` IM channel id is preserved in record payload metadata as the source conversation id; raw `D...` channel paths are diagnostic/legacy only and are not the mount contract for DMs. - Resources: | Resource | Schema | Create example | ID pattern | What it does | |---|---|---|---|---| | `/slack/channels/{channelId}/messages/.json` | `/slack/channels/{channelId}/messages/.schema.json` | `/slack/channels/{channelId}/messages/.create.example.json` | `^(?:meta\|(?:[A-Za-z0-9_.:-]+--)?\d{10,}(?:_\d+)?)$` | Posts a top-level Slack message. | -| `/slack/users/{userId}/messages/.json` | `/slack/users/{userId}/messages/.schema.json` | `/slack/users/{userId}/messages/.create.example.json` | `^$` | Reads 1:1 direct message records and opens or reuses a direct message conversation when posting a new draft. | +| `/slack/users/{userId}/messages/.json` | `/slack/users/{userId}/messages/.schema.json` | `/slack/users/{userId}/messages/.create.example.json` | `^$` | Opens or reuses a direct message conversation and posts a Slack message. | | `/slack/channels/{channelId}/messages/{messageTs}/replies/.json` | `/slack/channels/{channelId}/messages/{messageTs}/replies/.schema.json` | `/slack/channels/{channelId}/messages/{messageTs}/replies/.create.example.json` | `^(?:[A-Za-z0-9_.:-]+--)?\d{10,}(?:_\d+)?$` | Posts a reply in a Slack thread. | | `/slack/channels/{channelId}/messages/{messageTs}/reactions/.json` | `/slack/channels/{channelId}/messages/{messageTs}/reactions/.schema.json` | `/slack/channels/{channelId}/messages/{messageTs}/reactions/.create.example.json` | `^[A-Za-z0-9_.:-]+(?:--[A-Za-z0-9_.:-]+)*$` | Adds an emoji reaction to a Slack message. | @@ -64,7 +62,7 @@ Fields: - `unfurl_media` (optional, boolean) - Whether Slack should unfurl media. - `mrkdwn` (optional, boolean) - Whether Slack should parse mrkdwn in text. -### Slack direct message +### Post Slack direct message Resource: `/slack/users/{userId}/messages/.json` Schema: `/slack/users/{userId}/messages/.schema.json` @@ -73,8 +71,6 @@ Required fields: none at the top level. Optional fields: `text`, `blocks`, `attachments`, `username`, `icon_emoji`, `icon_url`, `unfurl_links`, `unfurl_media`, `mrkdwn`. Validation: provide at least one of `text`, `blocks`, `attachments`. -Synced 1:1 DM records are materialized at `/slack/users//messages//meta.json` and keep Slack's raw `D...` conversation id in read-only fields such as `channel`, `channelId`, or `_webhook.raw_event.channel` when present. New draft writes in the same directory use `conversations.open` with the bare `` and then post the message. - Fields: - `text` (optional, string) - Message text. Required unless blocks or attachments are supplied. diff --git a/packages/slack/discovery/slack/users/{userId}/messages/.schema.json b/packages/slack/discovery/slack/users/{userId}/messages/.schema.json index 5d44f6cb..3135de3b 100644 --- a/packages/slack/discovery/slack/users/{userId}/messages/.schema.json +++ b/packages/slack/discovery/slack/users/{userId}/messages/.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Slack direct message", + "title": "Post Slack direct message", "type": "object", "required": [], "anyOf": [ @@ -135,5 +135,5 @@ } }, "additionalProperties": false, - "description": "Full direct message resource record schema. Synced records are read under /slack/users/{userId}/messages//meta.json; create drafts in the same directory post a Slack DM. Fields marked readOnly are synced from the provider and cannot be written by agents." + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." } diff --git a/packages/slack/src/__tests__/path-mapper-v2.test.ts b/packages/slack/src/__tests__/path-mapper-v2.test.ts index edc5b620..d634ccde 100644 --- a/packages/slack/src/__tests__/path-mapper-v2.test.ts +++ b/packages/slack/src/__tests__/path-mapper-v2.test.ts @@ -6,12 +6,18 @@ import { channelMessagesDirectory, directMessageDirectory, directMessagePath, + directMessageThreadReplyLegacyPath, directMessageThreadReplyPath, messageLegacyPath, messagePath, parseSlackDirectMessagePath, parseSlackDirectMessageThreadReplyPath, + reactionPath, slackBotsAliasPath, + slackDirectMessageThreadReplyReadCandidatePaths, + slackThreadReplyReadCandidatePaths, + threadReplyLegacyPath, + threadReplyPath, slackByNameChannelAliasPath, slackByNameUserAliasPath, slackChannelsIndexPath, @@ -112,7 +118,7 @@ test('direct message paths use bare user id message roots', () => { ); assert.equal( directMessageThreadReplyPath('U0123ABCDEF', '1711111111.000100', '1711111222.000200'), - '/slack/users/U0123ABCDEF/messages/1711111111_000100/replies/1711111222_000200.json', + '/slack/users/U0123ABCDEF/messages/1711111111_000100/replies/1711111222_000200/meta.json', ); assert.deepEqual( parseSlackDirectMessagePath(directMessagePath('U0123ABCDEF', '1711111111.000100')), @@ -131,6 +137,28 @@ test('direct message paths use bare user id message roots', () => { replyTs: '1711111222.000200', }, ); + // Legacy flat reply paths must still parse so routing works mid-migration. + assert.deepEqual( + parseSlackDirectMessageThreadReplyPath( + directMessageThreadReplyLegacyPath('U0123ABCDEF', '1711111111.000100', '1711111222.000200'), + ), + { + userId: 'U0123ABCDEF', + messageTs: '1711111111.000100', + replyTs: '1711111222.000200', + }, + ); + assert.deepEqual( + slackDirectMessageThreadReplyReadCandidatePaths( + 'U0123ABCDEF', + '1711111111.000100', + '1711111222.000200', + ), + [ + '/slack/users/U0123ABCDEF/messages/1711111111_000100/replies/1711111222_000200/meta.json', + '/slack/users/U0123ABCDEF/messages/1711111111_000100/replies/1711111222_000200.json', + ], + ); assert.equal(parseSlackDirectMessagePath('/slack/channels/D123/messages/1711111111_000100/meta.json'), null); }); @@ -213,3 +241,52 @@ test('slackBotsAliasPath emits /slack/users/bots/__.json', () => { test('slackBotsAliasPath falls back to bare id when no name is given', () => { assert.equal(slackBotsAliasPath('B0123BOT'), '/slack/users/bots/B0123BOT.json'); }); + +test('threadReplyPath is a directory record and does not collide with its reaction children', () => { + const channelId = 'C123'; + const threadTs = '1711111111.000100'; + const replyTs = '1711111222.000200'; + + const reply = threadReplyPath(channelId, threadTs, replyTs); + assert.equal( + reply, + '/slack/channels/C123/threads/1711111111_000100/replies/1711111222_000200/meta.json', + ); + + // The reply's children (reactions) must nest UNDER the reply's directory — + // never as a sibling that shares the reply's name with a different node type. + // This is the invariant whose violation wedged the mount: a flat leaf file + // `replies/.json` could not coexist with the `replies//` directory. + const replyDir = reply.replace(/\/meta\.json$/u, ''); + const reaction = reactionPath({ + targetType: 'thread_reply', + channelId, + threadTs, + replyTs, + reaction: 'tada', + userId: 'U1', + }); + assert.equal( + reaction, + `${replyDir}/reactions/tada--U1.json`, + ); + assert.ok( + reaction.startsWith(`${replyDir}/`), + 'reaction must nest under the reply directory', + ); + assert.ok( + !reaction.startsWith(`${replyDir}.json`), + 'reply stem must be a directory, not a flat .json file', + ); + + // Back-compat: readers can still resolve a reply mirrored by a pre-0.8.x + // adapter at the legacy flat path. + assert.deepEqual(slackThreadReplyReadCandidatePaths(channelId, threadTs, replyTs), [ + reply, + threadReplyLegacyPath(channelId, threadTs, replyTs), + ]); + assert.equal( + threadReplyLegacyPath(channelId, threadTs, replyTs), + '/slack/channels/C123/threads/1711111111_000100/replies/1711111222_000200.json', + ); +}); diff --git a/packages/slack/src/__tests__/slack-adapter.test.ts b/packages/slack/src/__tests__/slack-adapter.test.ts index 1c857915..4e502642 100644 --- a/packages/slack/src/__tests__/slack-adapter.test.ts +++ b/packages/slack/src/__tests__/slack-adapter.test.ts @@ -386,11 +386,11 @@ test('message and thread path mapping is deterministic', () => { assert.equal( adapter.computePath('thread_reply', replyId), - '/slack/channels/C123/threads/1711111111_000100/replies/1711111222_000200.json', + '/slack/channels/C123/threads/1711111111_000100/replies/1711111222_000200/meta.json', ); assert.equal( computeSlackPath('thread_reply', replyId), - '/slack/channels/C123/threads/1711111111_000100/replies/1711111222_000200.json', + '/slack/channels/C123/threads/1711111111_000100/replies/1711111222_000200/meta.json', ); }); diff --git a/packages/slack/src/emit-auxiliary-files.ts b/packages/slack/src/emit-auxiliary-files.ts index ed4a2bc5..3e58b25a 100644 --- a/packages/slack/src/emit-auxiliary-files.ts +++ b/packages/slack/src/emit-auxiliary-files.ts @@ -26,10 +26,10 @@ * Bot-flip (`is_bot: true → false`) deletes the stale `bots/` alias * while leaving `by-name` in place. * - * 3. **Message** / **Thread** / **Thread reply** records emit only the - * canonical `meta.json` (and per-reply `.json`) under their - * channel directory. No alias fan-out and no index file at this - * level. Because the message record itself doesn't carry the parent + * 3. **Message** / **Thread** / **Thread reply** records emit only their + * canonical `meta.json` under the channel directory. No alias fan-out + * and no index file at this level. Because the message record itself + * doesn't carry the parent * channel name, the path falls back to the bare `` segment * — readers join via the channel index to discover the human-readable * directory name. diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts index 25af4fb5..ff24adc9 100644 --- a/packages/slack/src/index.ts +++ b/packages/slack/src/index.ts @@ -9,6 +9,7 @@ export { createSlackThreadReplyObjectId, directMessageDirectory, directMessagePath, + directMessageThreadReplyLegacyPath, directMessageThreadReplyPath, fileCommentPath, fileMetadataPath, @@ -26,12 +27,15 @@ export { slackByNameChannelAliasPath, slackByNameUserAliasPath, slackChannelsIndexPath, + slackDirectMessageThreadReplyReadCandidatePaths, slackMessageReadCandidatePaths, slackNameWithId, slackRootIndexPath, + slackThreadReplyReadCandidatePaths, slackTimestampToPathToken, slackUsersIndexPath, threadPath, + threadReplyLegacyPath, threadReplyPath, userMetadataPath, } from './path-mapper.js'; diff --git a/packages/slack/src/layout-prompt.ts b/packages/slack/src/layout-prompt.ts index 2ccdb528..8d510b40 100644 --- a/packages/slack/src/layout-prompt.ts +++ b/packages/slack/src/layout-prompt.ts @@ -10,11 +10,11 @@ Always run \`ls\` before constructing a path. v2 standardizes resource directory \`/slack/channels/__/\` owns per-channel records: - \`meta.json\` — canonical channel record. - \`messages//meta.json\` — canonical top-level message records. Message text is mutable, so the stable Slack timestamp is the directory key. - - \`threads//meta.json\` and \`threads//replies/.json\` — thread roots and replies. + - \`threads//meta.json\` and \`threads//replies//meta.json\` — thread roots and replies (each a directory record, so a reply can carry \`reactions/\`). - \`messages//reactions/--.json\` — reaction records. \`/slack/users/__/meta.json\` — canonical user record. \`/slack/users//messages//meta.json\` — canonical 1:1 direct message records addressed by bare user id. Slack's internal \`D…\` IM channel id stays inside the JSON payload as source metadata; do not mount or write raw \`D…\` paths as the product contract. -\`/slack/users//messages//replies/.json\` — threaded replies in a 1:1 direct message. +\`/slack/users//messages//replies//meta.json\` — threaded replies in a 1:1 direct message (directory record). \`/slack/users/by-name/.json\` and \`/slack/channels/by-name/.json\` — name-keyed alias files pointing to canonical records. Collisions are disambiguated with a short id-derived hash suffix (e.g. \`sam-3b1a9f7c.json\`). \`/slack/users/bots/__.json\` — alias subtree of bot users only, for \`ls\`-style discovery. \`/discovery/slack/channels/_index.json\` and \`/discovery/slack/users/_index.json\` are history-independent lookup indexes for writeback context. They are populated from Slack channel/user discovery syncs and can be mounted even when historical message records under \`/slack/channels/**\` or \`/slack/users/**\` are not mounted. diff --git a/packages/slack/src/path-mapper.ts b/packages/slack/src/path-mapper.ts index 671073c3..6836cf6d 100644 --- a/packages/slack/src/path-mapper.ts +++ b/packages/slack/src/path-mapper.ts @@ -351,10 +351,37 @@ export function directMessagePath(userId: string, messageTs: string): string { ); } +/** + * Canonical 1:1 direct-message thread-reply record path. Like + * {@link threadReplyPath}, the reply is a directory record + * (`replies//meta.json`) so its stem is a directory and can carry children + * without the file/dir name collision that wedges a POSIX mount. Pre-0.8.x + * emitted a flat `replies/.json` leaf — read it back via + * {@link slackDirectMessageThreadReplyReadCandidatePaths}. + */ export function directMessageThreadReplyPath( userId: string, threadTs: string, replyTs: string, +): string { + return joinPath( + directMessageDirectory(userId), + messageSegmentV2(threadTs), + 'replies', + messageSegmentV2(replyTs), + 'meta.json', + ); +} + +/** + * @deprecated Pre-0.8.x emitted a flat `.../replies/.json` leaf. Use + * {@link directMessageThreadReplyPath}. Retained for back-compat reads only — + * see {@link slackDirectMessageThreadReplyReadCandidatePaths}. + */ +export function directMessageThreadReplyLegacyPath( + userId: string, + threadTs: string, + replyTs: string, ): string { return joinPath( directMessageDirectory(userId), @@ -364,6 +391,22 @@ export function directMessageThreadReplyPath( ); } +/** + * Reader hint: candidate paths for a DM thread-reply canonical record, current + * (`/meta.json`) then legacy (`.json`), so a reply mirrored by either + * the current or a pre-0.8.x adapter still reads. + */ +export function slackDirectMessageThreadReplyReadCandidatePaths( + userId: string, + threadTs: string, + replyTs: string, +): string[] { + return [ + directMessageThreadReplyPath(userId, threadTs, replyTs), + directMessageThreadReplyLegacyPath(userId, threadTs, replyTs), + ]; +} + export function parseSlackDirectMessagePath(path: string): SlackDirectMessageReference | null { const match = /^\/slack\/users\/([^/]+)\/messages\/([^/]+)\/meta\.json$/.exec(path); if (!match?.[1] || !match[2]) { @@ -379,7 +422,11 @@ export function parseSlackDirectMessagePath(path: string): SlackDirectMessageRef export function parseSlackDirectMessageThreadReplyPath( path: string, ): SlackDirectMessageThreadReplyReference | null { - const match = /^\/slack\/users\/([^/]+)\/messages\/([^/]+)\/replies\/([^/]+)\.json$/.exec(path); + // Accept both the current reply record (`replies//meta.json`) and the + // legacy flat leaf (`replies/.json`) so routing works mid-migration. + const match = /^\/slack\/users\/([^/]+)\/messages\/([^/]+)\/replies\/([^/]+?)(?:\/meta)?\.json$/.exec( + path, + ); if (!match?.[1] || !match[2] || !match[3]) { return null; } @@ -403,11 +450,44 @@ export function threadPath(channelId: string, threadTs: string, channelName?: st ); } +/** + * Canonical thread-reply record path. The reply is a **directory record** + * (`replies//meta.json`) — matching `messagePath`, `directMessagePath`, and + * `threadPath`, all of which use `/meta.json`. This is deliberate: a reply + * can carry children (reactions live at `replies//reactions/...`, see + * {@link reactionPath}), so its stem MUST be a directory. The pre-0.8.x adapter + * wrote a flat leaf file `replies/.json`, which collided with that same + * `` directory — one name as both a file and a directory — and could not be + * materialized on a POSIX mount (`mkdir ... : not a directory`), wedging the + * whole mirror. Readers should fall back to the legacy filename via + * {@link slackThreadReplyReadCandidatePaths}. + */ export function threadReplyPath( channelId: string, threadTs: string, replyTs: string, channelName?: string, +): string { + return joinPath( + channelThreadsDirectory(channelId, channelName), + slackTimestampToPathToken(threadTs), + 'replies', + slackTimestampToPathToken(replyTs), + 'meta.json', + ); +} + +/** + * @deprecated Pre-0.8.x emitted a flat `.../replies/.json` leaf file, which + * collided with the `` reaction directory. Use {@link threadReplyPath}. + * Retained for back-compat reads only — see + * {@link slackThreadReplyReadCandidatePaths}. + */ +export function threadReplyLegacyPath( + channelId: string, + threadTs: string, + replyTs: string, + channelName?: string, ): string { return joinPath( channelThreadsDirectory(channelId, channelName), @@ -417,6 +497,23 @@ export function threadReplyPath( ); } +/** + * Reader hint: candidate paths for a Slack thread-reply canonical record, in + * order of preference — current (`/meta.json`) then legacy (`.json`) — + * so a reply mirrored by either the current or a pre-0.8.x adapter still reads. + */ +export function slackThreadReplyReadCandidatePaths( + channelId: string, + threadTs: string, + replyTs: string, + channelName?: string, +): string[] { + return [ + threadReplyPath(channelId, threadTs, replyTs, channelName), + threadReplyLegacyPath(channelId, threadTs, replyTs, channelName), + ]; +} + export function userMetadataPath(userId: string, userName?: string): string { return joinPath(SLACK_ROOT, 'users', slackNameWithId(userName, userId), 'meta.json'); } diff --git a/packages/slack/src/thread.ts b/packages/slack/src/thread.ts index 8219f7d8..6ae770ec 100644 --- a/packages/slack/src/thread.ts +++ b/packages/slack/src/thread.ts @@ -68,7 +68,12 @@ function extractRepliesPath(path: string): string | null { return path.replace(/\/meta\.json$/u, '/replies'); } - const match = path.match(/^(\/slack\/channels\/[^/]+\/threads\/[^/]+)\/replies\/[^/]+\.json$/u); + // Accept both the current reply record (`replies//meta.json`) and the + // legacy flat leaf (`replies/.json`) so a mirror mid-migration still maps + // a reply path back to its parent replies listing. + const match = path.match( + /^(\/slack\/channels\/[^/]+\/threads\/[^/]+)\/replies\/[^/]+(?:\/meta)?\.json$/u, + ); return match?.[1] ? `${match[1]}/replies` : null; } diff --git a/scripts/writeback-discovery-data.mjs b/scripts/writeback-discovery-data.mjs index 65f8a340..f07365cb 100644 --- a/scripts/writeback-discovery-data.mjs +++ b/scripts/writeback-discovery-data.mjs @@ -395,12 +395,14 @@ export const adapters = [ slug: 'slack', title: 'Slack adapter', overview: - 'The Slack adapter exposes channels, users, messages, threads, replies, files, and reactions under `/slack`, with writeback routes for posting channel messages, direct messages, replies, and reactions. It also advertises history-independent discovery lookup indexes for Slack channel and user ids under `/discovery/slack`.', + 'The Slack adapter exposes channels, users, messages, threads, replies, files, and reactions under `/slack`, with writeback routes for posting channel messages, direct messages, replies, and reactions. Direct messages use `/slack/users//messages` as the product contract for both reads and writes; Slack internal `D...` IM channel ids stay in record payload metadata. It also advertises history-independent discovery lookup indexes for Slack channel and user ids under `/discovery/slack`.', readPaths: [ ['/slack/channels/.json', 'Channel records.'], ['/slack/channels//messages//meta.json', 'Message records.'], - ['/slack/channels//messages//replies/.json', 'Thread reply records.'], + ['/slack/channels//threads//replies//meta.json', 'Thread reply records (directory records; legacy flat `/messages//replies/.json` writeback paths still route for edits/deletes).'], ['/slack/users/.json', 'User records.'], + ['/slack/users//messages//meta.json', '1:1 direct message records addressed by bare Slack user id.'], + ['/slack/users//messages//replies//meta.json', 'Threaded replies in a 1:1 direct message (directory records; legacy flat `.json` still readable).'], ['/discovery/slack/channels/_index.json', 'History-independent channel id/name lookup rows for writeback context, materialized from Slack channel discovery syncs.'], ['/discovery/slack/users/_index.json', 'History-independent user id/name lookup rows for direct-message writeback context, materialized from Slack user discovery syncs.'], ],