@open-everywhere/provider-notion 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +48 -0
- package/agent/channels/notion-acting-identity.ts +88 -0
- package/agent/channels/notion-ancestry-trust.ts +458 -0
- package/agent/channels/notion-api.ts +1290 -0
- package/agent/channels/notion-bot-user.ts +250 -0
- package/agent/channels/notion-catch-up.ts +339 -0
- package/agent/channels/notion-credentials.ts +417 -0
- package/agent/channels/notion-engagement.ts +372 -0
- package/agent/channels/notion-event-normalizer.ts +587 -0
- package/agent/channels/notion-identity.ts +271 -0
- package/agent/channels/notion-ingress-drain.ts +289 -0
- package/agent/channels/notion-ingress.ts +182 -0
- package/agent/channels/notion-message-ops.ts +140 -0
- package/agent/channels/notion-origin-codec.ts +38 -0
- package/agent/channels/notion-post-arm.ts +189 -0
- package/agent/channels/notion-question-return.ts +223 -0
- package/agent/channels/notion-read.ts +283 -0
- package/agent/channels/notion-scope.ts +560 -0
- package/agent/channels/notion-stop.ts +169 -0
- package/agent/channels/notion-supervisor.ts +856 -0
- package/agent/channels/notion-webhook-timing.ts +131 -0
- package/agent/channels/notion-webhook-verifier.ts +55 -0
- package/agent/channels/notion-webhook.ts +963 -0
- package/agent/channels/notion-work-search.ts +74 -0
- package/agent/channels/notion-work.ts +144 -0
- package/agent/channels/notion.ts +1242 -0
- package/agent/contributions.ts +39 -0
- package/agent/docs-generated.ts +15 -0
- package/agent/memory/blocks.ts +295 -0
- package/agent/memory/memory.ts +622 -0
- package/agent/provider-events.ts +198 -0
- package/agent/register.ts +144 -0
- package/agent/schedules/notion-ingress.ts +20 -0
- package/agent/surface-parity.ts +105 -0
- package/agent/tools/notion_search.ts +110 -0
- package/agent/tools/read_notion_comments.ts +250 -0
- package/agent/tools/read_notion_page.ts +351 -0
- package/dist/agent/channels/notion-acting-identity.d.ts +23 -0
- package/dist/agent/channels/notion-acting-identity.d.ts.map +1 -0
- package/dist/agent/channels/notion-acting-identity.js +85 -0
- package/dist/agent/channels/notion-acting-identity.js.map +1 -0
- package/dist/agent/channels/notion-ancestry-trust.d.ts +208 -0
- package/dist/agent/channels/notion-ancestry-trust.d.ts.map +1 -0
- package/dist/agent/channels/notion-ancestry-trust.js +331 -0
- package/dist/agent/channels/notion-ancestry-trust.js.map +1 -0
- package/dist/agent/channels/notion-api.d.ts +655 -0
- package/dist/agent/channels/notion-api.d.ts.map +1 -0
- package/dist/agent/channels/notion-api.js +938 -0
- package/dist/agent/channels/notion-api.js.map +1 -0
- package/dist/agent/channels/notion-bot-user.d.ts +107 -0
- package/dist/agent/channels/notion-bot-user.d.ts.map +1 -0
- package/dist/agent/channels/notion-bot-user.js +192 -0
- package/dist/agent/channels/notion-bot-user.js.map +1 -0
- package/dist/agent/channels/notion-catch-up.d.ts +64 -0
- package/dist/agent/channels/notion-catch-up.d.ts.map +1 -0
- package/dist/agent/channels/notion-catch-up.js +289 -0
- package/dist/agent/channels/notion-catch-up.js.map +1 -0
- package/dist/agent/channels/notion-credentials.d.ts +210 -0
- package/dist/agent/channels/notion-credentials.d.ts.map +1 -0
- package/dist/agent/channels/notion-credentials.js +360 -0
- package/dist/agent/channels/notion-credentials.js.map +1 -0
- package/dist/agent/channels/notion-engagement.d.ts +219 -0
- package/dist/agent/channels/notion-engagement.d.ts.map +1 -0
- package/dist/agent/channels/notion-engagement.js +283 -0
- package/dist/agent/channels/notion-engagement.js.map +1 -0
- package/dist/agent/channels/notion-event-normalizer.d.ts +194 -0
- package/dist/agent/channels/notion-event-normalizer.d.ts.map +1 -0
- package/dist/agent/channels/notion-event-normalizer.js +542 -0
- package/dist/agent/channels/notion-event-normalizer.js.map +1 -0
- package/dist/agent/channels/notion-identity.d.ts +119 -0
- package/dist/agent/channels/notion-identity.d.ts.map +1 -0
- package/dist/agent/channels/notion-identity.js +205 -0
- package/dist/agent/channels/notion-identity.js.map +1 -0
- package/dist/agent/channels/notion-ingress-drain.d.ts +66 -0
- package/dist/agent/channels/notion-ingress-drain.d.ts.map +1 -0
- package/dist/agent/channels/notion-ingress-drain.js +185 -0
- package/dist/agent/channels/notion-ingress-drain.js.map +1 -0
- package/dist/agent/channels/notion-ingress.d.ts +64 -0
- package/dist/agent/channels/notion-ingress.d.ts.map +1 -0
- package/dist/agent/channels/notion-ingress.js +130 -0
- package/dist/agent/channels/notion-ingress.js.map +1 -0
- package/dist/agent/channels/notion-message-ops.d.ts +3 -0
- package/dist/agent/channels/notion-message-ops.d.ts.map +1 -0
- package/dist/agent/channels/notion-message-ops.js +126 -0
- package/dist/agent/channels/notion-message-ops.js.map +1 -0
- package/dist/agent/channels/notion-origin-codec.d.ts +7 -0
- package/dist/agent/channels/notion-origin-codec.d.ts.map +1 -0
- package/dist/agent/channels/notion-origin-codec.js +32 -0
- package/dist/agent/channels/notion-origin-codec.js.map +1 -0
- package/dist/agent/channels/notion-post-arm.d.ts +3 -0
- package/dist/agent/channels/notion-post-arm.d.ts.map +1 -0
- package/dist/agent/channels/notion-post-arm.js +185 -0
- package/dist/agent/channels/notion-post-arm.js.map +1 -0
- package/dist/agent/channels/notion-question-return.d.ts +77 -0
- package/dist/agent/channels/notion-question-return.d.ts.map +1 -0
- package/dist/agent/channels/notion-question-return.js +183 -0
- package/dist/agent/channels/notion-question-return.js.map +1 -0
- package/dist/agent/channels/notion-read.d.ts +62 -0
- package/dist/agent/channels/notion-read.d.ts.map +1 -0
- package/dist/agent/channels/notion-read.js +257 -0
- package/dist/agent/channels/notion-read.js.map +1 -0
- package/dist/agent/channels/notion-scope.d.ts +188 -0
- package/dist/agent/channels/notion-scope.d.ts.map +1 -0
- package/dist/agent/channels/notion-scope.js +433 -0
- package/dist/agent/channels/notion-scope.js.map +1 -0
- package/dist/agent/channels/notion-stop.d.ts +48 -0
- package/dist/agent/channels/notion-stop.d.ts.map +1 -0
- package/dist/agent/channels/notion-stop.js +141 -0
- package/dist/agent/channels/notion-stop.js.map +1 -0
- package/dist/agent/channels/notion-supervisor.d.ts +462 -0
- package/dist/agent/channels/notion-supervisor.d.ts.map +1 -0
- package/dist/agent/channels/notion-supervisor.js +482 -0
- package/dist/agent/channels/notion-supervisor.js.map +1 -0
- package/dist/agent/channels/notion-webhook-timing.d.ts +35 -0
- package/dist/agent/channels/notion-webhook-timing.d.ts.map +1 -0
- package/dist/agent/channels/notion-webhook-timing.js +99 -0
- package/dist/agent/channels/notion-webhook-timing.js.map +1 -0
- package/dist/agent/channels/notion-webhook-verifier.d.ts +5 -0
- package/dist/agent/channels/notion-webhook-verifier.d.ts.map +1 -0
- package/dist/agent/channels/notion-webhook-verifier.js +53 -0
- package/dist/agent/channels/notion-webhook-verifier.js.map +1 -0
- package/dist/agent/channels/notion-webhook.d.ts +242 -0
- package/dist/agent/channels/notion-webhook.d.ts.map +1 -0
- package/dist/agent/channels/notion-webhook.js +675 -0
- package/dist/agent/channels/notion-webhook.js.map +1 -0
- package/dist/agent/channels/notion-work-search.d.ts +9 -0
- package/dist/agent/channels/notion-work-search.d.ts.map +1 -0
- package/dist/agent/channels/notion-work-search.js +69 -0
- package/dist/agent/channels/notion-work-search.js.map +1 -0
- package/dist/agent/channels/notion-work.d.ts +68 -0
- package/dist/agent/channels/notion-work.d.ts.map +1 -0
- package/dist/agent/channels/notion-work.js +126 -0
- package/dist/agent/channels/notion-work.js.map +1 -0
- package/dist/agent/channels/notion.d.ts +95 -0
- package/dist/agent/channels/notion.d.ts.map +1 -0
- package/dist/agent/channels/notion.js +1081 -0
- package/dist/agent/channels/notion.js.map +1 -0
- package/dist/agent/contributions.d.ts +32 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +37 -0
- package/dist/agent/contributions.js.map +1 -0
- package/dist/agent/docs-generated.d.ts +3 -0
- package/dist/agent/docs-generated.d.ts.map +1 -0
- package/dist/agent/docs-generated.js +13 -0
- package/dist/agent/docs-generated.js.map +1 -0
- package/dist/agent/memory/blocks.d.ts +58 -0
- package/dist/agent/memory/blocks.d.ts.map +1 -0
- package/dist/agent/memory/blocks.js +264 -0
- package/dist/agent/memory/blocks.js.map +1 -0
- package/dist/agent/memory/memory.d.ts +157 -0
- package/dist/agent/memory/memory.d.ts.map +1 -0
- package/dist/agent/memory/memory.js +471 -0
- package/dist/agent/memory/memory.js.map +1 -0
- package/dist/agent/provider-events.d.ts +3 -0
- package/dist/agent/provider-events.d.ts.map +1 -0
- package/dist/agent/provider-events.js +196 -0
- package/dist/agent/provider-events.js.map +1 -0
- package/dist/agent/register.d.ts +5 -0
- package/dist/agent/register.d.ts.map +1 -0
- package/dist/agent/register.js +128 -0
- package/dist/agent/register.js.map +1 -0
- package/dist/agent/schedules/notion-ingress.d.ts +6 -0
- package/dist/agent/schedules/notion-ingress.d.ts.map +1 -0
- package/dist/agent/schedules/notion-ingress.js +16 -0
- package/dist/agent/schedules/notion-ingress.js.map +1 -0
- package/dist/agent/surface-parity.d.ts +3 -0
- package/dist/agent/surface-parity.d.ts.map +1 -0
- package/dist/agent/surface-parity.js +99 -0
- package/dist/agent/surface-parity.js.map +1 -0
- package/dist/agent/tools/notion_search.d.ts +56 -0
- package/dist/agent/tools/notion_search.d.ts.map +1 -0
- package/dist/agent/tools/notion_search.js +100 -0
- package/dist/agent/tools/notion_search.js.map +1 -0
- package/dist/agent/tools/read_notion_comments.d.ts +57 -0
- package/dist/agent/tools/read_notion_comments.d.ts.map +1 -0
- package/dist/agent/tools/read_notion_comments.js +236 -0
- package/dist/agent/tools/read_notion_comments.js.map +1 -0
- package/dist/agent/tools/read_notion_page.d.ts +53 -0
- package/dist/agent/tools/read_notion_page.d.ts.map +1 -0
- package/dist/agent/tools/read_notion_page.js +327 -0
- package/dist/agent/tools/read_notion_page.js.map +1 -0
- package/package.json +231 -0
- package/tsconfig.build.json +22 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// THE NOTION AMBIENT ENGAGEMENT SEAM (chat-surface contract ยง2.5) โ the Notion wiring over core's
|
|
2
|
+
// pure engagement policy (`packages/core/agent/channels/engagement.ts`). It gives Notion the middle
|
|
3
|
+
// posture Slack, Discord and Teams already have: addressing the app marks the PAGE ENGAGED, a later
|
|
4
|
+
// PLAIN comment on that page wakes the agent, and `stand_down` stores the stop.
|
|
5
|
+
//
|
|
6
|
+
// THE ANCHOR is `notionConversationKey(workspaceId, pageId)` โ `<workspace>/<page>`, the same string
|
|
7
|
+
// the mapper publishes as `anchorChannelId` for the stop registry. The doc session is PAGE-scoped
|
|
8
|
+
// (`notionSupervisorToken` keys the standing session by page, aware of all the page's discussions),
|
|
9
|
+
// so the page is the conversation a person can address a stop to, and a per-discussion mark would
|
|
10
|
+
// leave the same session answering the discussion next door.
|
|
11
|
+
//
|
|
12
|
+
// ๐ด THE ADDRESS IS A NAME-DROP, NOT AN @MENTION, AND THAT IS A VENDOR FACT. Notion lets no user
|
|
13
|
+
// @mention an internal integration at all, so `classifyNotionComment` promotes a name-drop (or the
|
|
14
|
+
// structurally-unreachable structured mention) to `direct` โ that IS Notion's addressed leg, and it
|
|
15
|
+
// is therefore what enrols a page here. `notionNameDrop` owns the matching, including its
|
|
16
|
+
// word-boundary guards; this module never looks at comment text.
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
getOperationalStore,
|
|
20
|
+
getThreadEngagement,
|
|
21
|
+
markThreadEngaged,
|
|
22
|
+
resolvePartition,
|
|
23
|
+
shouldDeliverReply,
|
|
24
|
+
type EngagementRow,
|
|
25
|
+
type Partition,
|
|
26
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
27
|
+
import { buildNotionAuth, notionConversationKey } from "./notion-identity.js";
|
|
28
|
+
// TYPE ONLY โ it erases, so the "this module imports only notion-identity.ts and core" note above
|
|
29
|
+
// `markNotionEngaged`'s use in notion-supervisor.ts still holds and no cycle is added.
|
|
30
|
+
import type { NotionVia } from "./notion-scope.js";
|
|
31
|
+
|
|
32
|
+
/** The facts one Notion delivery contributes to the seam โ everything the anchor and the partition
|
|
33
|
+
* need, and nothing about the comment's text. */
|
|
34
|
+
export interface NotionSeamFacts {
|
|
35
|
+
readonly workspaceId: string | undefined;
|
|
36
|
+
readonly pageId: string | undefined;
|
|
37
|
+
/** The acting Notion user, so the partition the seam keys is built from the delivery's identity. */
|
|
38
|
+
readonly principalId?: string;
|
|
39
|
+
/** The integration this delivery verified under โ carried for binding parity with the wake. */
|
|
40
|
+
readonly credentialId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The engagement-registry anchor for one Notion delivery, or `undefined` when either id is missing.
|
|
44
|
+
*
|
|
45
|
+
* ๐ด IT IS THE ONE PREDICATE THE MAPPER AND THE SEAM SHARE. `bindNotion` publishes exactly this as
|
|
46
|
+
* `engagementKey`, so `stand_down`'s mark and this seam's read address the same row. */
|
|
47
|
+
export function notionEngagementAnchor(f: NotionSeamFacts): string | undefined {
|
|
48
|
+
return notionConversationKey(f.workspaceId, f.pageId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The partition one page's engagement row lives in โ `notion/<workspace>/<page>`, which is what
|
|
52
|
+
* `resolvePartition` gives a bound Notion session and therefore what `stand_down` writes under. It
|
|
53
|
+
* is built through the same `buildNotionAuth` the wake dispatches, so the seam can never key a row
|
|
54
|
+
* the turn would not. Null on an unresolvable identity โ the caller then keeps today's path. */
|
|
55
|
+
export function notionSeamPartition(f: NotionSeamFacts): Partition | null {
|
|
56
|
+
if (!f.workspaceId || !f.pageId) return null;
|
|
57
|
+
try {
|
|
58
|
+
const auth = buildNotionAuth({
|
|
59
|
+
workspaceId: f.workspaceId,
|
|
60
|
+
pageId: f.pageId,
|
|
61
|
+
...(f.principalId ? { principalId: f.principalId } : {}),
|
|
62
|
+
...(f.credentialId ? { credentialId: f.credentialId } : {}),
|
|
63
|
+
});
|
|
64
|
+
return resolvePartition({ channel: { kind: "notion" }, session: { auth: { current: auth } } });
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What the seam says about one comment the classifier did NOT call `direct`. Three outcomes, each
|
|
72
|
+
* naming what the CALLER owes:
|
|
73
|
+
*
|
|
74
|
+
* โข `unanchored` โ the delivery names no page (or no resolvable partition), so there is no row to
|
|
75
|
+
* address and the caller keeps today's path exactly.
|
|
76
|
+
* โข `deliver` โ a plain comment on an engaged page. The caller wakes a turn even where the page's
|
|
77
|
+
* domain has not opted into the ambient supervisor: somebody addressed the agent on THIS page.
|
|
78
|
+
* โข `hold` โ the seam does not admit it. `stopped` says WHY, and it is the one field a caller must
|
|
79
|
+
* branch on: `stopped: true` means the agent was asked to stand down on this page, so the comment
|
|
80
|
+
* must be SUPPRESSED outright โ including where the ambient supervisor is enabled and would
|
|
81
|
+
* otherwise answer. `stopped: false` means the page simply is not engaged, which is not a
|
|
82
|
+
* suppression: the caller keeps today's path (the domain opt-in then decides).
|
|
83
|
+
*/
|
|
84
|
+
export type NotionEngagementVerdict =
|
|
85
|
+
| { readonly kind: "unanchored"; readonly reason: string }
|
|
86
|
+
| { readonly kind: "deliver"; readonly anchor: string; readonly reason: string }
|
|
87
|
+
| { readonly kind: "hold"; readonly anchor: string; readonly reason: string; readonly stopped: boolean };
|
|
88
|
+
|
|
89
|
+
/** Injectable seams so the whole verdict is unit-tested without a store. */
|
|
90
|
+
export interface NotionEngagementDeps {
|
|
91
|
+
readonly getEngagement?: (p: Partition, anchor: string) => Promise<EngagementRow | null>;
|
|
92
|
+
readonly markEngaged?: (p: Partition, anchor: string, atIso: string) => Promise<void>;
|
|
93
|
+
/** The durable once-guard BOTH this module's guards take โ the engage mark
|
|
94
|
+
* ({@link markNotionEngaged}) and the wake ({@link firstNotionDispatchOfComment}). They key on
|
|
95
|
+
* different prefixes, so one injected stub serves both. Injected so a test can drive a
|
|
96
|
+
* redelivered comment without an operational store. */
|
|
97
|
+
readonly markEventSeen?: (org: string, key: string, atIso: string) => Promise<boolean>;
|
|
98
|
+
readonly now?: () => string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve the seam for one NON-ADDRESSED comment โ READ-ONLY. An addressed comment never reaches
|
|
103
|
+
* this function: it marks instead ({@link markNotionEngaged}), so exactly one caller writes the row.
|
|
104
|
+
*
|
|
105
|
+
* THE DECISION ITSELF IS CORE'S. `shouldDeliverReply` owns the disqualifier order and the reasons;
|
|
106
|
+
* this function only translates Notion facts into its input. Nothing here re-states the policy.
|
|
107
|
+
*
|
|
108
|
+
* FAIL-CLOSED on a store error: `getThreadEngagement` reads a failure as `null`, so an unreadable
|
|
109
|
+
* registry holds rather than delivers.
|
|
110
|
+
*
|
|
111
|
+
* `authorIsBot` is the caller's own loop-guard verdict. The classifier drops a self-authored or
|
|
112
|
+
* integration-authored comment before this runs, so it is `false` on every live call; it stays a
|
|
113
|
+
* parameter rather than a hard-coded literal so the guard is visible at the call site and a test can
|
|
114
|
+
* drive the other direction.
|
|
115
|
+
*/
|
|
116
|
+
export async function resolveNotionEngagement(
|
|
117
|
+
f: NotionSeamFacts,
|
|
118
|
+
authorIsBot: boolean,
|
|
119
|
+
deps: NotionEngagementDeps = {},
|
|
120
|
+
): Promise<NotionEngagementVerdict> {
|
|
121
|
+
const anchor = notionEngagementAnchor(f);
|
|
122
|
+
if (!anchor) return { kind: "unanchored", reason: "the delivery names no workspace and page โ no row to address" };
|
|
123
|
+
const partition = notionSeamPartition(f);
|
|
124
|
+
if (!partition) return { kind: "unanchored", reason: "the delivery has no resolvable partition โ no row to address" };
|
|
125
|
+
|
|
126
|
+
const read = deps.getEngagement ?? ((p, key) => getThreadEngagement(p, key));
|
|
127
|
+
const row = await read(partition, anchor);
|
|
128
|
+
const decision = shouldDeliverReply({
|
|
129
|
+
isEngaged: row !== null,
|
|
130
|
+
stopped: row?.stopped ?? false,
|
|
131
|
+
authorIsBot,
|
|
132
|
+
isAppMention: false, // an addressed comment never reaches this function
|
|
133
|
+
inThread: true, // an anchored Notion page IS the container engagement is kept per
|
|
134
|
+
});
|
|
135
|
+
if (decision.deliver) return { kind: "deliver", anchor, reason: decision.reason };
|
|
136
|
+
return { kind: "hold", anchor, reason: decision.reason, stopped: row?.stopped === true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Mark one page ENGAGED on an addressed (name-drop or structured-mention) comment. ONCE PER COMMENT.
|
|
141
|
+
*
|
|
142
|
+
* `participating: true` is the re-invitation: it promotes the row and CLEARS a previous stand-down,
|
|
143
|
+
* which is what "name the agent to bring it back" means here.
|
|
144
|
+
*
|
|
145
|
+
* ๐ด THE MARK IS DEDUPED, AND THE STOP IS WHY โ the same durable guard GitHub's and Linear's marks
|
|
146
|
+
* take. Clearing the flag is right for a person's second name-drop and WRONG for Notion redelivering
|
|
147
|
+
* the first one. THE ROUTE DOES NOT ALWAYS ACK, so the redelivery is reachable: `handleNotionDelivery`
|
|
148
|
+
* carries no top-level `catch`, and eve's channel dispatcher answers a throw with a 500 โ while this
|
|
149
|
+
* mark sits ABOVE `getOrCreateSession` and the wake, either of which can throw on a store outage. The
|
|
150
|
+
* sequence that costs is then: name-drop marks the page, the mint throws, Notion is told 500, somebody
|
|
151
|
+
* asks the agent to stand down, and Notion's retry of the original comment lifts the stop.
|
|
152
|
+
* `commentId` is the trigger comment's own id, so the guard keys on the thing that must happen once.
|
|
153
|
+
* DURABLE layer only, and it expires with the store's TTL โ a retry later than that window can still
|
|
154
|
+
* re-mark. FAIL-OPEN on a store error, the posture every dedupe in this repository takes.
|
|
155
|
+
*
|
|
156
|
+
* Best-effort throughout โ `markThreadEngaged` swallows a store failure, because engagement
|
|
157
|
+
* bookkeeping must never fail a turn. Returns the anchor it marked, or null when it marked nothing.
|
|
158
|
+
*/
|
|
159
|
+
export async function markNotionEngaged(f: NotionSeamFacts, commentId: string | undefined, deps: NotionEngagementDeps = {}): Promise<string | null> {
|
|
160
|
+
const anchor = notionEngagementAnchor(f);
|
|
161
|
+
if (!anchor) return null;
|
|
162
|
+
const partition = notionSeamPartition(f);
|
|
163
|
+
if (!partition) return null;
|
|
164
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
165
|
+
const atIso = now();
|
|
166
|
+
if (commentId !== undefined && commentId.length > 0) {
|
|
167
|
+
const seen = deps.markEventSeen ?? liveMarkEngageSeen;
|
|
168
|
+
if (!(await seen(partition.org, `notion-engage:${anchor}:${commentId}`, atIso))) return null;
|
|
169
|
+
}
|
|
170
|
+
const mark = deps.markEngaged ?? ((p, key, at) => markThreadEngaged(p, key, at, { participating: true }));
|
|
171
|
+
await mark(partition, anchor, atIso);
|
|
172
|
+
return anchor;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* ONE WAKE PER COMMENT (OPE-1906). TRUE = this comment has not woken the agent inside the window,
|
|
177
|
+
* so the caller should dispatch. FALSE = the same comment id already did, so the caller must not.
|
|
178
|
+
*
|
|
179
|
+
* ๐ด THE OTHER HALF OF THE LOOP GUARD, AND THE CLASSIFIER CANNOT BE IT. `classifyNotionComment`
|
|
180
|
+
* reads one comment's author; a REDELIVERY of a person's comment carries the same author and the
|
|
181
|
+
* same text, so it classifies identically and dispatches again โ a second model turn, a second
|
|
182
|
+
* `notion.dispatched` row, and a second reply into the discussion. Nothing else on this road is
|
|
183
|
+
* keyed on the delivery, so this is the only place that can say "again".
|
|
184
|
+
*
|
|
185
|
+
* REDELIVERY IS REACHABLE, and {@link markNotionEngaged} above already says why: the route does not
|
|
186
|
+
* always ack. `handleNotionDelivery` carries no top-level `catch`, and eve's channel dispatcher
|
|
187
|
+
* answers a throw with a 500, so Notion retries. A page shared with two subscriptions delivers the
|
|
188
|
+
* same comment twice by design (OPE-467), and a vendor retry needs no failure of ours at all.
|
|
189
|
+
*
|
|
190
|
+
* ๐ด THE MARK IS TAKEN BEFORE THE WAKE, AND THAT TRADE IS DELIBERATE. Marking afterwards would let
|
|
191
|
+
* two concurrent deliveries of one comment both run a turn, which is the cost this guard exists to
|
|
192
|
+
* stop; `markEventSeen` is atomic across instances, so marking first stops them. The price is that
|
|
193
|
+
* a delivery whose dispatch THROWS is not retried inside the window โ the same price the engage
|
|
194
|
+
* mark one step above already pays, and the reason this guard sits as LATE as it can, below every
|
|
195
|
+
* cheaper refusal, so only a delivery that was really about to wake a turn ever spends a mark.
|
|
196
|
+
*
|
|
197
|
+
* ๐ด A SUITE THAT DRIVES `handleNotionDelivery` PAST THIS POINT MUST INJECT `markEventSeen`, or
|
|
198
|
+
* pin its own store path. The default is the DURABLE operational store, and an unpinned suite in
|
|
199
|
+
* this package shares one sqlite file (scripts/suite-store-isolation-lint.ts says why) โ so a suite
|
|
200
|
+
* that replays a fixed comment id writes a mark that survives the run, and its NEXT run reads
|
|
201
|
+
* `duplicate-delivery` where it expected `dispatched`. `lint:suite-store` cannot catch that: its
|
|
202
|
+
* reach test is a source scan, and such a suite names no store entry point of its own. Five suites
|
|
203
|
+
* inject the stub today.
|
|
204
|
+
*
|
|
205
|
+
* FAIL-OPEN, three ways: a store error, an unresolvable partition and a delivery with no comment id
|
|
206
|
+
* all answer TRUE, so an outage costs a duplicate turn rather than a deaf deployment. The try/catch
|
|
207
|
+
* covers the INJECTED edge too, not just the live default (the shape webhooks/replay-dedupe.ts and
|
|
208
|
+
* webhooks/reject-audit.ts take) โ which is how a test drives the fail-open arm without breaking a
|
|
209
|
+
* real store. `commentId` is the thing that must happen once, and a Notion comment id is globally
|
|
210
|
+
* unique, so the key needs no anchor.
|
|
211
|
+
*/
|
|
212
|
+
export async function firstNotionDispatchOfComment(
|
|
213
|
+
f: NotionSeamFacts,
|
|
214
|
+
commentId: string | undefined,
|
|
215
|
+
deps: NotionEngagementDeps = {},
|
|
216
|
+
): Promise<boolean> {
|
|
217
|
+
if (commentId === undefined || commentId.length === 0) return true;
|
|
218
|
+
const partition = notionSeamPartition(f);
|
|
219
|
+
if (!partition) return true;
|
|
220
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
221
|
+
const seen = deps.markEventSeen ?? liveMarkDispatchSeen;
|
|
222
|
+
try {
|
|
223
|
+
return await seen(partition.org, `notion-dispatch:${commentId}`, now());
|
|
224
|
+
} catch {
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The two addressing values a Notion delivery can carry into a dispatch โ `drop` never reaches one. */
|
|
230
|
+
export type NotionDispatchAddressing = "direct" | "channel-watch";
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* WHAT WOKE ONE ADMITTED DELIVERY, in the wake envelope's own vocabulary (core's `WakeAddressing`).
|
|
234
|
+
* It is the value the `notion.dispatched` audit row reports, and NO PRODUCT CODE BRANCHES ON IT.
|
|
235
|
+
* Every reader is an EVIDENCE reader outside the product: the journey pack's `reportedThreadFollow`
|
|
236
|
+
* (packages/core/scripts/e2e/engaged-rows.ts) gates `J-ENGAGED-notion`'s follow-up step on it,
|
|
237
|
+
* `drive-notion-namedrop.ts` prints and asserts it, and the test tiers read it. So changing the
|
|
238
|
+
* value moves what the pack can prove; it moves no delivery decision.
|
|
239
|
+
*
|
|
240
|
+
* ๐ด IT IS DELIBERATELY NOT THE CLASSIFIER'S VALUE. The classifier answers one question โ did this
|
|
241
|
+
* comment ADDRESS the agent โ and an engaged follow-up did not, so it stays `channel-watch`. The
|
|
242
|
+
* supervisor core needs that: `channel-watch` is what makes the turn a JUDGED one, and promoting it
|
|
243
|
+
* would silence the very wake this seam exists to admit.
|
|
244
|
+
*
|
|
245
|
+
* ๐ด BUT `channel-watch` ALSO NAMES UNOWNED ROOM CHATTER on an ambient page, and the audit row is
|
|
246
|
+
* the one place those two must not read alike. A row that reported `channel-watch` for both cannot
|
|
247
|
+
* tell an ENGAGED wake from an AMBIENT one, and that distinction is the whole evidentiary worth of
|
|
248
|
+
* the ยง2.5 seam on this surface: it is what `J-ENGAGED-notion` gates its follow-up step on
|
|
249
|
+
* (`reportedThreadFollow`, packages/core/scripts/e2e/engaged-rows.ts). Until OPE-1501 the row was
|
|
250
|
+
* unreachable on an addressed-only page โ the ambient gate returned before the classifier ran โ so
|
|
251
|
+
* the collision cost nothing. The gate now admits an engaged follow-up, and the collision costs the
|
|
252
|
+
* proof, which is what OPE-1510 fixed here.
|
|
253
|
+
*
|
|
254
|
+
* ๐ด IT REPORTS THE REGISTRY, NOT THE DOOR. The seam is read for every plain comment, above and
|
|
255
|
+
* independently of the ambient gate, so on an ambient-ENABLED page a plain comment on an engaged
|
|
256
|
+
* page also reads `thread-follow` โ the ambient flag would have admitted it either way. The value
|
|
257
|
+
* therefore separates an engaged wake from an ambient one only where the domain has ambient OFF,
|
|
258
|
+
* which is the addressed-only posture the journeys drive.
|
|
259
|
+
*
|
|
260
|
+
* `engagedFollowUp` is the webhook core's own seam verdict, and the `channel-watch` guard below is
|
|
261
|
+
* belt-and-braces: an ADDRESSED comment never asks the seam, so it can never carry the flag.
|
|
262
|
+
*/
|
|
263
|
+
export function notionWakeAddressing(
|
|
264
|
+
addressing: NotionDispatchAddressing,
|
|
265
|
+
engagedFollowUp: boolean | undefined,
|
|
266
|
+
): "direct" | "channel-watch" | "thread-follow" {
|
|
267
|
+
return addressing === "channel-watch" && engagedFollowUp === true ? "thread-follow" : addressing;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The `notion.dispatched` row's two seam fields: what woke the delivery, and how the operator's
|
|
271
|
+
* config holds the page. */
|
|
272
|
+
export interface NotionWakeAuditDetail {
|
|
273
|
+
readonly addressing: "direct" | "channel-watch" | "thread-follow";
|
|
274
|
+
readonly via: NotionVia;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* THE SEAM'S WHOLE CONTRIBUTION TO THE AUDIT ROW, as one pure mapping.
|
|
279
|
+
*
|
|
280
|
+
* ๐ด THE TWO FIELDS ANSWER DIFFERENT QUESTIONS, AND A ROW NEEDS BOTH. `addressing` says WHAT WOKE
|
|
281
|
+
* the delivery. `via` says HOW THE OPERATOR DECLARED THE PAGE, which is the domain origin the
|
|
282
|
+
* addressed door gated on (`notionPageDomain`, channels/notion-scope.ts). A `container` claim
|
|
283
|
+
* reaches pages NOBODY ENUMERATED โ the whole workspace, or a same-level page glob, which are the
|
|
284
|
+
* two shapes a container entry may take (memory/domain.ts); a `roster` entry is a page an
|
|
285
|
+
* operator wrote down. Both reach the same door, and a row carrying only `addressing` reports them
|
|
286
|
+
* identically โ which leaves the wider admission looking exactly like the narrower one in the
|
|
287
|
+
* audit trail. The row says WHICH KIND of claim, never how wide that claim is: read the domain's
|
|
288
|
+
* own container entry for the breadth.
|
|
289
|
+
*
|
|
290
|
+
* `via` is the webhook core's own read, carried on the dispatch. An absent field reads `none`: the
|
|
291
|
+
* conservative word, so a caller that omits it can never claim a declaration it did not make.
|
|
292
|
+
*/
|
|
293
|
+
export function notionWakeAuditDetail(
|
|
294
|
+
addressing: NotionDispatchAddressing,
|
|
295
|
+
engagedFollowUp: boolean | undefined,
|
|
296
|
+
via: NotionVia | undefined,
|
|
297
|
+
): NotionWakeAuditDetail {
|
|
298
|
+
return { addressing: notionWakeAddressing(addressing, engagedFollowUp), via: via ?? "none" };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** How long this module remembers one comment id โ the window both durable guards below key on.
|
|
302
|
+
* It bounds the vendor retry both of them answer, so ONE number serves both: a redelivery this
|
|
303
|
+
* module treats as "again" for the mark must be "again" for the wake too, or the two guards would
|
|
304
|
+
* disagree about the same delivery. */
|
|
305
|
+
const NOTION_DEDUPE_TTL_MS = 10 * 60_000;
|
|
306
|
+
|
|
307
|
+
/** The durable half of the mark dedupe. FAIL-OPEN: an unreachable store answers "not seen", so a
|
|
308
|
+
* store outage costs a redundant mark rather than a lost engagement. */
|
|
309
|
+
let warnedEngageDedupe = false;
|
|
310
|
+
async function liveMarkEngageSeen(org: string, key: string, atIso: string): Promise<boolean> {
|
|
311
|
+
try {
|
|
312
|
+
return await getOperationalStore().markEventSeen(org, key, atIso, NOTION_DEDUPE_TTL_MS);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
if (!warnedEngageDedupe) {
|
|
315
|
+
warnedEngageDedupe = true;
|
|
316
|
+
console.warn(`[open-everywhere:notion-engagement] engage dedupe unavailable โ a redelivered name-drop may re-mark: ${String(err)}`);
|
|
317
|
+
}
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The durable half of the WAKE once-guard ({@link firstNotionDispatchOfComment}). Same window as
|
|
323
|
+
* the mark above, and the same FAIL-OPEN posture: an unreachable store costs a duplicate turn,
|
|
324
|
+
* never a silenced comment. */
|
|
325
|
+
let warnedDispatchDedupe = false;
|
|
326
|
+
async function liveMarkDispatchSeen(org: string, key: string, atIso: string): Promise<boolean> {
|
|
327
|
+
try {
|
|
328
|
+
return await getOperationalStore().markEventSeen(org, key, atIso, NOTION_DEDUPE_TTL_MS);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
if (!warnedDispatchDedupe) {
|
|
331
|
+
warnedDispatchDedupe = true;
|
|
332
|
+
console.warn(`[open-everywhere:notion-engagement] dispatch dedupe unavailable โ a redelivered comment may wake a second turn: ${String(err)}`);
|
|
333
|
+
}
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// โโ THE EXACT-JOIN IDS ON THE `notion.dispatched` ROW (OPE-1907) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
339
|
+
|
|
340
|
+
/** The three ids that let one Notion delivery be joined to the turn it woke. IDS ONLY. */
|
|
341
|
+
export interface NotionDispatchJoinIds {
|
|
342
|
+
/** The WEBHOOK ENVELOPE's own `id` โ unique to this delivery, so two deliveries of the SAME
|
|
343
|
+
* comment are tellable apart. Absent when the body carried no id. */
|
|
344
|
+
readonly deliveryId?: string;
|
|
345
|
+
/** The TRIGGER COMMENT's id โ what the delivery named, and what a replay leg configures. */
|
|
346
|
+
readonly commentId?: string;
|
|
347
|
+
/** The single-window wake this delivery minted โ the join key `wake.ingress` and
|
|
348
|
+
* `wake.turn_start` carry, and therefore the bridge to the session that answered. Absent on a
|
|
349
|
+
* per-doc (non-unified) delivery, which mints no window wake. */
|
|
350
|
+
readonly wakeId?: string;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Build the join ids for the row. Pure, and EVERY FIELD IS AN ID โ never a comment body, an author
|
|
355
|
+
* name or a page title.
|
|
356
|
+
*
|
|
357
|
+
* ๐ด WHY THE ROW NEEDS THEM. Before this, `notion.dispatched` recorded the discussion and the
|
|
358
|
+
* addressing but nothing that identified the DELIVERY, so a leg that forged one replay could only
|
|
359
|
+
* scan a time window for a verdict row and hope no other wake fell inside it. S-TAINT-DRYRUN Arm B
|
|
360
|
+
* did exactly that and the scan could not tell its own replay's verdict from an ambient one
|
|
361
|
+
* (OPE-1907). With the delivery id on the row, one forge joins one wake joins one session.
|
|
362
|
+
*
|
|
363
|
+
* An absent value is OMITTED rather than written as `undefined`: an audit row that carries a key
|
|
364
|
+
* with no value reads, to anyone querying it, like a value that was looked for and found empty.
|
|
365
|
+
*/
|
|
366
|
+
export function notionDispatchJoinDetail(ids: NotionDispatchJoinIds): Record<string, string> {
|
|
367
|
+
const out: Record<string, string> = {};
|
|
368
|
+
if (ids.deliveryId) out.deliveryId = ids.deliveryId;
|
|
369
|
+
if (ids.commentId) out.commentId = ids.commentId;
|
|
370
|
+
if (ids.wakeId) out.wakeId = ids.wakeId;
|
|
371
|
+
return out;
|
|
372
|
+
}
|