@oxygen-agent/cli 1.1011.98 → 1.1011.322
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/README.md +1 -1
- package/dist/admin-primary-providers-render.js +25 -5
- package/dist/column-run-notices.d.ts +16 -0
- package/dist/column-run-notices.js +57 -0
- package/dist/command-manifest.js +10 -1
- package/dist/connectors-commands.d.ts +11 -0
- package/dist/connectors-commands.js +63 -0
- package/dist/help.d.ts +27 -1
- package/dist/help.js +112 -29
- package/dist/http-client.js +8 -0
- package/dist/index.js +362 -130
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +20 -69
- package/node_modules/@oxygen/shared/dist/byok-connect.d.ts +6 -4
- package/node_modules/@oxygen/shared/dist/byok-connect.js +11 -4
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +131 -1
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +325 -30
- package/node_modules/@oxygen/shared/dist/collab.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/collab.js +12 -0
- package/node_modules/@oxygen/shared/dist/column-decision.js +7 -3
- package/node_modules/@oxygen/shared/dist/column-output-fields.js +45 -3
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +20 -13
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +29 -13
- package/node_modules/@oxygen/shared/dist/contracts/connectors.d.ts +23 -0
- package/node_modules/@oxygen/shared/dist/contracts/connectors.js +34 -0
- package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +0 -6
- package/node_modules/@oxygen/shared/dist/contracts/operations.js +8 -1
- package/node_modules/@oxygen/shared/dist/contracts/sender-limits.js +1 -1
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +2 -2
- package/node_modules/@oxygen/shared/dist/credit-guidance.js +12 -1
- package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +37 -0
- package/node_modules/@oxygen/shared/dist/enrichment-intents.js +79 -44
- package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/feature-gates.js +6 -0
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +57 -8
- package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +74 -13
- package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +7 -0
- package/node_modules/@oxygen/shared/dist/linkedin-url.js +9 -0
- package/node_modules/@oxygen/shared/dist/log.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/log.js +15 -0
- package/node_modules/@oxygen/shared/dist/mcp-event-contract.d.ts +597 -0
- package/node_modules/@oxygen/shared/dist/mcp-event-contract.js +713 -0
- package/node_modules/@oxygen/shared/dist/mcp-events-enabled.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/mcp-events-enabled.js +14 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.js +6 -2
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +7 -3
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +22 -1
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +3 -7
- package/node_modules/@oxygen/shared/dist/plan-limits.js +2 -14
- package/node_modules/@oxygen/shared/dist/select-options.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/select-options.js +6 -1
- package/node_modules/@oxygen/shared/dist/sending-schedule.d.ts +39 -0
- package/node_modules/@oxygen/shared/dist/sending-schedule.js +41 -0
- package/node_modules/@oxygen/shared/dist/sequence-lifecycle.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/sequence-lifecycle.js +20 -0
- package/node_modules/@oxygen/shared/dist/sequence-sender-variables.d.ts +35 -0
- package/node_modules/@oxygen/shared/dist/sequence-sender-variables.js +63 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +13 -7
- package/node_modules/@oxygen/shared/dist/sequences.js +26 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +5 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-series.js +1 -15
- package/node_modules/@oxygen/shared/dist/standard-webhooks.d.ts +51 -0
- package/node_modules/@oxygen/shared/dist/standard-webhooks.js +124 -0
- package/node_modules/@oxygen/shared/dist/tags.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/tags.js +13 -1
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +11 -7
- package/node_modules/@oxygen/shared/dist/ugc.js +14 -6
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/zoned-time.d.ts +39 -0
- package/node_modules/@oxygen/shared/dist/zoned-time.js +81 -0
- package/package.json +1 -1
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { arr, enumOf, int, nullable, num, obj, str, unwrapNullable, validateContract, } from "./contracts/schema.js";
|
|
3
|
+
import { CRM_ACTIVITY_CREATED_EVENT } from "./crm-activity-events.js";
|
|
4
|
+
import { CRM_EMAIL_REPLY_CLASSIFIED_EVENT, CRM_WORKFLOW_EVENT_SOURCE, } from "./crm-reply-events.js";
|
|
5
|
+
import { NOTETAKER_MEETING_COMPLETED_EVENT, NOTETAKER_WORKFLOW_EVENT_SOURCE } from "./notetaker-events.js";
|
|
6
|
+
import { slugifyOption } from "./select-options.js";
|
|
7
|
+
import { SEQUENCE_ENROLLMENT_TERMINAL_EVENT, SEQUENCE_TERMINAL_EVENT_SOURCE, SEQUENCE_TERMINAL_STATUSES, } from "./sequence-terminal-events.js";
|
|
8
|
+
import { resolveAppBaseUrl } from "./signed-token.js";
|
|
9
|
+
import { isRecord, readCoercedString, readTrimmedString } from "./type-guards.js";
|
|
10
|
+
// The v1 MCP Events contract (ADR 0030, ledger OXP-11.5.17): the six events
|
|
11
|
+
// `events/list` advertises, what a subscription may filter on, what a delivery
|
|
12
|
+
// carries, and the deterministic ids that make `events/subscribe` a refresh and
|
|
13
|
+
// a redelivery recognisable. Server-only (node:crypto). The control-DB tables,
|
|
14
|
+
// the `events/*` handlers and the delivery worker are OXP-11.5.18 to .20; the
|
|
15
|
+
// run and approval producers are OXP-11.5.21. Signing and the SSRF-safe POST
|
|
16
|
+
// are `@oxygen/shared/standard-webhooks` and `@oxygen/integrations/outbound-webhook`.
|
|
17
|
+
//
|
|
18
|
+
// A payload leaves OXYGEN for a third party and lands in a model's context, so
|
|
19
|
+
// it holds ids, enums, counts, timestamps and one OXYGEN deep link, never
|
|
20
|
+
// message text, subjects, names, email addresses, phone numbers or LinkedIn
|
|
21
|
+
// URLs (ADR 0030 §7). Every projection builds its payload field by field and
|
|
22
|
+
// checks it against the event's closed `payloadSchema` before returning it.
|
|
23
|
+
/** OpenAI MCP Events: one event per request, and the whole request body at most 256 KiB. */
|
|
24
|
+
export const MCP_EVENT_MAX_BODY_BYTES = 262_144;
|
|
25
|
+
export const MCP_EVENT_NAMES = [
|
|
26
|
+
"reply.classified",
|
|
27
|
+
"enrollment.ended",
|
|
28
|
+
"meeting.completed",
|
|
29
|
+
"record.stage_changed",
|
|
30
|
+
"run.settled",
|
|
31
|
+
"approval.requested",
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
34
|
+
* How OXYGEN classified a reply. The first six are the Inbox labels the
|
|
35
|
+
* classifier sets, and `bounced` is set when the bounce arrives. A label the
|
|
36
|
+
* workspace defined is `other`, because its key is workspace-authored text; a
|
|
37
|
+
* one-click email unsubscribe, which has no reply, is `unsubscribed`.
|
|
38
|
+
*/
|
|
39
|
+
export const MCP_REPLY_CLASSIFICATIONS = [
|
|
40
|
+
"interested",
|
|
41
|
+
"neutral",
|
|
42
|
+
"not_now",
|
|
43
|
+
"not_interested",
|
|
44
|
+
"out_of_office",
|
|
45
|
+
"wrong_person",
|
|
46
|
+
"bounced",
|
|
47
|
+
"unsubscribed",
|
|
48
|
+
"other",
|
|
49
|
+
];
|
|
50
|
+
const REPLY_LABELS_DELIVERED_AS_IS = new Set([
|
|
51
|
+
"interested",
|
|
52
|
+
"neutral",
|
|
53
|
+
"not_now",
|
|
54
|
+
"not_interested",
|
|
55
|
+
"out_of_office",
|
|
56
|
+
"wrong_person",
|
|
57
|
+
"bounced",
|
|
58
|
+
]);
|
|
59
|
+
export const MCP_REPLY_CHANNELS = ["email", "linkedin", "whatsapp"];
|
|
60
|
+
/** How an enrollment ended: the Sequencer's own terminal statuses. */
|
|
61
|
+
export const MCP_ENROLLMENT_OUTCOMES = SEQUENCE_TERMINAL_STATUSES;
|
|
62
|
+
/** The run ledgers `run_outcome_analytics` reads (`SettledRunKind` in @oxygen/tenant-db). */
|
|
63
|
+
export const MCP_RUN_KINDS = ["workflow", "table_action", "table_ingestion", "agent", "copilot_turn"];
|
|
64
|
+
/**
|
|
65
|
+
* Every terminal status those ledgers write, with one spelling of cancel:
|
|
66
|
+
* Workflow and Table runs write `canceled`, Agent runs and Copilot turns `cancelled`.
|
|
67
|
+
*/
|
|
68
|
+
export const MCP_RUN_STATUSES = [
|
|
69
|
+
"completed",
|
|
70
|
+
"completed_with_errors",
|
|
71
|
+
"failed",
|
|
72
|
+
"canceled",
|
|
73
|
+
"budget_exhausted",
|
|
74
|
+
"effect_unknown",
|
|
75
|
+
];
|
|
76
|
+
export const MCP_APPROVAL_KINDS = ["workflow", "agent"];
|
|
77
|
+
// An OXYGEN id on the wire: a uuid, a slug or a field key. Never whitespace, so
|
|
78
|
+
// free text cannot pass as an id.
|
|
79
|
+
const ID_PATTERN = "^[A-Za-z0-9][A-Za-z0-9_.:-]*$";
|
|
80
|
+
const ID_MAX_LENGTH = 128;
|
|
81
|
+
// Bounded so the largest valid meeting payload stays far below the body cap.
|
|
82
|
+
const MEETING_RECORD_IDS_MAX = 1_000;
|
|
83
|
+
function id(description) {
|
|
84
|
+
return { ...str({ description, minLength: 1, maxLength: ID_MAX_LENGTH }), pattern: ID_PATTERN };
|
|
85
|
+
}
|
|
86
|
+
function timestamp(description) {
|
|
87
|
+
return { ...str({ description }), format: "date-time" };
|
|
88
|
+
}
|
|
89
|
+
function deepLink(description) {
|
|
90
|
+
return { ...str({ description }), format: "uri" };
|
|
91
|
+
}
|
|
92
|
+
/** A closed payload whose every field is always present, null where a fact can be missing. */
|
|
93
|
+
function payloadObject(properties) {
|
|
94
|
+
return obj(properties, { required: Object.keys(properties) });
|
|
95
|
+
}
|
|
96
|
+
const REPLY_CLASSIFIED_INPUT = obj({
|
|
97
|
+
sequence_id: id("Only replies from leads enrolled in this Sequence."),
|
|
98
|
+
classification: enumOf(MCP_REPLY_CLASSIFICATIONS, { description: "Only replies with this classification." }),
|
|
99
|
+
});
|
|
100
|
+
const REPLY_CLASSIFIED_PAYLOAD = payloadObject({
|
|
101
|
+
classification: enumOf(MCP_REPLY_CLASSIFICATIONS, {
|
|
102
|
+
description: "How OXYGEN classified the reply. `bounced` is a bounce, `unsubscribed` a one-click email unsubscribe, `other` a label your workspace defined.",
|
|
103
|
+
}),
|
|
104
|
+
channel: enumOf(MCP_REPLY_CHANNELS, { description: "Where the reply arrived." }),
|
|
105
|
+
conversation_id: nullable(id("The Inbox conversation; null for a one-click unsubscribe, which has none.")),
|
|
106
|
+
record_id: nullable(id("The lead's CRM person record; null when OXYGEN has not matched one.")),
|
|
107
|
+
sequence_id: nullable(id("The Sequence the lead was enrolled in; null outside Sequences.")),
|
|
108
|
+
enrollment_id: nullable(id("The lead's enrollment in that Sequence; null outside Sequences.")),
|
|
109
|
+
occurred_at: timestamp("When the reply arrived, in ISO 8601."),
|
|
110
|
+
url: deepLink("The conversation in OXYGEN, or the lead's record or Sequence when there is no conversation."),
|
|
111
|
+
});
|
|
112
|
+
const ENROLLMENT_ENDED_INPUT = obj({
|
|
113
|
+
sequence_id: id("Only enrollments in this Sequence."),
|
|
114
|
+
outcome: enumOf(MCP_ENROLLMENT_OUTCOMES, { description: "Only enrollments that ended this way." }),
|
|
115
|
+
});
|
|
116
|
+
const ENROLLMENT_ENDED_PAYLOAD = payloadObject({
|
|
117
|
+
outcome: enumOf(MCP_ENROLLMENT_OUTCOMES, {
|
|
118
|
+
description: "How the enrollment ended: `replied`; `completed`, every step sent without a reply; `stopped` or `excluded`, withdrawn by a reply-stop, an unsubscribe, a suppression rule or an operator; `failed`, sending failed.",
|
|
119
|
+
}),
|
|
120
|
+
steps_sent: nullable(int({ minimum: 0, description: "Sequence steps OXYGEN sent to the lead; null when they could not be counted." })),
|
|
121
|
+
sequence_id: id("The Sequence."),
|
|
122
|
+
enrollment_id: id("The lead's enrollment."),
|
|
123
|
+
occurred_at: timestamp("When the enrollment ended, in ISO 8601."),
|
|
124
|
+
url: deepLink("The Sequence in OXYGEN."),
|
|
125
|
+
});
|
|
126
|
+
const MEETING_COMPLETED_INPUT = obj({
|
|
127
|
+
record_id: id("Only meetings linked to this CRM record: a person, company or deal."),
|
|
128
|
+
});
|
|
129
|
+
const MEETING_COMPLETED_PAYLOAD = payloadObject({
|
|
130
|
+
session_id: id("The Meeting Notetaker session."),
|
|
131
|
+
duration_seconds: nullable(int({ minimum: 0, description: "Recorded length in whole seconds; null when its start or end was not captured." })),
|
|
132
|
+
record_ids: {
|
|
133
|
+
...arr(id("A CRM record id."), {
|
|
134
|
+
maxItems: MEETING_RECORD_IDS_MAX,
|
|
135
|
+
description: "CRM records the meeting is linked to: people, companies and deals.",
|
|
136
|
+
}),
|
|
137
|
+
uniqueItems: true,
|
|
138
|
+
},
|
|
139
|
+
activity_id: nullable(id("The meeting on the CRM timeline; null when none was logged.")),
|
|
140
|
+
occurred_at: timestamp("When OXYGEN finished processing the meeting, in ISO 8601."),
|
|
141
|
+
url: deepLink("The meeting in OXYGEN."),
|
|
142
|
+
});
|
|
143
|
+
const RECORD_STAGE_CHANGED_INPUT = obj({
|
|
144
|
+
object: id("Only records of this CRM object, such as `people`, `companies` or `deals`."),
|
|
145
|
+
pipeline_id: id("Only changes to this stage field: `lead_stage` on people, `lifecycle_stage` or `deal_stage` on companies, `pipeline_stage` on deals, or a custom status field's key."),
|
|
146
|
+
});
|
|
147
|
+
const RECORD_STAGE_CHANGED_PAYLOAD = payloadObject({
|
|
148
|
+
object: id("The record's CRM object."),
|
|
149
|
+
record_id: id("The record."),
|
|
150
|
+
pipeline_id: id("The stage field that changed."),
|
|
151
|
+
from_stage_id: nullable(id("The stage option the record left; null when it had none.")),
|
|
152
|
+
to_stage_id: nullable(id("The stage option the record moved to; null when the stage was cleared.")),
|
|
153
|
+
activity_id: id("The stage change on the record's CRM timeline."),
|
|
154
|
+
occurred_at: timestamp("When the stage changed, in ISO 8601."),
|
|
155
|
+
url: deepLink("The record in OXYGEN."),
|
|
156
|
+
});
|
|
157
|
+
const RUN_SETTLED_INPUT = obj({
|
|
158
|
+
kind: enumOf(MCP_RUN_KINDS, { description: "Only runs of this kind." }),
|
|
159
|
+
status: enumOf(MCP_RUN_STATUSES, { description: "Only runs that ended with this status." }),
|
|
160
|
+
});
|
|
161
|
+
const RUN_SETTLED_PAYLOAD = payloadObject({
|
|
162
|
+
kind: enumOf(MCP_RUN_KINDS, {
|
|
163
|
+
description: "What ran: a Workflow run, a Table action run, a Table import (`table_ingestion`), an Agent run or a Workspace Copilot turn.",
|
|
164
|
+
}),
|
|
165
|
+
status: enumOf(MCP_RUN_STATUSES, {
|
|
166
|
+
description: "How it ended. An Agent run can also end `budget_exhausted` or `effect_unknown`.",
|
|
167
|
+
}),
|
|
168
|
+
run_id: id("The run."),
|
|
169
|
+
credits_used: nullable(num({ minimum: 0, description: "Credits the run spent; null when its ledger records no figure." })),
|
|
170
|
+
occurred_at: timestamp("When the run settled, in ISO 8601."),
|
|
171
|
+
url: deepLink("The run in OXYGEN."),
|
|
172
|
+
});
|
|
173
|
+
const APPROVAL_REQUESTED_INPUT = obj({
|
|
174
|
+
kind: enumOf(MCP_APPROVAL_KINDS, { description: "Only approvals requested by this kind of run." }),
|
|
175
|
+
});
|
|
176
|
+
const APPROVAL_REQUESTED_PAYLOAD = payloadObject({
|
|
177
|
+
kind: enumOf(MCP_APPROVAL_KINDS, { description: "What is waiting: a Workflow run or an Agent run." }),
|
|
178
|
+
approval_id: id("The pending approval."),
|
|
179
|
+
run_id: id("The waiting run."),
|
|
180
|
+
occurred_at: timestamp("When the approval was requested, in ISO 8601."),
|
|
181
|
+
url: deepLink("The waiting run in OXYGEN, where it is approved or rejected."),
|
|
182
|
+
});
|
|
183
|
+
function eventDefinition(name, description, inputSchema, payloadSchema) {
|
|
184
|
+
return { name, description, delivery: ["webhook"], inputSchema, payloadSchema };
|
|
185
|
+
}
|
|
186
|
+
/** What `events/list` answers, in this order. Plain JSON: nothing internal rides along. Read-only. */
|
|
187
|
+
export const MCP_EVENT_DEFINITIONS = [
|
|
188
|
+
eventDefinition("reply.classified", "A lead replied to your outreach by email, LinkedIn or WhatsApp and OXYGEN classified the reply in its Inbox. A one-click email unsubscribe, which has no reply, arrives as `unsubscribed`. Ids only: message text and contact details stay in OXYGEN.", REPLY_CLASSIFIED_INPUT, REPLY_CLASSIFIED_PAYLOAD),
|
|
189
|
+
eventDefinition("enrollment.ended", "A lead's enrollment in an OXYGEN Sequence ended: the lead replied, every step was sent, the enrollment was stopped or excluded, or sending failed.", ENROLLMENT_ENDED_INPUT, ENROLLMENT_ENDED_PAYLOAD),
|
|
190
|
+
eventDefinition("meeting.completed", "OXYGEN's Meeting Notetaker finished processing a recorded meeting; its transcript and meeting note are ready in OXYGEN.", MEETING_COMPLETED_INPUT, MEETING_COMPLETED_PAYLOAD),
|
|
191
|
+
eventDefinition("record.stage_changed", "A CRM record's stage changed in OXYGEN, such as a person's lead stage, a company's account stage or a deal's pipeline stage.", RECORD_STAGE_CHANGED_INPUT, RECORD_STAGE_CHANGED_PAYLOAD),
|
|
192
|
+
eventDefinition("run.settled", "A run in OXYGEN reached its final status: a Workflow run, a Table action run or import, an Agent run, or a Workspace Copilot turn.", RUN_SETTLED_INPUT, RUN_SETTLED_PAYLOAD),
|
|
193
|
+
eventDefinition("approval.requested", "A Workflow run or an Agent run in OXYGEN paused and is waiting for a person to approve its next step.", APPROVAL_REQUESTED_INPUT, APPROVAL_REQUESTED_PAYLOAD),
|
|
194
|
+
];
|
|
195
|
+
const DEFINITIONS_BY_NAME = new Map(MCP_EVENT_DEFINITIONS.map((entry) => [entry.name, entry]));
|
|
196
|
+
const equals = (field) => ({ field, match: "equals" });
|
|
197
|
+
/** How each subscription argument narrows delivery. Applied by OXYGEN before anything is sent. */
|
|
198
|
+
export const MCP_EVENT_FILTERS = {
|
|
199
|
+
"reply.classified": { sequence_id: equals("sequence_id"), classification: equals("classification") },
|
|
200
|
+
"enrollment.ended": { sequence_id: equals("sequence_id"), outcome: equals("outcome") },
|
|
201
|
+
"meeting.completed": { record_id: { field: "record_ids", match: "includes" } },
|
|
202
|
+
"record.stage_changed": { object: equals("object"), pipeline_id: equals("pipeline_id") },
|
|
203
|
+
"run.settled": { kind: equals("kind"), status: equals("status") },
|
|
204
|
+
"approval.requested": { kind: equals("kind") },
|
|
205
|
+
};
|
|
206
|
+
// The JSON-RPC error an `events/*` handler answers with: -32011 is the draft
|
|
207
|
+
// extension's unknown event or subscription, -32602 invalid params. null marks a
|
|
208
|
+
// fault in OXYGEN's own reader or producer, never the caller's.
|
|
209
|
+
const JSON_RPC_CODES = {
|
|
210
|
+
unknown_event: -32011,
|
|
211
|
+
invalid_arguments: -32602,
|
|
212
|
+
invalid_callback_url: -32602,
|
|
213
|
+
invalid_principal: null,
|
|
214
|
+
invalid_source_event: null,
|
|
215
|
+
invalid_payload: null,
|
|
216
|
+
payload_too_large: null,
|
|
217
|
+
};
|
|
218
|
+
export class McpEventContractError extends Error {
|
|
219
|
+
code;
|
|
220
|
+
/** -32011 or -32602 when the caller's request is at fault; null when OXYGEN's own data is. */
|
|
221
|
+
jsonRpcCode;
|
|
222
|
+
issues;
|
|
223
|
+
constructor(code, message, issues = []) {
|
|
224
|
+
super(message);
|
|
225
|
+
this.name = "McpEventContractError";
|
|
226
|
+
this.code = code;
|
|
227
|
+
this.jsonRpcCode = JSON_RPC_CODES[code];
|
|
228
|
+
this.issues = issues;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
export function isMcpEventName(value) {
|
|
232
|
+
return typeof value === "string" && DEFINITIONS_BY_NAME.has(value);
|
|
233
|
+
}
|
|
234
|
+
/** The event's definition, or `unknown_event` (-32011). */
|
|
235
|
+
export function requireMcpEventDefinition(name) {
|
|
236
|
+
const found = typeof name === "string" ? DEFINITIONS_BY_NAME.get(name) : undefined;
|
|
237
|
+
if (!found) {
|
|
238
|
+
const named = typeof name === "string" ? `an event named ${JSON.stringify(name.slice(0, 100))}` : "that event";
|
|
239
|
+
throw new McpEventContractError("unknown_event", `OXYGEN has no ${named}.`, [
|
|
240
|
+
{ path: "name", message: `name must be one of: ${MCP_EVENT_NAMES.join(", ")}.` },
|
|
241
|
+
]);
|
|
242
|
+
}
|
|
243
|
+
return found;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Check `events/subscribe` and `events/unsubscribe` arguments against the
|
|
247
|
+
* event's closed `inputSchema`. Absent arguments and a `null` value both mean
|
|
248
|
+
* "no filter", as a model sends null for "any". An unknown key, a wrong type or
|
|
249
|
+
* a value outside an enum is `invalid_arguments` (-32602).
|
|
250
|
+
*/
|
|
251
|
+
export function validateMcpEventArguments(name, args) {
|
|
252
|
+
const definition = requireMcpEventDefinition(name);
|
|
253
|
+
if (args !== undefined && args !== null && !isRecord(args)) {
|
|
254
|
+
throw new McpEventContractError("invalid_arguments", `${definition.name} arguments must be an object.`, [
|
|
255
|
+
{ path: "arguments", message: "arguments must be an object." },
|
|
256
|
+
]);
|
|
257
|
+
}
|
|
258
|
+
const given = withoutAbsentValues(args ?? {});
|
|
259
|
+
const issues = schemaIssues(definition.inputSchema, given);
|
|
260
|
+
if (issues.length > 0) {
|
|
261
|
+
throw new McpEventContractError("invalid_arguments", `Invalid ${definition.name} arguments: ${issues.map((issue) => issue.message).join(" ")}`, issues);
|
|
262
|
+
}
|
|
263
|
+
return Object.fromEntries(Object.keys(given).sort().map((key) => [key, given[key]]));
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* The payload typed as the event's `data`, or `invalid_payload` when it breaks
|
|
267
|
+
* the closed `payloadSchema`: an unknown field, a missing one, a wrong type, a
|
|
268
|
+
* value outside an enum, or text where an id, timestamp or link belongs. The
|
|
269
|
+
* 256 KiB body cap is enforced where the body is serialized.
|
|
270
|
+
*/
|
|
271
|
+
export function assertMcpEventPayload(name, payload) {
|
|
272
|
+
const definition = requireMcpEventDefinition(name);
|
|
273
|
+
const issues = isRecord(payload)
|
|
274
|
+
? schemaIssues(definition.payloadSchema, payload)
|
|
275
|
+
: [{ path: "", message: "The payload must be an object." }];
|
|
276
|
+
if (issues.length > 0) {
|
|
277
|
+
throw new McpEventContractError("invalid_payload", `${definition.name} payload breaks its schema: ${issues.map((issue) => issue.message).join(" ")}`, issues);
|
|
278
|
+
}
|
|
279
|
+
return payload;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Whether a subscription wants this delivery: every filter it set must match,
|
|
283
|
+
* and no filters match every event. Fails closed, as the Workflow trigger gate
|
|
284
|
+
* does (`matchesWorkflowEventFilters`): an unknown event, an argument this event
|
|
285
|
+
* does not filter on, or a non-string value never widens a subscription.
|
|
286
|
+
*/
|
|
287
|
+
export function mcpEventMatchesSubscription(input) {
|
|
288
|
+
const { name, arguments: args, payload } = input;
|
|
289
|
+
if (!isMcpEventName(name) || !isRecord(payload))
|
|
290
|
+
return false;
|
|
291
|
+
if (args !== undefined && args !== null && !isRecord(args))
|
|
292
|
+
return false;
|
|
293
|
+
const filters = MCP_EVENT_FILTERS[name];
|
|
294
|
+
for (const [argument, expected] of Object.entries(args ?? {})) {
|
|
295
|
+
if (expected === undefined || expected === null)
|
|
296
|
+
continue;
|
|
297
|
+
const binding = Object.hasOwn(filters, argument) ? filters[argument] : undefined;
|
|
298
|
+
if (!binding || typeof expected !== "string")
|
|
299
|
+
return false;
|
|
300
|
+
const actual = payload[binding.field];
|
|
301
|
+
const matched = binding.match === "includes"
|
|
302
|
+
? Array.isArray(actual) && actual.includes(expected)
|
|
303
|
+
: actual === expected;
|
|
304
|
+
if (!matched)
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
const SUBSCRIPTION_ID_SCHEME = "oxygen.mcp_event_subscription.v1";
|
|
310
|
+
const DELIVERY_ID_SCHEME = "oxygen.mcp_event_delivery.v1";
|
|
311
|
+
/**
|
|
312
|
+
* The deterministic subscription id: sha256 over the principal, the callback
|
|
313
|
+
* URL, the event name and the canonical arguments, so a repeated
|
|
314
|
+
* `events/subscribe` refreshes the subscription instead of duplicating it and
|
|
315
|
+
* `events/unsubscribe` finds it from the same inputs. Pass validated arguments.
|
|
316
|
+
*/
|
|
317
|
+
export function mcpEventSubscriptionId(input) {
|
|
318
|
+
const definition = requireMcpEventDefinition(input.name);
|
|
319
|
+
const { organizationId, userId, oauthClientId } = input.principal;
|
|
320
|
+
if (![organizationId, userId, oauthClientId].every((part) => typeof part === "string" && part.trim() !== "")) {
|
|
321
|
+
throw new McpEventContractError("invalid_principal", "A subscription belongs to one organization, user and OAuth client; all three are required.");
|
|
322
|
+
}
|
|
323
|
+
return `sub_${sha256Hex(canonicalMcpEventJson({
|
|
324
|
+
scheme: SUBSCRIPTION_ID_SCHEME,
|
|
325
|
+
principal: { organization_id: organizationId, user_id: userId, oauth_client_id: oauthClientId },
|
|
326
|
+
callback_url: normalizedCallbackUrl(input.callbackUrl),
|
|
327
|
+
name: definition.name,
|
|
328
|
+
arguments: withoutAbsentValues(input.arguments ?? {}),
|
|
329
|
+
}))}`;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* The `eventId` (and `webhook-id`) of one event for one subscription, derived
|
|
333
|
+
* from the subscription id and the source event's durable key, e.g. its
|
|
334
|
+
* `workflow_events` row id. A redelivered job, a reader rescan or a crash
|
|
335
|
+
* between the POST and the journal therefore reuse the id the receiver
|
|
336
|
+
* deduplicates on (ADR 0030 §4).
|
|
337
|
+
*/
|
|
338
|
+
export function mcpEventDeliveryId(input) {
|
|
339
|
+
if (!input.subscriptionId.trim() || !input.sourceEventKey.trim()) {
|
|
340
|
+
throw new McpEventContractError("invalid_source_event", "An event id needs the subscription id and the source event's durable key.");
|
|
341
|
+
}
|
|
342
|
+
return `evt_${sha256Hex(canonicalMcpEventJson({
|
|
343
|
+
scheme: DELIVERY_ID_SCHEME,
|
|
344
|
+
subscription_id: input.subscriptionId,
|
|
345
|
+
source_event_key: input.sourceEventKey,
|
|
346
|
+
}))}`;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* The request body of one event delivery: `{eventId, name, timestamp, data,
|
|
350
|
+
* cursor: null}`, with `timestamp` the event's occurrence time (v1 has no
|
|
351
|
+
* replay, so `cursor` is always null). `data` must pass its schema, and a body
|
|
352
|
+
* over the cap is `payload_too_large` rather than a 413 from the receiver.
|
|
353
|
+
*/
|
|
354
|
+
export function serializeMcpEventDelivery(input) {
|
|
355
|
+
const data = assertMcpEventPayload(input.name, input.data);
|
|
356
|
+
const eventId = mcpEventDeliveryId(input);
|
|
357
|
+
const body = JSON.stringify({ eventId, name: input.name, timestamp: data.occurred_at, data, cursor: null });
|
|
358
|
+
const bytes = Buffer.byteLength(body, "utf8");
|
|
359
|
+
const limit = input.maxBodyBytes ?? MCP_EVENT_MAX_BODY_BYTES;
|
|
360
|
+
if (bytes > limit) {
|
|
361
|
+
throw new McpEventContractError("payload_too_large", `The ${input.name} event body is ${bytes} bytes; one delivery may carry at most ${limit}.`);
|
|
362
|
+
}
|
|
363
|
+
return { eventId, body, bytes };
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* JSON with every object's keys sorted, so equal values serialize identically
|
|
367
|
+
* however their keys were ordered: the canonical form of stored subscription
|
|
368
|
+
* arguments and of every id hashed here. `undefined` members are dropped as
|
|
369
|
+
* JSON.stringify drops them; anything JSON cannot carry is refused.
|
|
370
|
+
*/
|
|
371
|
+
export function canonicalMcpEventJson(value) {
|
|
372
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
373
|
+
return JSON.stringify(value);
|
|
374
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
375
|
+
return JSON.stringify(value);
|
|
376
|
+
if (Array.isArray(value)) {
|
|
377
|
+
return `[${value.map((item) => canonicalMcpEventJson(item === undefined ? null : item)).join(",")}]`;
|
|
378
|
+
}
|
|
379
|
+
if (isRecord(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value))) {
|
|
380
|
+
const members = Object.keys(value)
|
|
381
|
+
.filter((key) => value[key] !== undefined)
|
|
382
|
+
.sort()
|
|
383
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalMcpEventJson(value[key])}`);
|
|
384
|
+
return `{${members.join(",")}}`;
|
|
385
|
+
}
|
|
386
|
+
throw new McpEventContractError("invalid_arguments", "Arguments must be plain JSON: strings, finite numbers, booleans, null, lists and objects.");
|
|
387
|
+
}
|
|
388
|
+
const STAGE_CHANGED_ACTIVITY = "stage_changed";
|
|
389
|
+
// `/api/cli/workflows/events/emit`, behind both the CLI and `oxygen_workflows_events_emit`.
|
|
390
|
+
const MANUAL_EMIT_SURFACE = "cli";
|
|
391
|
+
/**
|
|
392
|
+
* The v1 event a workspace event row is delivered as, or null. A manual emit
|
|
393
|
+
* is never delivered (ADR 0030 §8): it may name any source and event, so a
|
|
394
|
+
* subscriber would receive whatever a workspace member typed as if OXYGEN had
|
|
395
|
+
* observed it.
|
|
396
|
+
*/
|
|
397
|
+
export function mcpEventNameForWorkspaceEvent(row) {
|
|
398
|
+
if (isRecord(row.metadata) && row.metadata.surface === MANUAL_EMIT_SURFACE)
|
|
399
|
+
return null;
|
|
400
|
+
if (row.source === CRM_WORKFLOW_EVENT_SOURCE && row.event === CRM_EMAIL_REPLY_CLASSIFIED_EVENT) {
|
|
401
|
+
return "reply.classified";
|
|
402
|
+
}
|
|
403
|
+
if (row.source === SEQUENCE_TERMINAL_EVENT_SOURCE && row.event === SEQUENCE_ENROLLMENT_TERMINAL_EVENT) {
|
|
404
|
+
return "enrollment.ended";
|
|
405
|
+
}
|
|
406
|
+
if (row.source === NOTETAKER_WORKFLOW_EVENT_SOURCE && row.event === NOTETAKER_MEETING_COMPLETED_EVENT) {
|
|
407
|
+
return "meeting.completed";
|
|
408
|
+
}
|
|
409
|
+
if (row.source === CRM_WORKFLOW_EVENT_SOURCE
|
|
410
|
+
&& row.event === CRM_ACTIVITY_CREATED_EVENT
|
|
411
|
+
&& isRecord(row.payload)
|
|
412
|
+
&& row.payload.activity_type === STAGE_CHANGED_ACTIVITY) {
|
|
413
|
+
return "record.stage_changed";
|
|
414
|
+
}
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* `crm / email_reply_classified` as `reply.classified`. The ledger names the
|
|
419
|
+
* lead only by contact details, so the reader passes the CRM person it matched.
|
|
420
|
+
*/
|
|
421
|
+
export function projectReplyClassifiedEvent(row, context = {}) {
|
|
422
|
+
const payload = catalogPayload("reply.classified", row);
|
|
423
|
+
const channel = readOneOf(payload.channel, MCP_REPLY_CHANNELS);
|
|
424
|
+
if (!channel)
|
|
425
|
+
throw sourceError("reply.classified", "names no reply channel");
|
|
426
|
+
const conversationId = readTrimmedString(payload.conversation_id);
|
|
427
|
+
const sequenceId = readTrimmedString(payload.sequence_id);
|
|
428
|
+
const record = context.record ?? null;
|
|
429
|
+
const path = conversationId
|
|
430
|
+
? `/unibox?conversation=${encodeURIComponent(conversationId)}`
|
|
431
|
+
: record
|
|
432
|
+
? crmRecordPath(record.object, record.id)
|
|
433
|
+
: sequenceId
|
|
434
|
+
? sequencePath(sequenceId)
|
|
435
|
+
: "/unibox";
|
|
436
|
+
return assertMcpEventPayload("reply.classified", {
|
|
437
|
+
classification: replyClassification(payload),
|
|
438
|
+
channel,
|
|
439
|
+
conversation_id: conversationId,
|
|
440
|
+
record_id: record?.id ?? null,
|
|
441
|
+
sequence_id: sequenceId,
|
|
442
|
+
enrollment_id: readTrimmedString(payload.enrollment_id),
|
|
443
|
+
occurred_at: occurredAt("reply.classified", payload.occurred_at, row.receivedAt),
|
|
444
|
+
url: oxygenUrl(path, context),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* `sequencer / enrollment_terminal` as `enrollment.ended`. The outbox row does
|
|
449
|
+
* not count sends, so the reader passes the enrollment's sent steps.
|
|
450
|
+
*/
|
|
451
|
+
export function projectEnrollmentEndedEvent(row, context) {
|
|
452
|
+
const payload = catalogPayload("enrollment.ended", row);
|
|
453
|
+
const outcome = readOneOf(payload.status, MCP_ENROLLMENT_OUTCOMES);
|
|
454
|
+
const sequenceId = readTrimmedString(payload.sequence_id);
|
|
455
|
+
const enrollmentId = readTrimmedString(payload.enrollment_id);
|
|
456
|
+
if (!outcome || !sequenceId || !enrollmentId) {
|
|
457
|
+
throw sourceError("enrollment.ended", "lacks a terminal status, Sequence or enrollment");
|
|
458
|
+
}
|
|
459
|
+
return assertMcpEventPayload("enrollment.ended", {
|
|
460
|
+
outcome,
|
|
461
|
+
steps_sent: context.stepsSent,
|
|
462
|
+
sequence_id: sequenceId,
|
|
463
|
+
enrollment_id: enrollmentId,
|
|
464
|
+
occurred_at: occurredAt("enrollment.ended", payload.occurred_at, row.receivedAt),
|
|
465
|
+
url: oxygenUrl(sequencePath(sequenceId), context),
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
/** `oxygen.notetaker / meeting.completed` as `meeting.completed`. */
|
|
469
|
+
export function projectMeetingCompletedEvent(row, context = {}) {
|
|
470
|
+
const payload = catalogPayload("meeting.completed", row);
|
|
471
|
+
const session = isRecord(payload.session) ? payload.session : {};
|
|
472
|
+
const sessionId = readTrimmedString(session.id);
|
|
473
|
+
if (!sessionId)
|
|
474
|
+
throw sourceError("meeting.completed", "names no Notetaker session");
|
|
475
|
+
const crm = isRecord(payload.crm) ? payload.crm : {};
|
|
476
|
+
const links = Array.isArray(crm.links) ? crm.links : [];
|
|
477
|
+
const recordIds = links.flatMap((link) => {
|
|
478
|
+
const recordId = isRecord(link) ? readTrimmedString(link.row_id) : null;
|
|
479
|
+
return recordId ? [recordId] : [];
|
|
480
|
+
});
|
|
481
|
+
return assertMcpEventPayload("meeting.completed", {
|
|
482
|
+
session_id: sessionId,
|
|
483
|
+
duration_seconds: durationSeconds(session.started_at, session.ended_at),
|
|
484
|
+
record_ids: [...new Set(recordIds)],
|
|
485
|
+
activity_id: readTrimmedString(crm.activity_id),
|
|
486
|
+
occurred_at: occurredAt("meeting.completed", session.completed_at, session.ended_at, row.receivedAt),
|
|
487
|
+
url: oxygenUrl(`/crm/calls/${encodeURIComponent(sessionId)}`, context),
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* `crm / activity_created` of kind `stage_changed` as `record.stage_changed`.
|
|
492
|
+
* The activity stores the stage values; when the reader has not resolved their
|
|
493
|
+
* option ids (`stageIds`), each id is derived from its value as the option
|
|
494
|
+
* model derives an id the option did not declare (`slugifyOption`). The stock
|
|
495
|
+
* stages' ids equal their values, and a stage's name, workspace text, never
|
|
496
|
+
* leaves OXYGEN.
|
|
497
|
+
*/
|
|
498
|
+
export function projectRecordStageChangedEvent(row, context = {}) {
|
|
499
|
+
const payload = catalogPayload("record.stage_changed", row);
|
|
500
|
+
const activityId = readTrimmedString(payload.activity_id);
|
|
501
|
+
const metadata = isRecord(payload.metadata) ? payload.metadata : {};
|
|
502
|
+
const pipelineId = readTrimmedString(metadata.attribute);
|
|
503
|
+
const record = changedRecord(payload.links);
|
|
504
|
+
if (!activityId || !pipelineId || !record) {
|
|
505
|
+
throw sourceError("record.stage_changed", "lacks its activity id, stage field or linked record");
|
|
506
|
+
}
|
|
507
|
+
return assertMcpEventPayload("record.stage_changed", {
|
|
508
|
+
object: record.object,
|
|
509
|
+
record_id: record.id,
|
|
510
|
+
pipeline_id: pipelineId,
|
|
511
|
+
from_stage_id: context.stageIds ? context.stageIds.from : stageOptionId(metadata.from),
|
|
512
|
+
to_stage_id: context.stageIds ? context.stageIds.to : stageOptionId(metadata.to),
|
|
513
|
+
activity_id: activityId,
|
|
514
|
+
occurred_at: occurredAt("record.stage_changed", payload.occurred_at, row.receivedAt),
|
|
515
|
+
url: oxygenUrl(crmRecordPath(record.object, record.id), context),
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
/** A settled run as `run.settled`. Its producer is OXP-11.5.21. */
|
|
519
|
+
export function projectRunSettledEvent(source, context = {}) {
|
|
520
|
+
const status = source.status === "cancelled" ? "canceled" : source.status;
|
|
521
|
+
if (!readOneOf(source.kind, MCP_RUN_KINDS) || !readOneOf(status, MCP_RUN_STATUSES)) {
|
|
522
|
+
throw sourceError("run.settled", `is a ${String(source.kind)} run with status ${String(source.status)}, not a settled run`);
|
|
523
|
+
}
|
|
524
|
+
return assertMcpEventPayload("run.settled", {
|
|
525
|
+
kind: source.kind,
|
|
526
|
+
status,
|
|
527
|
+
run_id: source.runId,
|
|
528
|
+
credits_used: source.creditsUsed,
|
|
529
|
+
occurred_at: occurredAt("run.settled", source.settledAt),
|
|
530
|
+
url: oxygenUrl(runPath(source), context),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
/** A pending approval as `approval.requested`. Its producer is OXP-11.5.21. */
|
|
534
|
+
export function projectApprovalRequestedEvent(source, context = {}) {
|
|
535
|
+
if (!readOneOf(source.kind, MCP_APPROVAL_KINDS)) {
|
|
536
|
+
throw sourceError("approval.requested", `comes from a ${String(source.kind)}, not a Workflow or Agent run`);
|
|
537
|
+
}
|
|
538
|
+
const path = source.kind === "workflow"
|
|
539
|
+
? workflowRunPath(source.workflowId, source.runId)
|
|
540
|
+
: agentRunPath(source.agentSlug, source.runId);
|
|
541
|
+
return assertMcpEventPayload("approval.requested", {
|
|
542
|
+
kind: source.kind,
|
|
543
|
+
approval_id: source.approvalId,
|
|
544
|
+
run_id: source.runId,
|
|
545
|
+
occurred_at: occurredAt("approval.requested", source.requestedAt),
|
|
546
|
+
url: oxygenUrl(path, context),
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
// Deep links: the org-scoped top-level routes the CLI and MCP link to, mirrored
|
|
550
|
+
// from apps/web/src/lib/oxygen-urls.ts, which a layer-0 package cannot import.
|
|
551
|
+
// The host is always the deployment's public app URL (`resolveAppBaseUrl`).
|
|
552
|
+
function oxygenUrl(path, options) {
|
|
553
|
+
return `${(options.appBaseUrl ?? resolveAppBaseUrl()).replace(/\/+$/, "")}${path}`;
|
|
554
|
+
}
|
|
555
|
+
function crmRecordPath(object, recordId) {
|
|
556
|
+
return `/crm/${encodeURIComponent(object)}/${encodeURIComponent(recordId)}`;
|
|
557
|
+
}
|
|
558
|
+
function sequencePath(sequenceId) {
|
|
559
|
+
return `/sequencer/sequences/${encodeURIComponent(sequenceId)}`;
|
|
560
|
+
}
|
|
561
|
+
function workflowRunPath(workflowId, runId) {
|
|
562
|
+
return `/workflows/${encodeURIComponent(workflowId)}/runs/${encodeURIComponent(runId)}`;
|
|
563
|
+
}
|
|
564
|
+
function agentRunPath(agentSlug, runId) {
|
|
565
|
+
return `/agents/${encodeURIComponent(agentSlug)}/runs/${encodeURIComponent(runId)}`;
|
|
566
|
+
}
|
|
567
|
+
function runPath(source) {
|
|
568
|
+
switch (source.kind) {
|
|
569
|
+
case "workflow":
|
|
570
|
+
return workflowRunPath(source.workflowId, source.runId);
|
|
571
|
+
case "table_action":
|
|
572
|
+
return `/tables/${encodeURIComponent(source.tableId)}/runs/${encodeURIComponent(source.runId)}`;
|
|
573
|
+
case "table_ingestion":
|
|
574
|
+
return `/tables/${encodeURIComponent(source.tableId)}/ingestions/${encodeURIComponent(source.runId)}`;
|
|
575
|
+
case "agent":
|
|
576
|
+
return agentRunPath(source.agentSlug, source.runId);
|
|
577
|
+
case "copilot_turn":
|
|
578
|
+
return `/copilot/${encodeURIComponent(source.copilotSessionId)}`;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// Every catalogue projection first proves the row is that event, so a reader
|
|
582
|
+
// that skipped the router still cannot project a manual emit.
|
|
583
|
+
function catalogPayload(name, row) {
|
|
584
|
+
if (mcpEventNameForWorkspaceEvent(row) !== name) {
|
|
585
|
+
const manual = isRecord(row.metadata) && row.metadata.surface === MANUAL_EMIT_SURFACE;
|
|
586
|
+
throw sourceError(name, manual ? "was emitted by hand" : `is not a ${name} source event`);
|
|
587
|
+
}
|
|
588
|
+
if (!isRecord(row.payload))
|
|
589
|
+
throw sourceError(name, "carries no payload object");
|
|
590
|
+
return row.payload;
|
|
591
|
+
}
|
|
592
|
+
function sourceError(name, problem) {
|
|
593
|
+
return new McpEventContractError("invalid_source_event", `Cannot deliver ${name}: the source event ${problem}.`);
|
|
594
|
+
}
|
|
595
|
+
function replyClassification(payload) {
|
|
596
|
+
const label = readTrimmedString(payload.status);
|
|
597
|
+
if (label)
|
|
598
|
+
return REPLY_LABELS_DELIVERED_AS_IS.has(label) ? label : "other";
|
|
599
|
+
// The one-click unsubscribe route records an opt-out with no reply to classify.
|
|
600
|
+
if (payload.opt_out === true)
|
|
601
|
+
return "unsubscribed";
|
|
602
|
+
throw sourceError("reply.classified", "carries neither a classification nor an opt-out");
|
|
603
|
+
}
|
|
604
|
+
// The stage writer links the changed record as the activity's actor.
|
|
605
|
+
function changedRecord(value) {
|
|
606
|
+
const links = Array.isArray(value) ? value.filter(isRecord) : [];
|
|
607
|
+
const link = links.find((entry) => entry.role === "actor") ?? links[0];
|
|
608
|
+
const object = link ? readTrimmedString(link.object_slug) : null;
|
|
609
|
+
const recordId = link ? readTrimmedString(link.row_id) : null;
|
|
610
|
+
return object && recordId ? { object, id: recordId } : null;
|
|
611
|
+
}
|
|
612
|
+
function stageOptionId(value) {
|
|
613
|
+
const stored = readCoercedString(value);
|
|
614
|
+
return stored ? slugifyOption(stored) : null;
|
|
615
|
+
}
|
|
616
|
+
function readOneOf(value, values) {
|
|
617
|
+
return typeof value === "string" && values.includes(value) ? value : null;
|
|
618
|
+
}
|
|
619
|
+
function toIsoTimestamp(value) {
|
|
620
|
+
if (!(value instanceof Date) && !(typeof value === "string" && value.trim()))
|
|
621
|
+
return null;
|
|
622
|
+
const date = new Date(value);
|
|
623
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
624
|
+
}
|
|
625
|
+
function occurredAt(name, ...candidates) {
|
|
626
|
+
for (const candidate of candidates) {
|
|
627
|
+
const iso = toIsoTimestamp(candidate);
|
|
628
|
+
if (iso)
|
|
629
|
+
return iso;
|
|
630
|
+
}
|
|
631
|
+
throw sourceError(name, "has no occurrence time");
|
|
632
|
+
}
|
|
633
|
+
function durationSeconds(startedAt, endedAt) {
|
|
634
|
+
const start = toIsoTimestamp(startedAt);
|
|
635
|
+
const end = toIsoTimestamp(endedAt);
|
|
636
|
+
if (!start || !end)
|
|
637
|
+
return null;
|
|
638
|
+
const seconds = Math.round((Date.parse(end) - Date.parse(start)) / 1000);
|
|
639
|
+
return seconds >= 0 ? seconds : null;
|
|
640
|
+
}
|
|
641
|
+
function withoutAbsentValues(value) {
|
|
642
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined && entry !== null));
|
|
643
|
+
}
|
|
644
|
+
function sha256Hex(content) {
|
|
645
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
646
|
+
}
|
|
647
|
+
// The contract DSL checks structure: closed keys, required fields, types, enums,
|
|
648
|
+
// lengths and bounds. These schemas also carry `pattern`, `format` and
|
|
649
|
+
// `uniqueItems`, which it does not read, so they are checked here.
|
|
650
|
+
function schemaIssues(schema, value) {
|
|
651
|
+
const result = validateContract(schema, value, { unknown: "reject", overMax: "reject" });
|
|
652
|
+
if (!result.ok)
|
|
653
|
+
return result.issues;
|
|
654
|
+
const issues = [];
|
|
655
|
+
for (const [key, property] of Object.entries(schema.properties)) {
|
|
656
|
+
const entry = value[key];
|
|
657
|
+
if (entry !== undefined && entry !== null)
|
|
658
|
+
annotationIssues(unwrapNullable(property).property, entry, key, issues);
|
|
659
|
+
}
|
|
660
|
+
return issues;
|
|
661
|
+
}
|
|
662
|
+
const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
663
|
+
const PATTERNS = new Map();
|
|
664
|
+
function compiledPattern(source) {
|
|
665
|
+
let pattern = PATTERNS.get(source);
|
|
666
|
+
if (!pattern) {
|
|
667
|
+
pattern = new RegExp(source);
|
|
668
|
+
PATTERNS.set(source, pattern);
|
|
669
|
+
}
|
|
670
|
+
return pattern;
|
|
671
|
+
}
|
|
672
|
+
function annotationIssues(property, value, path, issues) {
|
|
673
|
+
if (!("type" in property))
|
|
674
|
+
return;
|
|
675
|
+
const annotated = property;
|
|
676
|
+
if (property.type === "array" && Array.isArray(value)) {
|
|
677
|
+
if (annotated.uniqueItems && new Set(value.map((item) => JSON.stringify(item))).size !== value.length) {
|
|
678
|
+
issues.push({ path, message: `${path} must not repeat an entry.` });
|
|
679
|
+
}
|
|
680
|
+
value.forEach((item, index) => annotationIssues(property.items, item, `${path}[${index}]`, issues));
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (property.type !== "string" || typeof value !== "string")
|
|
684
|
+
return;
|
|
685
|
+
if (annotated.pattern && !compiledPattern(annotated.pattern).test(value)) {
|
|
686
|
+
issues.push({ path, message: `${path} must be an id: letters, digits, _ . : or -, starting with a letter or digit.` });
|
|
687
|
+
}
|
|
688
|
+
if (annotated.format === "date-time" && !(ISO_DATE_TIME.test(value) && Number.isFinite(Date.parse(value)))) {
|
|
689
|
+
issues.push({ path, message: `${path} must be an ISO 8601 date-time with a time zone.` });
|
|
690
|
+
}
|
|
691
|
+
if (annotated.format === "uri" && !isHttpUrl(value)) {
|
|
692
|
+
issues.push({ path, message: `${path} must be an absolute http(s) URL.` });
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
function isHttpUrl(value) {
|
|
696
|
+
try {
|
|
697
|
+
const url = new URL(value);
|
|
698
|
+
return url.protocol === "https:" || url.protocol === "http:";
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
function normalizedCallbackUrl(value) {
|
|
705
|
+
try {
|
|
706
|
+
return new URL(value).href;
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
throw new McpEventContractError("invalid_callback_url", "The callback URL is not an absolute URL.", [
|
|
710
|
+
{ path: "delivery.url", message: "delivery.url must be an absolute URL." },
|
|
711
|
+
]);
|
|
712
|
+
}
|
|
713
|
+
}
|