@extrovert.dev/mcp 0.1.0-pre.3
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 +21 -0
- package/README.md +313 -0
- package/dist/bin.d.ts +13 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +89 -0
- package/dist/bin.js.map +1 -0
- package/dist/client.d.ts +956 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1354 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +51 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +53 -0
- package/dist/config.js.map +1 -0
- package/dist/contract.d.ts +62 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +78 -0
- package/dist/contract.js.map +1 -0
- package/dist/extract.d.ts +25 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +131 -0
- package/dist/extract.js.map +1 -0
- package/dist/fixtures.d.ts +676 -0
- package/dist/fixtures.d.ts.map +1 -0
- package/dist/fixtures.js +2685 -0
- package/dist/fixtures.js.map +1 -0
- package/dist/http.d.ts +18 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +124 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +36 -0
- package/dist/server.js.map +1 -0
- package/dist/stdio.d.ts +8 -0
- package/dist/stdio.d.ts.map +1 -0
- package/dist/stdio.js +22 -0
- package/dist/stdio.js.map +1 -0
- package/dist/tools.d.ts +27 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +2752 -0
- package/dist/tools.js.map +1 -0
- package/dist/types.d.ts +1037 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +83 -0
- package/dist/types.js.map +1 -0
- package/package.json +72 -0
package/dist/fixtures.js
ADDED
|
@@ -0,0 +1,2685 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline fixture store.
|
|
3
|
+
*
|
|
4
|
+
* Every method here maps 1:1 onto a REST call in `client.ts`. Live MCP sessions
|
|
5
|
+
* use the Go REST API by default; this store is used for tests and offline demos
|
|
6
|
+
* when `EXTROVERT_MOCK=1`.
|
|
7
|
+
*
|
|
8
|
+
* The store is intentionally stateful within a process: creating an inbox,
|
|
9
|
+
* sending mail, and waiting for a reply all mutate the same in-memory data, so
|
|
10
|
+
* the example agent flow (redeem -> create_inbox -> send -> wait_for_email)
|
|
11
|
+
* produces coherent results offline.
|
|
12
|
+
*/
|
|
13
|
+
import { extractSignals } from "./extract.js";
|
|
14
|
+
let seq = 1000;
|
|
15
|
+
function nextId(prefix) {
|
|
16
|
+
seq += 1;
|
|
17
|
+
return `${prefix}_${seq.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The FIXED org/project the mock agent key is bound to. There is no mutable
|
|
21
|
+
* project selector — these are the offline mirror of the values the live key
|
|
22
|
+
* resolves from its stored binding (surfaced by whoami / enroll, stamped onto
|
|
23
|
+
* project-layer rules).
|
|
24
|
+
*/
|
|
25
|
+
const MOCK_ORG_ID = "org_mock";
|
|
26
|
+
// Matches the SDK fixture + docs convention ("prj_…"); a shared cross-tool test
|
|
27
|
+
// can hardcode the same expected mock project id for both MCP and SDK output.
|
|
28
|
+
const MOCK_PROJECT_ID = "prj_mock";
|
|
29
|
+
/** Effective per-inbox recipient cap returned by the offline API fixture. */
|
|
30
|
+
const DEFAULT_DAILY_SEND_LIMIT = 75;
|
|
31
|
+
/**
|
|
32
|
+
* The recipient the mock seeds an active org-scope suppression for, so the
|
|
33
|
+
* suppression reads (check/list/revoke) and the `recipient_suppressed`
|
|
34
|
+
* send-rejection path have deterministic data offline.
|
|
35
|
+
*/
|
|
36
|
+
export const SEEDED_SUPPRESSED_RECIPIENT = "unsubscribed@example.com";
|
|
37
|
+
/**
|
|
38
|
+
* The recipient whose delivery ALWAYS fails at the mock's outbound-provider boundary, so the
|
|
39
|
+
* `approved -> failed` edge and its terminal `send_failed` nudge are drivable
|
|
40
|
+
* offline. Without it the mock could only ever demonstrate the happy path, and a
|
|
41
|
+
* drain loop that never sees `send_failed` is a drain loop nobody proved
|
|
42
|
+
* terminates on failure — which is exactly how the missing terminal nudges
|
|
43
|
+
* survived unnoticed.
|
|
44
|
+
*/
|
|
45
|
+
export const SEEDED_SEND_FAILURE_RECIPIENT = "bounce@example.com";
|
|
46
|
+
/** The scrubbed error the mock reports for {@link SEEDED_SEND_FAILURE_RECIPIENT}. */
|
|
47
|
+
const MOCK_SEND_FAILURE_ERROR = "delivery rejected by the recipient's mail server (550 mailbox unavailable)";
|
|
48
|
+
/**
|
|
49
|
+
* The mock mirror of `reviewloop.AllowedAgentActions` — the verbs that ARE legal
|
|
50
|
+
* from a given state. A `wrong_state` / `terminal` 409 carries these so the agent
|
|
51
|
+
* is told what to do instead of being left to guess; from a terminal state the
|
|
52
|
+
* reads are the only honest answer.
|
|
53
|
+
*/
|
|
54
|
+
function allowedAgentActions(state) {
|
|
55
|
+
const actions = [];
|
|
56
|
+
// submit_revision targets needs_review WITH a revision bump — including the
|
|
57
|
+
// needs_review self-edge, which is legal precisely because a redraft bumps the
|
|
58
|
+
// revision and rewrites the draft.
|
|
59
|
+
if (["needs_review", "in_review", "chatting", "rejected", "stale", "stalled"].includes(state)) {
|
|
60
|
+
actions.push("submit_revision");
|
|
61
|
+
}
|
|
62
|
+
// post_review_chat from an AGENT actor: in_review/chatting always, and
|
|
63
|
+
// needs_review because an agent question does not open the draft.
|
|
64
|
+
if (["needs_review", "in_review", "chatting"].includes(state))
|
|
65
|
+
actions.push("post_review_chat");
|
|
66
|
+
if (["needs_review", "in_review", "chatting", "stale"].includes(state))
|
|
67
|
+
actions.push("restamp_review");
|
|
68
|
+
if (["needs_review", "in_review", "chatting", "stale", "stalled", "rejected", "failed"].includes(state)) {
|
|
69
|
+
actions.push("cancel_review");
|
|
70
|
+
}
|
|
71
|
+
return [...actions, "get_review", "list_review_events"];
|
|
72
|
+
}
|
|
73
|
+
/** Terminal states: nothing will ever move these rows (409 `terminal`). */
|
|
74
|
+
const TERMINAL_REVIEW_STATES = ["sent", "auto_sent", "cancelled"];
|
|
75
|
+
/**
|
|
76
|
+
* `closed` — the DEFINITIVE "am I done?" answer. `failed` is included even though
|
|
77
|
+
* it is not formally terminal: the console cannot re-approve it, so an agent told
|
|
78
|
+
* `closed:false` would wait forever on a row nobody is going to move.
|
|
79
|
+
*/
|
|
80
|
+
function reviewClosed(state) {
|
|
81
|
+
return TERMINAL_REVIEW_STATES.includes(state) || state === "failed";
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Enforce the project_id assertion contract (mock): a request `project_id` is an
|
|
85
|
+
* ASSERTION, never a selector. The mock binds every key to {@link MOCK_PROJECT_ID},
|
|
86
|
+
* so a non-matching assertion is a 403 — mirroring the SDK MockBackend and the
|
|
87
|
+
* real server (assertProjectMatch). Offline parity prevents the bug from only
|
|
88
|
+
* surfacing in production.
|
|
89
|
+
*/
|
|
90
|
+
function assertProjectMatch(projectId) {
|
|
91
|
+
if (projectId !== undefined && projectId !== MOCK_PROJECT_ID) {
|
|
92
|
+
throw new ForbiddenError(`project_id "${projectId}" does not match the key's bound project.`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Apply a metadata patch with merge-null-clear semantics (mirrors the server):
|
|
97
|
+
* a top-level `null` clears ALL metadata; otherwise the patch merges into the
|
|
98
|
+
* current object and a key whose value is `null` deletes that key.
|
|
99
|
+
*/
|
|
100
|
+
function applyMetadataPatch(current, patch) {
|
|
101
|
+
if (patch === undefined)
|
|
102
|
+
return current;
|
|
103
|
+
if (patch === null)
|
|
104
|
+
return {};
|
|
105
|
+
const next = { ...current };
|
|
106
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
107
|
+
if (value === null)
|
|
108
|
+
delete next[key];
|
|
109
|
+
else
|
|
110
|
+
next[key] = value;
|
|
111
|
+
}
|
|
112
|
+
return next;
|
|
113
|
+
}
|
|
114
|
+
function shortLabel() {
|
|
115
|
+
return Math.random().toString(36).slice(2, 5);
|
|
116
|
+
}
|
|
117
|
+
/** ruleSnapshotJSON renders a rule's restorable fields for an undo before/after column. */
|
|
118
|
+
function ruleSnapshotJSON(r) {
|
|
119
|
+
return JSON.stringify({
|
|
120
|
+
id: r.id,
|
|
121
|
+
lineage_id: r.lineage_id,
|
|
122
|
+
rev: r.rev,
|
|
123
|
+
scope: r.scope,
|
|
124
|
+
category_id: r.category_id,
|
|
125
|
+
rule_text: r.rule_text,
|
|
126
|
+
kind: r.kind,
|
|
127
|
+
priority: r.priority,
|
|
128
|
+
status: r.status,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/** Join the source MIME alternatives without turning a missing part into "null". */
|
|
132
|
+
function sourceMessageBody(message) {
|
|
133
|
+
return [message.text, message.html]
|
|
134
|
+
.filter((part) => typeof part === "string" && part.length > 0)
|
|
135
|
+
.join("\n");
|
|
136
|
+
}
|
|
137
|
+
export class FixtureStore {
|
|
138
|
+
inboxes = new Map();
|
|
139
|
+
messages = new Map(); // inbox_id -> messages
|
|
140
|
+
/** message id -> stored attachments (mock mirror of the real MIME parts). */
|
|
141
|
+
attachments = new Map();
|
|
142
|
+
/** webhook id -> registered webhook (mock mirror of extrovert_webhooks). */
|
|
143
|
+
webhooks = new Map();
|
|
144
|
+
/** entry id -> contact-list entry (mock mirror of extrovert_contact_lists). */
|
|
145
|
+
contactLists = new Map();
|
|
146
|
+
/** domain name -> onboarded domain (mock mirror of extrovert_domains). */
|
|
147
|
+
domains = new Map();
|
|
148
|
+
/** job id -> async job status (mock mirror of extrovert_jobs; currently only
|
|
149
|
+
* the domain-offboard teardown enqueues one). */
|
|
150
|
+
jobs = new Map();
|
|
151
|
+
/** suppression id -> recipient opt-out row (mock mirror of extrovert_suppressions). */
|
|
152
|
+
suppressions = new Map();
|
|
153
|
+
/** review id -> review request (mock mirror of extrovert_review_requests). */
|
|
154
|
+
reviews = new Map();
|
|
155
|
+
/** review id -> append-only thread turns (mock mirror of the turn log). */
|
|
156
|
+
reviewTurns = new Map();
|
|
157
|
+
/**
|
|
158
|
+
* review id -> reviewer hand-back count (M8 Slice B circuit breaker (a)). The wire
|
|
159
|
+
* Review shape doesn't carry hop_count, so the mock tracks it here to surface the
|
|
160
|
+
* max_hops breaker on the decision context + reviewer_decide.
|
|
161
|
+
*/
|
|
162
|
+
reviewHopCounts = new Map();
|
|
163
|
+
/**
|
|
164
|
+
* review id -> durable nudges for that review (mock mirror of
|
|
165
|
+
* extrovert_review_nudges), oldest-first with a per-review monotonic seq. The
|
|
166
|
+
* authoritative liveness queue (spec §4.5) the agent drains/acks.
|
|
167
|
+
*/
|
|
168
|
+
reviewEvents = new Map();
|
|
169
|
+
/** review id -> the agent's last-acked seq (the per-(agent, review) cursor). */
|
|
170
|
+
reviewEventCursors = new Map();
|
|
171
|
+
/** review id -> the thread a queued reply/forward delivers into (materialized at submit). */
|
|
172
|
+
reviewThreads = new Map();
|
|
173
|
+
/** review id -> the opaque parent message id a queued reply threads to. */
|
|
174
|
+
reviewParents = new Map();
|
|
175
|
+
/** category id -> category (mock mirror of extrovert_categories, D9/D10). */
|
|
176
|
+
categories = new Map();
|
|
177
|
+
/** rule id -> writing rule (mock mirror of extrovert_writing_rules, D2/D11). */
|
|
178
|
+
rules = new Map();
|
|
179
|
+
/** udo id -> change/undo audit row (mock mirror of extrovert_rule_undo_log). */
|
|
180
|
+
ruleAudit = new Map();
|
|
181
|
+
/** human_email -> mock self-signup state (in-memory OTP). */
|
|
182
|
+
signups = new Map();
|
|
183
|
+
/** "<scope>:<client_id>" -> the created resource id, mirroring the server's
|
|
184
|
+
* idempotency replay (a repeat with the same key returns the first result). */
|
|
185
|
+
idempotency = new Map();
|
|
186
|
+
agentId = "agt_demo7";
|
|
187
|
+
/**
|
|
188
|
+
* The ceiling tier of the session's key (redesign §3.1). Default `project`
|
|
189
|
+
* (legacy bare `pk_agent_` behavior). Drives the bare-vs-wildcard list ceiling:
|
|
190
|
+
* an `org` key must pick a breadth (`breadth_required`); a non-org key cannot use
|
|
191
|
+
* the org wildcard (`forbidden_scope`). Mirrors the live choke-point so the
|
|
192
|
+
* isolation contract is exercised offline.
|
|
193
|
+
*/
|
|
194
|
+
keyTier;
|
|
195
|
+
/**
|
|
196
|
+
* The org's review policy, mirrored offline so the mock enforces the SAME tree
|
|
197
|
+
* the server does.
|
|
198
|
+
*
|
|
199
|
+
* The default is `require_review` — deliberately, and matching the column
|
|
200
|
+
* default every real account gets. A mock that defaulted to `allow_direct` would
|
|
201
|
+
* teach every offline agent that a bare send just sends, which is precisely the
|
|
202
|
+
* lie that let the wire bug live for months: the mock passed while the real API
|
|
203
|
+
* refused. Override it with `EXTROVERT_MOCK_REVIEW_POLICY=allow_direct` when a
|
|
204
|
+
* test needs a delivered message rather than a queued review.
|
|
205
|
+
*/
|
|
206
|
+
reviewPolicy;
|
|
207
|
+
/**
|
|
208
|
+
* The `front_run_next` nudge keys already enqueued, so N identical retries
|
|
209
|
+
* against a terminal review collapse to ONE row (the server dedupes on a
|
|
210
|
+
* deterministic key over reason + review + terminal state + parent revision).
|
|
211
|
+
*/
|
|
212
|
+
frontRunKeys = new Set();
|
|
213
|
+
constructor(opts = {}) {
|
|
214
|
+
this.keyTier = opts.keyTier ?? "project";
|
|
215
|
+
this.reviewPolicy = opts.reviewPolicy ?? "require_review";
|
|
216
|
+
this.seed();
|
|
217
|
+
}
|
|
218
|
+
/** The resolved review policy for this account (mock mirror of the inbox read). */
|
|
219
|
+
effectiveReviewPolicy() {
|
|
220
|
+
return this.reviewPolicy;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Persist a review and recompute the derived `closed` flag from its state. Every
|
|
224
|
+
* mutation goes through here so `closed` can never drift from `state` — an agent
|
|
225
|
+
* polling `closed` after a crash is trusting exactly this.
|
|
226
|
+
*/
|
|
227
|
+
commitReview(review) {
|
|
228
|
+
review.closed = reviewClosed(review.state);
|
|
229
|
+
this.reviews.set(review.id, review);
|
|
230
|
+
return review;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The mock mirror of the submit-time D3 gate. A resolved-review send REQUIRES an
|
|
234
|
+
* intent; a bare send/reply/forward has none by construction, so under anything
|
|
235
|
+
* but an explicitly-asserted direct mode on an `allow_direct` account it is
|
|
236
|
+
* refused — nothing sent, nothing queued.
|
|
237
|
+
*/
|
|
238
|
+
resolvedModeIsReview(mode) {
|
|
239
|
+
return !(this.reviewPolicy === "allow_direct" && mode === "direct");
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* The SUBMIT-TIME pre-flight: contact lists and list-unsubscribe suppression,
|
|
243
|
+
* run against the resolved recipient set BEFORE the intent gate.
|
|
244
|
+
*
|
|
245
|
+
* The order matters and mirrors the server exactly. A blocked or suppressed
|
|
246
|
+
* recipient is a fact about the message that no amount of intent will fix, so
|
|
247
|
+
* answering `intent_required` first would send the agent off to add an intent
|
|
248
|
+
* and retry straight into the same wall. Running it before the review is created
|
|
249
|
+
* also means no human is ever handed a draft that could never have been sent.
|
|
250
|
+
*/
|
|
251
|
+
preflight(fromAddress, recipients) {
|
|
252
|
+
this.enforceSendPolicy(fromAddress, recipients);
|
|
253
|
+
this.enforceSuppression(recipients);
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Reject a BARE send/reply/forward under a policy that requires review. The
|
|
257
|
+
* error mirrors the server's 422 `intent_required` remediation, including the
|
|
258
|
+
* `retry_with` example, so an offline agent recovers exactly the way it would
|
|
259
|
+
* against the live API.
|
|
260
|
+
*/
|
|
261
|
+
requireDirectSendAllowed(verb) {
|
|
262
|
+
if (this.reviewPolicy === "allow_direct")
|
|
263
|
+
return;
|
|
264
|
+
throw new IntentRequiredError(`This inbox requires human review before sending (review policy: ${this.reviewPolicy}, from the account default; ` +
|
|
265
|
+
"no per-inbox override). Nothing was sent and nothing was queued. Retry the SAME request with an `intent` " +
|
|
266
|
+
'object added: {"intent":{"summary":"<one sentence: who you are writing to, what you want, and why now>"}}. ' +
|
|
267
|
+
"That summary is the first thing the human reviewer reads; 8-200 characters. On success you get a queued " +
|
|
268
|
+
"review id (rr_…); then monitor it with wait_for_review_event / list_review_events until you receive a `sent` " +
|
|
269
|
+
"or `send_failed` event.", [
|
|
270
|
+
{
|
|
271
|
+
field: "intent.summary",
|
|
272
|
+
code: "required",
|
|
273
|
+
detail: "One sentence for the human reviewer: who / what / why. 8-200 chars.",
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
field: "policy",
|
|
277
|
+
code: "review_policy",
|
|
278
|
+
detail: `${this.reviewPolicy} (source: account default; inbox override: none)`,
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
field: "retry_with",
|
|
282
|
+
code: "example",
|
|
283
|
+
detail: '{"intent":{"summary":"Follow up with vp@acme.com on the Q3 pilot; 2 prior touches"}}',
|
|
284
|
+
},
|
|
285
|
+
{ field: "verb", code: verb, detail: "the request that was refused" },
|
|
286
|
+
]);
|
|
287
|
+
}
|
|
288
|
+
// ---- self-signup + auth (Slice E) -------------------------------------
|
|
289
|
+
signUp(input) {
|
|
290
|
+
const email = input.human_email.trim().toLowerCase();
|
|
291
|
+
const existing = this.signups.get(email);
|
|
292
|
+
const customerId = existing?.customerId ?? nextId("cus");
|
|
293
|
+
const agentId = existing?.agentId ?? nextId("agt");
|
|
294
|
+
const address = existing?.address ?? `${input.username ?? "agent" + shortLabel()}@smtp.extrovert.dev`;
|
|
295
|
+
const otp = String(Math.floor(100000 + Math.random() * 900000));
|
|
296
|
+
this.signups.set(email, { customerId, agentId, address, otp, verified: false });
|
|
297
|
+
const keyPrefix = "pk_agent_" + nextId("").split("_")[1];
|
|
298
|
+
return {
|
|
299
|
+
customer_id: customerId,
|
|
300
|
+
agent_id: agentId,
|
|
301
|
+
agent_key: `${keyPrefix}_${Math.random().toString(36).slice(2)}`,
|
|
302
|
+
key_prefix: keyPrefix,
|
|
303
|
+
scopes: ["mailbox:read"],
|
|
304
|
+
address,
|
|
305
|
+
verified: false,
|
|
306
|
+
otp_sent_to: email,
|
|
307
|
+
otp_expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
|
308
|
+
message: "A verification code was sent to your email. Call verify with it.",
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
verify(otp) {
|
|
312
|
+
for (const [, s] of this.signups) {
|
|
313
|
+
if (!s.verified && s.otp === otp.trim()) {
|
|
314
|
+
s.verified = true;
|
|
315
|
+
const keyPrefix = "pk_agent_" + nextId("").split("_")[1];
|
|
316
|
+
return {
|
|
317
|
+
agent_id: s.agentId,
|
|
318
|
+
agent_key: `${keyPrefix}_${Math.random().toString(36).slice(2)}`,
|
|
319
|
+
key_prefix: keyPrefix,
|
|
320
|
+
scopes: ["mailbox:create", "mailbox:read", "mailbox:send"],
|
|
321
|
+
verified: true,
|
|
322
|
+
message: "Verified. Use the new agent_key (full scopes).",
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
throw new NotFoundError("verification code invalid or expired");
|
|
327
|
+
}
|
|
328
|
+
whoami() {
|
|
329
|
+
return {
|
|
330
|
+
customer_id: "cus_pn_mock",
|
|
331
|
+
org_id: MOCK_ORG_ID,
|
|
332
|
+
project_id: MOCK_PROJECT_ID,
|
|
333
|
+
agent_id: this.agentId,
|
|
334
|
+
key_id: "pkey_mock",
|
|
335
|
+
scopes: ["mailbox:create", "mailbox:read", "mailbox:send"],
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
// ---- enrollment -------------------------------------------------------
|
|
339
|
+
redeemEnrollment(token, agentHandle) {
|
|
340
|
+
const keyPrefix = "pk_agent_" + nextId("").split("_")[1];
|
|
341
|
+
const result = {
|
|
342
|
+
agent_id: this.agentId,
|
|
343
|
+
agent_key: `${keyPrefix}_${Math.random().toString(36).slice(2)}${Math.random()
|
|
344
|
+
.toString(36)
|
|
345
|
+
.slice(2)}`,
|
|
346
|
+
scopes: ["mailbox:create", "mailbox:read", "mailbox:send"],
|
|
347
|
+
org_id: MOCK_ORG_ID,
|
|
348
|
+
project_id: MOCK_PROJECT_ID,
|
|
349
|
+
};
|
|
350
|
+
// token is validated server-side in production; offline we accept any.
|
|
351
|
+
void token;
|
|
352
|
+
void agentHandle;
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
// ---- inboxes ----------------------------------------------------------
|
|
356
|
+
createInbox(opts) {
|
|
357
|
+
// A project_id assertion must match the key's bound project (403 on mismatch),
|
|
358
|
+
// mirroring the SDK mock + the real server.
|
|
359
|
+
assertProjectMatch(opts.projectId);
|
|
360
|
+
// Idempotency replay: a repeat with the same client id returns the first inbox.
|
|
361
|
+
const idemKey = opts.clientId?.trim() ? `inbox.create:${opts.clientId.trim()}` : "";
|
|
362
|
+
if (idemKey) {
|
|
363
|
+
const existingId = this.idempotency.get(idemKey);
|
|
364
|
+
const existing = existingId ? this.inboxes.get(existingId) : undefined;
|
|
365
|
+
if (existing)
|
|
366
|
+
return existing;
|
|
367
|
+
}
|
|
368
|
+
const username = (opts.username ?? `agent${this.inboxes.size + 1}`).toLowerCase();
|
|
369
|
+
const domain = opts.domain ?? `${shortLabel()}.smtp.extrovert.dev`;
|
|
370
|
+
const inbox = {
|
|
371
|
+
object: "inbox",
|
|
372
|
+
// Mint the canonical opaque inbox id with the LIVE prefix (`pmbx_…`,
|
|
373
|
+
// Appendix A); the public contract is "treat it as opaque."
|
|
374
|
+
id: nextId("pmbx"),
|
|
375
|
+
org_id: MOCK_ORG_ID,
|
|
376
|
+
project_id: MOCK_PROJECT_ID,
|
|
377
|
+
address: `${username}@${domain}`,
|
|
378
|
+
domain,
|
|
379
|
+
onboarding_mode: "shared",
|
|
380
|
+
status: "live",
|
|
381
|
+
agent_id: this.agentId,
|
|
382
|
+
created_at: new Date().toISOString(),
|
|
383
|
+
sender_verified: true,
|
|
384
|
+
daily_send_limit: DEFAULT_DAILY_SEND_LIMIT,
|
|
385
|
+
// Metadata is always an object on a read (`{}` when none is set); a create
|
|
386
|
+
// patch drops any `null` values per the merge-null-clear semantics.
|
|
387
|
+
metadata: applyMetadataPatch({}, opts.metadata),
|
|
388
|
+
};
|
|
389
|
+
if (opts.displayName)
|
|
390
|
+
inbox.display_name = opts.displayName;
|
|
391
|
+
if (opts.inboundWebhookUrl)
|
|
392
|
+
inbox.webhook_url = opts.inboundWebhookUrl;
|
|
393
|
+
this.inboxes.set(inbox.id, inbox);
|
|
394
|
+
this.messages.set(inbox.id, []);
|
|
395
|
+
if (idemKey)
|
|
396
|
+
this.idempotency.set(idemKey, inbox.id);
|
|
397
|
+
return inbox;
|
|
398
|
+
}
|
|
399
|
+
listInboxes(opts = {}) {
|
|
400
|
+
const o = typeof opts === "number" ? { limit: opts } : opts;
|
|
401
|
+
const limit = o.limit ?? 20;
|
|
402
|
+
// Enforce the same bare-vs-wildcard ceiling the live choke-point applies
|
|
403
|
+
// (redesign §4.1), so the isolation contract is exercised offline.
|
|
404
|
+
const projectSegment = o.wildcard ? "-" : o.project;
|
|
405
|
+
if (projectSegment === undefined) {
|
|
406
|
+
// Bare list. An org key must pick a breadth; project/inbox keys default to
|
|
407
|
+
// their (implicit) project — exactly today's behavior.
|
|
408
|
+
if (this.keyTier === "org") {
|
|
409
|
+
throw new BreadthRequiredError("An org-tier key must pick a list breadth: pass a project id or wildcard=true (the org subtree).");
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
else if (projectSegment === "-") {
|
|
413
|
+
// The org wildcard is reserved for org keys (a non-org key is 403).
|
|
414
|
+
if (this.keyTier !== "org") {
|
|
415
|
+
throw new ForbiddenError("Only an org-tier key may list the org subtree (/v1/projects/-/inboxes).");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
else if (projectSegment !== MOCK_PROJECT_ID) {
|
|
419
|
+
// A concrete project outside the key's ceiling is a 404 (no existence leak).
|
|
420
|
+
throw new NotFoundError(`project "${projectSegment}" is outside this key's ceiling.`);
|
|
421
|
+
}
|
|
422
|
+
const items = [...this.inboxes.values()]
|
|
423
|
+
.sort((a, b) => b.created_at.localeCompare(a.created_at))
|
|
424
|
+
.slice(0, limit);
|
|
425
|
+
return { items, total: this.inboxes.size };
|
|
426
|
+
}
|
|
427
|
+
getInbox(idOrAddress) {
|
|
428
|
+
const inbox = this.resolveInbox(idOrAddress);
|
|
429
|
+
if (!inbox)
|
|
430
|
+
return undefined;
|
|
431
|
+
// Only the SINGLE-inbox read carries the policy — the list path omits it
|
|
432
|
+
// because the value is identical for every inbox in the org.
|
|
433
|
+
return { ...inbox, effective_review_policy: this.reviewPolicy };
|
|
434
|
+
}
|
|
435
|
+
/** Update an inbox's settings in place (mirrors PATCH /v1/inboxes/{inbox_id}). */
|
|
436
|
+
updateInbox(idOrAddress, opts) {
|
|
437
|
+
// A project_id assertion must match the key's bound project (403 on mismatch),
|
|
438
|
+
// mirroring the SDK mock + the real server.
|
|
439
|
+
assertProjectMatch(opts.projectId);
|
|
440
|
+
const inbox = this.resolveInbox(idOrAddress);
|
|
441
|
+
if (!inbox)
|
|
442
|
+
return undefined;
|
|
443
|
+
if (opts.displayName !== undefined) {
|
|
444
|
+
inbox.display_name = opts.displayName || undefined;
|
|
445
|
+
}
|
|
446
|
+
if (opts.inboundWebhookUrl !== undefined) {
|
|
447
|
+
inbox.webhook_url = opts.inboundWebhookUrl || undefined;
|
|
448
|
+
}
|
|
449
|
+
if (opts.dailySendLimit !== undefined) {
|
|
450
|
+
if (!Number.isInteger(opts.dailySendLimit) ||
|
|
451
|
+
opts.dailySendLimit < 1 ||
|
|
452
|
+
opts.dailySendLimit > 10_000) {
|
|
453
|
+
throw new Error("daily_send_limit must be an integer from 1 through 10000");
|
|
454
|
+
}
|
|
455
|
+
inbox.daily_send_limit = opts.dailySendLimit;
|
|
456
|
+
}
|
|
457
|
+
if (opts.metadata !== undefined) {
|
|
458
|
+
// Shallow merge: object merges (null value deletes a key); top-level null
|
|
459
|
+
// clears all. Omitting `metadata` (handled above) leaves it unchanged.
|
|
460
|
+
inbox.metadata = applyMetadataPatch(inbox.metadata ?? {}, opts.metadata);
|
|
461
|
+
}
|
|
462
|
+
return inbox;
|
|
463
|
+
}
|
|
464
|
+
getCredentials(idOrAddress) {
|
|
465
|
+
const inbox = this.resolveInbox(idOrAddress);
|
|
466
|
+
if (!inbox)
|
|
467
|
+
throw new NotFoundError(`Inbox not found: ${idOrAddress}`);
|
|
468
|
+
return {
|
|
469
|
+
address: inbox.address,
|
|
470
|
+
username: inbox.address,
|
|
471
|
+
// Deterministic offline stand-in; the live API returns the real password.
|
|
472
|
+
password: `fixture-pw-${inbox.id}`,
|
|
473
|
+
imap: { host: "smtp.extrovert.dev", port: 993, security: "tls" },
|
|
474
|
+
smtp: { host: "smtp.extrovert.dev", port: 587, security: "starttls" },
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
deleteInbox(idOrAddress) {
|
|
478
|
+
const inbox = this.resolveInbox(idOrAddress);
|
|
479
|
+
if (!inbox)
|
|
480
|
+
return undefined;
|
|
481
|
+
this.inboxes.delete(inbox.id);
|
|
482
|
+
this.messages.delete(inbox.id);
|
|
483
|
+
return { id: inbox.id, deleted: true };
|
|
484
|
+
}
|
|
485
|
+
// ---- messages ---------------------------------------------------------
|
|
486
|
+
/**
|
|
487
|
+
* A BARE send — no mode/intent/category_id. Policy-gated exactly like the live
|
|
488
|
+
* endpoint: only an `allow_direct` account delivers here; under `require_review`
|
|
489
|
+
* (the default) this is 422 `intent_required` with the remediation attached, and
|
|
490
|
+
* nothing is sent or queued.
|
|
491
|
+
*/
|
|
492
|
+
sendEmail(opts) {
|
|
493
|
+
const inbox = this.requireInbox(opts.inbox);
|
|
494
|
+
this.preflight(inbox.address, [...opts.to, ...(opts.cc ?? []), ...(opts.bcc ?? [])]);
|
|
495
|
+
this.requireDirectSendAllowed("send");
|
|
496
|
+
const msg = this.deliverSend(opts);
|
|
497
|
+
return { status: "sent", message_id: msg.id, review_id: this.recordDirectReview("send", msg, opts.subject, opts.to) };
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* The actual mock delivery, shared by the bare direct path and the review
|
|
501
|
+
* loop's approve/auto-send dispatch. Policy is enforced by the CALLERS, never
|
|
502
|
+
* here: an approved review has already passed the human and must deliver.
|
|
503
|
+
*/
|
|
504
|
+
deliverSend(opts) {
|
|
505
|
+
const inbox = this.requireInbox(opts.inbox);
|
|
506
|
+
const recipients = [...opts.to, ...(opts.cc ?? []), ...(opts.bcc ?? [])];
|
|
507
|
+
// Contact lists (Slice 3): reject a block-listed recipient, or any recipient
|
|
508
|
+
// outside the allowlist when allowlist mode is active.
|
|
509
|
+
this.enforceSendPolicy(inbox.address, recipients);
|
|
510
|
+
// Suppression (list-unsubscribe): reject the WHOLE send if any recipient has an
|
|
511
|
+
// active org-scope opt-out, naming exactly the suppressed addresses to drop.
|
|
512
|
+
this.enforceSuppression(recipients);
|
|
513
|
+
const msg = this.appendMessage(inbox.id, {
|
|
514
|
+
direction: "outbound",
|
|
515
|
+
fromName: inbox.display_name,
|
|
516
|
+
fromEmail: inbox.address,
|
|
517
|
+
to: opts.to,
|
|
518
|
+
cc: opts.cc,
|
|
519
|
+
subject: opts.subject,
|
|
520
|
+
text: opts.text,
|
|
521
|
+
html: opts.html,
|
|
522
|
+
threadId: nextId("thr"),
|
|
523
|
+
ageMinutes: 0,
|
|
524
|
+
attachments: opts.attachments,
|
|
525
|
+
});
|
|
526
|
+
// Offline affordance: queue a believable inbound reply so wait_for_email
|
|
527
|
+
// and read_messages return something coherent in the demo flow.
|
|
528
|
+
this.queueAutoReply(inbox, msg);
|
|
529
|
+
return msg;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Thread-aware reply (mock). Resolves the parent by message_id or the latest
|
|
533
|
+
* message in thread_id, derives recipients/subject server-side, and returns the
|
|
534
|
+
* canonical `{message_id, thread_id}` — matching the real API contract.
|
|
535
|
+
*/
|
|
536
|
+
replyEmail(opts) {
|
|
537
|
+
this.requireInbox(opts.inbox);
|
|
538
|
+
this.requireDirectSendAllowed("reply");
|
|
539
|
+
return this.deliverReply(opts);
|
|
540
|
+
}
|
|
541
|
+
/** The mock reply delivery, shared by the direct path and approval dispatch. */
|
|
542
|
+
deliverReply(opts) {
|
|
543
|
+
const inbox = this.requireInbox(opts.inbox);
|
|
544
|
+
const all = this.messages.get(inbox.id) ?? [];
|
|
545
|
+
let parent;
|
|
546
|
+
let threadId = opts.threadId;
|
|
547
|
+
if (opts.messageId) {
|
|
548
|
+
parent = all.find((m) => m.id === opts.messageId);
|
|
549
|
+
if (!parent)
|
|
550
|
+
throw new NotFoundError(`Message not found: ${opts.messageId}`);
|
|
551
|
+
threadId = parent.thread_id;
|
|
552
|
+
}
|
|
553
|
+
else if (opts.threadId) {
|
|
554
|
+
parent = all.filter((m) => m.thread_id === opts.threadId).at(-1);
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
throw new NotFoundError("reply requires thread_id or message_id");
|
|
558
|
+
}
|
|
559
|
+
const to = [];
|
|
560
|
+
if (parent) {
|
|
561
|
+
to.push(parent.from.email);
|
|
562
|
+
if (opts.replyAll)
|
|
563
|
+
for (const a of parent.to)
|
|
564
|
+
if (a.email !== inbox.address)
|
|
565
|
+
to.push(a.email);
|
|
566
|
+
}
|
|
567
|
+
const msg = this.appendMessage(inbox.id, {
|
|
568
|
+
direction: "outbound",
|
|
569
|
+
fromName: inbox.display_name,
|
|
570
|
+
fromEmail: inbox.address,
|
|
571
|
+
to: to.length ? to : ["reply@example.com"],
|
|
572
|
+
cc: opts.cc,
|
|
573
|
+
subject: parent ? reSubject(parent.subject) : "Re:",
|
|
574
|
+
text: opts.text ?? "",
|
|
575
|
+
html: opts.html,
|
|
576
|
+
threadId: threadId ?? nextId("thr"),
|
|
577
|
+
ageMinutes: 0,
|
|
578
|
+
attachments: opts.attachments,
|
|
579
|
+
});
|
|
580
|
+
return { message_id: msg.id, thread_id: msg.thread_id };
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Forward an existing message to new recipients (mock). Policy-gated like send
|
|
584
|
+
* and reply: a forward quotes an entire received thread to arbitrary NEW
|
|
585
|
+
* recipients, so leaving it ungated would make it the documented bypass.
|
|
586
|
+
*/
|
|
587
|
+
forwardEmail(opts) {
|
|
588
|
+
const inbox = this.requireInbox(opts.inbox);
|
|
589
|
+
this.preflight(inbox.address, [...opts.to, ...(opts.cc ?? []), ...(opts.bcc ?? [])]);
|
|
590
|
+
this.requireDirectSendAllowed("forward");
|
|
591
|
+
return this.deliverForward(opts);
|
|
592
|
+
}
|
|
593
|
+
/** The mock forward delivery, shared by the direct path and approval dispatch. */
|
|
594
|
+
deliverForward(opts) {
|
|
595
|
+
const inbox = this.requireInbox(opts.inbox);
|
|
596
|
+
const parent = (this.messages.get(inbox.id) ?? []).find((m) => m.id === opts.messageId);
|
|
597
|
+
if (!parent)
|
|
598
|
+
throw new NotFoundError(`Message not found: ${opts.messageId}`);
|
|
599
|
+
const msg = this.appendMessage(inbox.id, {
|
|
600
|
+
direction: "outbound",
|
|
601
|
+
fromName: inbox.display_name,
|
|
602
|
+
fromEmail: inbox.address,
|
|
603
|
+
to: opts.to,
|
|
604
|
+
cc: opts.cc,
|
|
605
|
+
subject: fwdSubject(parent.subject),
|
|
606
|
+
text: forwardBody(opts.text, parent),
|
|
607
|
+
threadId: parent.thread_id,
|
|
608
|
+
ageMinutes: 0,
|
|
609
|
+
});
|
|
610
|
+
return { message_id: msg.id, thread_id: msg.thread_id };
|
|
611
|
+
}
|
|
612
|
+
// ---- Review Loop (HITL) -----------------------------------------------
|
|
613
|
+
/**
|
|
614
|
+
* Submit a new message for review (mock). Mirrors the server's deterministic
|
|
615
|
+
* routing: a `direct` mode is sent immediately (`kind:"sent"`); otherwise the
|
|
616
|
+
* message is parked in `needs_review` (`kind:"queued_for_review"`). Intent is
|
|
617
|
+
* required when the resolved mode is review (D3).
|
|
618
|
+
*/
|
|
619
|
+
submitForReview(input) {
|
|
620
|
+
const inbox = this.requireInbox(input.inbox);
|
|
621
|
+
// The POLICY resolves the mode, not the caller: an asserted `direct` under
|
|
622
|
+
// require_review is downgraded to review, which is what makes the policy
|
|
623
|
+
// binding rather than advisory.
|
|
624
|
+
const asserted = input.mode === "direct" ? "direct" : "review";
|
|
625
|
+
// Pre-flight BEFORE the intent gate — see preflight()'s note on the ordering.
|
|
626
|
+
this.preflight(inbox.address, [...input.to, ...(input.cc ?? []), ...(input.bcc ?? [])]);
|
|
627
|
+
const isReview = this.resolvedModeIsReview(asserted);
|
|
628
|
+
if (isReview && !input.intent?.summary?.trim()) {
|
|
629
|
+
throw new IntentRequiredError("intent summary is required when the resolved mode is review", [
|
|
630
|
+
{
|
|
631
|
+
field: "intent.summary",
|
|
632
|
+
code: "required",
|
|
633
|
+
detail: "One sentence for the human reviewer: who / what / why. 8-200 chars.",
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
field: "policy",
|
|
637
|
+
code: "review_policy",
|
|
638
|
+
detail: `${this.reviewPolicy} (source: account default; inbox override: none)`,
|
|
639
|
+
},
|
|
640
|
+
]);
|
|
641
|
+
}
|
|
642
|
+
if (!isReview) {
|
|
643
|
+
const sent = this.deliverSend({
|
|
644
|
+
inbox: input.inbox,
|
|
645
|
+
to: input.to,
|
|
646
|
+
subject: input.subject ?? "",
|
|
647
|
+
text: input.text,
|
|
648
|
+
html: input.html,
|
|
649
|
+
cc: input.cc,
|
|
650
|
+
bcc: input.bcc,
|
|
651
|
+
reply_to: input.reply_to,
|
|
652
|
+
headers: input.headers,
|
|
653
|
+
attachments: input.attachments,
|
|
654
|
+
});
|
|
655
|
+
const reviewId = this.recordDirectReview("send", sent, input.subject ?? "", input.to);
|
|
656
|
+
return { kind: "sent", message: { id: sent.id, thread_id: sent.thread_id }, review: { id: reviewId } };
|
|
657
|
+
}
|
|
658
|
+
const review = this.createReviewRecord({
|
|
659
|
+
kind: "send",
|
|
660
|
+
fromAddress: inbox.address,
|
|
661
|
+
subject: input.subject ?? "",
|
|
662
|
+
text: input.text,
|
|
663
|
+
html: input.html,
|
|
664
|
+
to: input.to,
|
|
665
|
+
cc: input.cc,
|
|
666
|
+
bcc: input.bcc,
|
|
667
|
+
intent: input.intent,
|
|
668
|
+
categoryId: input.category_id,
|
|
669
|
+
});
|
|
670
|
+
return { kind: "queued_for_review", review: { id: review.id, state: review.state, effective_mode: review.effective_mode } };
|
|
671
|
+
}
|
|
672
|
+
/** Submit an in-thread reply for review (mock). Same routing as submitForReview. */
|
|
673
|
+
submitReplyForReview(input) {
|
|
674
|
+
const inbox = this.requireInbox(input.inbox);
|
|
675
|
+
const asserted = input.mode === "direct" ? "direct" : "review";
|
|
676
|
+
const isReview = this.resolvedModeIsReview(asserted);
|
|
677
|
+
if (isReview && !input.intent?.summary?.trim()) {
|
|
678
|
+
throw new IntentRequiredError("intent summary is required when the resolved mode is review");
|
|
679
|
+
}
|
|
680
|
+
if (!isReview) {
|
|
681
|
+
const res = this.deliverReply({
|
|
682
|
+
inbox: input.inbox,
|
|
683
|
+
threadId: input.thread_id,
|
|
684
|
+
messageId: input.message_id,
|
|
685
|
+
text: input.text,
|
|
686
|
+
html: input.html,
|
|
687
|
+
cc: input.cc,
|
|
688
|
+
bcc: input.bcc,
|
|
689
|
+
replyTo: input.reply_to,
|
|
690
|
+
replyAll: input.reply_all,
|
|
691
|
+
attachments: input.attachments,
|
|
692
|
+
});
|
|
693
|
+
return { kind: "sent", message: { id: res.message_id, thread_id: res.thread_id } };
|
|
694
|
+
}
|
|
695
|
+
// The reply's envelope is materialized AT SUBMIT — subject and recipients are
|
|
696
|
+
// derived here, not at approval — so the human reviews a message that actually
|
|
697
|
+
// has a subject line and recipients. A queued reply used to store neither.
|
|
698
|
+
const parent = this.resolveReplyParent(inbox.id, input.thread_id, input.message_id);
|
|
699
|
+
const to = parent ? [parent.from.email] : [];
|
|
700
|
+
if (parent && input.reply_all) {
|
|
701
|
+
for (const participant of parent.to) {
|
|
702
|
+
if (participant.email !== inbox.address && !to.includes(participant.email))
|
|
703
|
+
to.push(participant.email);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const review = this.createReviewRecord({
|
|
707
|
+
kind: "reply",
|
|
708
|
+
fromAddress: inbox.address,
|
|
709
|
+
subject: parent ? reSubject(parent.subject) : "Re:",
|
|
710
|
+
text: input.text,
|
|
711
|
+
html: input.html,
|
|
712
|
+
to,
|
|
713
|
+
cc: input.cc,
|
|
714
|
+
intent: input.intent,
|
|
715
|
+
categoryId: input.category_id,
|
|
716
|
+
replyThreadId: parent?.thread_id ?? input.thread_id,
|
|
717
|
+
replyParentId: parent?.id ?? input.message_id,
|
|
718
|
+
});
|
|
719
|
+
return { kind: "queued_for_review", review: { id: review.id, state: review.state, effective_mode: review.effective_mode } };
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Submit a forward for review (mock). Same routing as submitForReview, with the
|
|
723
|
+
* forward's subject + quoted body MATERIALIZED here so the human reviews the
|
|
724
|
+
* exact bytes that go out rather than a body re-derived from the live parent at
|
|
725
|
+
* approval time (which would silently discard the reviewer's edit).
|
|
726
|
+
*/
|
|
727
|
+
submitForwardForReview(input) {
|
|
728
|
+
const inbox = this.requireInbox(input.inbox);
|
|
729
|
+
this.preflight(inbox.address, [...input.to, ...(input.cc ?? []), ...(input.bcc ?? [])]);
|
|
730
|
+
const asserted = input.mode === "direct" ? "direct" : "review";
|
|
731
|
+
const isReview = this.resolvedModeIsReview(asserted);
|
|
732
|
+
if (isReview && !input.intent?.summary?.trim()) {
|
|
733
|
+
throw new IntentRequiredError("intent summary is required when the resolved mode is review");
|
|
734
|
+
}
|
|
735
|
+
if (!isReview) {
|
|
736
|
+
const res = this.deliverForward({
|
|
737
|
+
inbox: input.inbox,
|
|
738
|
+
messageId: input.message_id,
|
|
739
|
+
to: input.to,
|
|
740
|
+
cc: input.cc,
|
|
741
|
+
bcc: input.bcc,
|
|
742
|
+
text: input.text,
|
|
743
|
+
html: input.html,
|
|
744
|
+
});
|
|
745
|
+
return { kind: "sent", message: { id: res.message_id, thread_id: res.thread_id } };
|
|
746
|
+
}
|
|
747
|
+
const parent = (this.messages.get(inbox.id) ?? []).find((m) => m.id === input.message_id);
|
|
748
|
+
if (!parent)
|
|
749
|
+
throw new NotFoundError(`Message not found: ${input.message_id}`);
|
|
750
|
+
const review = this.createReviewRecord({
|
|
751
|
+
kind: "forward",
|
|
752
|
+
fromAddress: inbox.address,
|
|
753
|
+
subject: fwdSubject(parent.subject),
|
|
754
|
+
text: forwardBody(input.text, parent),
|
|
755
|
+
to: input.to,
|
|
756
|
+
cc: input.cc,
|
|
757
|
+
bcc: input.bcc,
|
|
758
|
+
intent: input.intent,
|
|
759
|
+
categoryId: input.category_id,
|
|
760
|
+
// A forward is NOT threaded to its parent — the new recipients were never in
|
|
761
|
+
// that conversation — but the delivered forward still answers with the
|
|
762
|
+
// parent's thread id, matching the direct forward path.
|
|
763
|
+
replyThreadId: parent.thread_id,
|
|
764
|
+
});
|
|
765
|
+
return { kind: "queued_for_review", review: { id: review.id, state: review.state, effective_mode: review.effective_mode } };
|
|
766
|
+
}
|
|
767
|
+
/** Resolve a reply's parent by message id, else the latest message in a thread. */
|
|
768
|
+
resolveReplyParent(inboxId, threadId, messageId) {
|
|
769
|
+
const all = this.messages.get(inboxId) ?? [];
|
|
770
|
+
if (messageId) {
|
|
771
|
+
const parent = all.find((m) => m.id === messageId);
|
|
772
|
+
if (!parent)
|
|
773
|
+
throw new NotFoundError(`Message not found: ${messageId}`);
|
|
774
|
+
return parent;
|
|
775
|
+
}
|
|
776
|
+
if (threadId)
|
|
777
|
+
return all.filter((m) => m.thread_id === threadId).at(-1);
|
|
778
|
+
throw new NotFoundError("reply requires thread_id or message_id");
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Record the review row that governed a DIRECT (policy-permitted) send, park it
|
|
782
|
+
* in the terminal `auto_sent` state with `send_path: agent_direct`, and emit its
|
|
783
|
+
* one terminal `sent` nudge.
|
|
784
|
+
*
|
|
785
|
+
* The direct path used to hand back no handle at all, which meant an agent that
|
|
786
|
+
* crashed between the request and the response could never ask what became of
|
|
787
|
+
* the message. Leaving the dominant path handle-less would have entrenched
|
|
788
|
+
* exactly the crash-recovery hole the review loop exists to close.
|
|
789
|
+
*/
|
|
790
|
+
recordDirectReview(kind, msg, subject, to) {
|
|
791
|
+
const review = this.createReviewRecord({
|
|
792
|
+
kind,
|
|
793
|
+
fromAddress: msg.from.email,
|
|
794
|
+
subject,
|
|
795
|
+
text: msg.text ?? "",
|
|
796
|
+
to,
|
|
797
|
+
mode: "direct",
|
|
798
|
+
});
|
|
799
|
+
review.state = "auto_sent";
|
|
800
|
+
review.version += 1;
|
|
801
|
+
review.sent_message_id = msg.id;
|
|
802
|
+
review.send_path = "agent_direct";
|
|
803
|
+
review.sent_at = new Date().toISOString();
|
|
804
|
+
review.updated_at = review.sent_at;
|
|
805
|
+
this.commitReview(review);
|
|
806
|
+
this.enqueueTerminalNudge(review, "sent");
|
|
807
|
+
return review.id;
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Enqueue the ONE terminal nudge a finished review is allowed to produce.
|
|
811
|
+
*
|
|
812
|
+
* The invariant a drain loop is written against: every review that reaches
|
|
813
|
+
* `sent`, `auto_sent`, `failed` or `cancelled` emits exactly one terminal nudge,
|
|
814
|
+
* and it is the last and highest-`seq` nudge that review will ever produce. The
|
|
815
|
+
* payload carries everything the agent needs to stop — including WHY a send
|
|
816
|
+
* failed, which was previously stored and exposed on no surface at all.
|
|
817
|
+
*/
|
|
818
|
+
enqueueTerminalNudge(review, reason) {
|
|
819
|
+
if (reason === "cancelled") {
|
|
820
|
+
this.enqueueReviewEvent(review.id, "cancelled", { state: "cancelled" });
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (reason === "send_failed") {
|
|
824
|
+
this.enqueueReviewEvent(review.id, "send_failed", {
|
|
825
|
+
state: "failed",
|
|
826
|
+
error: review.send_error ?? "",
|
|
827
|
+
from_state: "approved",
|
|
828
|
+
// A failed review is NOT retryable: the row is absorbing. The only close-out
|
|
829
|
+
// is cancel_review; the message itself must be composed and submitted anew.
|
|
830
|
+
agent_retryable: false,
|
|
831
|
+
next_action: "compose_and_submit_a_new_message",
|
|
832
|
+
});
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
// ONE `sent` reason covers both delivery flavors: `payload.state` distinguishes
|
|
836
|
+
// a human-reviewed `sent` from a `auto_sent`, and the agent's action — record
|
|
837
|
+
// the message id, ack, stop polling — is identical either way.
|
|
838
|
+
this.enqueueReviewEvent(review.id, "sent", {
|
|
839
|
+
state: review.state,
|
|
840
|
+
message_id: review.sent_message_id ?? "",
|
|
841
|
+
send_path: review.send_path ?? "",
|
|
842
|
+
decision: review.sent_body_text || review.sent_subject || review.diff_unified ? "edited" : "approved",
|
|
843
|
+
sent_at: review.sent_at ?? "",
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Best-effort `front_run_next` nudge for an agent that tried to mutate a review
|
|
848
|
+
* somebody else already finished. It is enqueued OUTSIDE any transition (there
|
|
849
|
+
* is none) and deduped on a deterministic key, so a retry loop hitting the same
|
|
850
|
+
* 409 with the same parent_revision collapses N identical nudges to ONE row.
|
|
851
|
+
*/
|
|
852
|
+
enqueueFrontRunNudge(review, parentRevision) {
|
|
853
|
+
const key = `front_run_next:${review.id}:${review.state}:${parentRevision}`;
|
|
854
|
+
if (this.frontRunKeys.has(key))
|
|
855
|
+
return;
|
|
856
|
+
this.frontRunKeys.add(key);
|
|
857
|
+
this.enqueueReviewEvent(review.id, "front_run_next", {
|
|
858
|
+
state: review.state,
|
|
859
|
+
sent_message_id: review.sent_message_id ?? "",
|
|
860
|
+
your_parent_revision: parentRevision,
|
|
861
|
+
current_revision: review.revision,
|
|
862
|
+
diff_available: !!review.diff_unified,
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* The mock mirror of the 409 taxonomy for a review-mutating verb. A terminal row
|
|
867
|
+
* and a wrong-phase row are DIFFERENT errors on purpose: one must never be
|
|
868
|
+
* retried, the other needs a different verb. Collapsing them to a single
|
|
869
|
+
* "conflict" is what made a skill's one 409 handler retry a sent message forever.
|
|
870
|
+
*/
|
|
871
|
+
assertMutable(review, verb, parentRevision) {
|
|
872
|
+
if (TERMINAL_REVIEW_STATES.includes(review.state)) {
|
|
873
|
+
this.enqueueFrontRunNudge(review, parentRevision);
|
|
874
|
+
throw new TerminalError(`${verb} is not legal on a ${review.state} review: it is terminal and nothing will ever succeed. ` +
|
|
875
|
+
"STOP retrying — a front_run_next nudge is waiting for you.", review);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
/** List review requests (mock), newest-first, with optional state/category/inbox filters. */
|
|
879
|
+
listReviews(input = {}) {
|
|
880
|
+
let items = [...this.reviews.values()].sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
881
|
+
if (input.state !== undefined) {
|
|
882
|
+
const states = Array.isArray(input.state) ? input.state : [input.state];
|
|
883
|
+
items = items.filter((r) => states.includes(r.state));
|
|
884
|
+
}
|
|
885
|
+
if (input.category_id)
|
|
886
|
+
items = items.filter((r) => r.category_id === input.category_id);
|
|
887
|
+
if (input.inbox) {
|
|
888
|
+
const inbox = input.inbox.toLowerCase();
|
|
889
|
+
items = items.filter((r) => r.from_address.toLowerCase() === inbox);
|
|
890
|
+
}
|
|
891
|
+
return { items, total: items.length };
|
|
892
|
+
}
|
|
893
|
+
/** Get one review request (mock). */
|
|
894
|
+
getReview(id) {
|
|
895
|
+
const review = this.reviews.get(id);
|
|
896
|
+
if (!review)
|
|
897
|
+
throw new NotFoundError(`Review not found: ${id}`);
|
|
898
|
+
return review;
|
|
899
|
+
}
|
|
900
|
+
/** Get a review's append-only thread turns (mock). */
|
|
901
|
+
getReviewTurns(id) {
|
|
902
|
+
if (!this.reviews.has(id))
|
|
903
|
+
throw new NotFoundError(`Review not found: ${id}`);
|
|
904
|
+
const items = this.reviewTurns.get(id) ?? [];
|
|
905
|
+
return { items, total: items.length };
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Get the human's assembled feedback for a review (mock; M5): the diff + the
|
|
909
|
+
* human comments/rejection turns + the decision (derived from state) + the rules
|
|
910
|
+
* born from this review. Mirrors the server's $0-LLM assembly.
|
|
911
|
+
*/
|
|
912
|
+
getReviewFeedback(id) {
|
|
913
|
+
const review = this.reviews.get(id);
|
|
914
|
+
if (!review)
|
|
915
|
+
throw new NotFoundError(`Review not found: ${id}`);
|
|
916
|
+
const turns = this.reviewTurns.get(id) ?? [];
|
|
917
|
+
const comments = turns
|
|
918
|
+
.filter((t) => (t.turn_type === "human_comment" ||
|
|
919
|
+
t.turn_type === "human_reject" ||
|
|
920
|
+
t.turn_type === "human_question") &&
|
|
921
|
+
(t.body ?? "").trim() !== "")
|
|
922
|
+
.map((t) => ({
|
|
923
|
+
turn_id: t.id,
|
|
924
|
+
actor_kind: t.actor_kind,
|
|
925
|
+
body: t.body ?? "",
|
|
926
|
+
actor_id: t.actor_id,
|
|
927
|
+
created_at: t.created_at,
|
|
928
|
+
}));
|
|
929
|
+
let diffJson;
|
|
930
|
+
for (const t of turns) {
|
|
931
|
+
if ((t.turn_type === "human_edit" || t.turn_type === "system_diff") && t.diff_json) {
|
|
932
|
+
diffJson = t.diff_json;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
const edited = !!review.sent_body_text || !!review.sent_subject || !!review.diff_unified;
|
|
936
|
+
let decision;
|
|
937
|
+
if (review.state === "sent" || review.state === "auto_sent" || review.state === "approved") {
|
|
938
|
+
decision = edited ? "edited" : "approved";
|
|
939
|
+
}
|
|
940
|
+
else if (review.state === "rejected") {
|
|
941
|
+
decision = "rejected";
|
|
942
|
+
}
|
|
943
|
+
else {
|
|
944
|
+
decision = review.state;
|
|
945
|
+
}
|
|
946
|
+
const newRules = [...this.rules.values()]
|
|
947
|
+
.filter((r) => r.source_review_id === id)
|
|
948
|
+
.map((r) => r.id);
|
|
949
|
+
return {
|
|
950
|
+
review_id: id,
|
|
951
|
+
decision,
|
|
952
|
+
diff_unified: review.diff_unified,
|
|
953
|
+
diff_json: diffJson,
|
|
954
|
+
comments,
|
|
955
|
+
new_rules: newRules,
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Post a chat turn on a review's thread (mock; M5): append an agent_question turn,
|
|
960
|
+
* flip in_review -> chatting on the first turn, enqueue a feedback_added event.
|
|
961
|
+
* Idempotent-key dedup is the transport's concern (replayed there); the mock just
|
|
962
|
+
* appends one turn per call.
|
|
963
|
+
*/
|
|
964
|
+
postReviewChat(input) {
|
|
965
|
+
const review = this.reviews.get(input.id);
|
|
966
|
+
if (!review)
|
|
967
|
+
throw new NotFoundError(`Review not found: ${input.id}`);
|
|
968
|
+
this.assertMutable(review, "post_review_chat", review.revision);
|
|
969
|
+
// needs_review is legal for an AGENT actor: an agent asking a question does not
|
|
970
|
+
// open the draft or assign a reviewer, so it stays in the queue. (A HUMAN
|
|
971
|
+
// comment on a needs_review draft DOES open it — that asymmetry is the point.)
|
|
972
|
+
if (!["needs_review", "in_review", "chatting"].includes(review.state)) {
|
|
973
|
+
throw new WrongStateError(`post_review_chat is not legal while the draft is in '${review.state}'.`, review);
|
|
974
|
+
}
|
|
975
|
+
const turns = this.reviewTurns.get(input.id) ?? [];
|
|
976
|
+
turns.push({
|
|
977
|
+
id: nextId("turn"),
|
|
978
|
+
seq: turns.length + 1,
|
|
979
|
+
turn_type: "agent_question",
|
|
980
|
+
actor_kind: "agent",
|
|
981
|
+
actor_id: this.agentId,
|
|
982
|
+
body: input.text,
|
|
983
|
+
created_at: new Date().toISOString(),
|
|
984
|
+
});
|
|
985
|
+
this.reviewTurns.set(input.id, turns);
|
|
986
|
+
// in_review -> chatting only. A question posted on a needs_review draft leaves
|
|
987
|
+
// it in needs_review: an agent must not be able to pull a draft out of the
|
|
988
|
+
// human queue (or claim a reviewer) just by asking a question.
|
|
989
|
+
if (review.state === "in_review") {
|
|
990
|
+
review.state = "chatting";
|
|
991
|
+
review.version += 1;
|
|
992
|
+
review.updated_at = new Date().toISOString();
|
|
993
|
+
this.commitReview(review);
|
|
994
|
+
}
|
|
995
|
+
this.enqueueReviewEvent(input.id, "feedback_added", { actor: "agent" });
|
|
996
|
+
return review;
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Post a new agent draft under a parent_revision CAS (mock; M5). A mismatch is a
|
|
1000
|
+
* 409 STALE with NO mutation (D17); a clean CAS re-renders the draft in place
|
|
1001
|
+
* (revision++), returns to needs_review, and enqueues a redraft_requested event.
|
|
1002
|
+
*/
|
|
1003
|
+
submitRevision(input) {
|
|
1004
|
+
const review = this.reviews.get(input.id);
|
|
1005
|
+
if (!review)
|
|
1006
|
+
throw new NotFoundError(`Review not found: ${input.id}`);
|
|
1007
|
+
// TERMINAL first: nothing will ever move this row, so this must never be
|
|
1008
|
+
// retried — a distinct code from the stale CAS below, which MUST be.
|
|
1009
|
+
this.assertMutable(review, "submit_revision", input.parent_revision);
|
|
1010
|
+
// needs_review is a LEGAL source: the reviewer-reject, born-stale and
|
|
1011
|
+
// recheck_category paths all hand the composer a needs_review draft and nudge
|
|
1012
|
+
// it to redraft. A blanket self-edge ban used to deadlock exactly those loops.
|
|
1013
|
+
if (!["needs_review", "in_review", "chatting", "rejected", "stale", "stalled"].includes(review.state)) {
|
|
1014
|
+
throw new WrongStateError(`submit_revision is not legal while the draft is in '${review.state}'. Read the allowed_action hints and ` +
|
|
1015
|
+
"pick a legal verb — do NOT retry this one.", review);
|
|
1016
|
+
}
|
|
1017
|
+
if (review.revision !== input.parent_revision) {
|
|
1018
|
+
// STALE, not wrong_state: the draft is still live and the retry IS the fix —
|
|
1019
|
+
// re-read, re-apply your edit on top of theirs, resubmit with the new
|
|
1020
|
+
// parent_revision. Bounded (<=3): the human always wins (D17).
|
|
1021
|
+
throw new StaleError("parent_revision is stale; re-read the review and retry", review);
|
|
1022
|
+
}
|
|
1023
|
+
if (input.version !== undefined && review.version !== input.version) {
|
|
1024
|
+
throw new StaleError("version is stale; re-read the review and retry", review);
|
|
1025
|
+
}
|
|
1026
|
+
review.revision += 1;
|
|
1027
|
+
review.version += 1;
|
|
1028
|
+
review.state = "needs_review";
|
|
1029
|
+
if (input.subject !== undefined)
|
|
1030
|
+
review.proposed_subject = input.subject;
|
|
1031
|
+
// `text` is canonical; `body` is the deprecated alias. Both-but-different is a
|
|
1032
|
+
// caller bug the server rejects rather than guessing which bytes to relay.
|
|
1033
|
+
if (input.text !== undefined && input.body !== undefined && input.text !== input.body) {
|
|
1034
|
+
throw new ConflictingAliasError("`body` is a deprecated alias for `text`; send one or the other");
|
|
1035
|
+
}
|
|
1036
|
+
const newText = input.text ?? input.body;
|
|
1037
|
+
if (newText !== undefined)
|
|
1038
|
+
review.proposed_body_text = newText;
|
|
1039
|
+
if (input.html !== undefined)
|
|
1040
|
+
review.proposed_body_html = input.html;
|
|
1041
|
+
review.updated_at = new Date().toISOString();
|
|
1042
|
+
this.commitReview(review);
|
|
1043
|
+
const turns = this.reviewTurns.get(input.id) ?? [];
|
|
1044
|
+
turns.push({
|
|
1045
|
+
id: nextId("turn"),
|
|
1046
|
+
seq: turns.length + 1,
|
|
1047
|
+
turn_type: "agent_draft",
|
|
1048
|
+
actor_kind: "agent",
|
|
1049
|
+
actor_id: this.agentId,
|
|
1050
|
+
body: newText ?? review.proposed_body_text,
|
|
1051
|
+
revision: review.revision,
|
|
1052
|
+
created_at: new Date().toISOString(),
|
|
1053
|
+
});
|
|
1054
|
+
this.reviewTurns.set(input.id, turns);
|
|
1055
|
+
this.enqueueReviewEvent(input.id, "redraft_requested");
|
|
1056
|
+
return review;
|
|
1057
|
+
}
|
|
1058
|
+
/** Withdraw a pending review (mock; M5) to the terminal cancelled state. */
|
|
1059
|
+
cancelReview(id) {
|
|
1060
|
+
const review = this.reviews.get(id);
|
|
1061
|
+
if (!review)
|
|
1062
|
+
throw new NotFoundError(`Review not found: ${id}`);
|
|
1063
|
+
this.assertMutable(review, "cancel_review", review.revision);
|
|
1064
|
+
if (review.state === "approved") {
|
|
1065
|
+
throw new WrongStateError("cancel_review is not legal while the draft is in 'approved': it has been approved and is being delivered. " +
|
|
1066
|
+
"Wait for a `sent` or `send_failed` review event.", review);
|
|
1067
|
+
}
|
|
1068
|
+
review.state = "cancelled";
|
|
1069
|
+
review.version += 1;
|
|
1070
|
+
review.updated_at = new Date().toISOString();
|
|
1071
|
+
this.commitReview(review);
|
|
1072
|
+
this.enqueueTerminalNudge(review, "cancelled");
|
|
1073
|
+
return review;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Re-stamp a draft's rules-version WITHOUT redrafting (mock; D19/§8 $0 escape valve).
|
|
1077
|
+
* Advances the version the draft is current against; no revision bump, no body change.
|
|
1078
|
+
* A terminal/approved draft 409s; against_version < 0 is invalid.
|
|
1079
|
+
*/
|
|
1080
|
+
restampReview(input) {
|
|
1081
|
+
const review = this.reviews.get(input.id);
|
|
1082
|
+
if (!review)
|
|
1083
|
+
throw new NotFoundError(`Review not found: ${input.id}`);
|
|
1084
|
+
this.assertMutable(review, "restamp_review", review.revision);
|
|
1085
|
+
if (!["needs_review", "in_review", "chatting", "stale"].includes(review.state)) {
|
|
1086
|
+
throw new WrongStateError(`restamp_review applies only to a draft still sitting in the human queue (got '${review.state}').`, review);
|
|
1087
|
+
}
|
|
1088
|
+
if (input.against_version < 0) {
|
|
1089
|
+
throw new ConflictError("against_version must be >= 0");
|
|
1090
|
+
}
|
|
1091
|
+
review.version += 1;
|
|
1092
|
+
review.updated_at = new Date().toISOString();
|
|
1093
|
+
this.commitReview(review);
|
|
1094
|
+
return review;
|
|
1095
|
+
}
|
|
1096
|
+
// ---- BYO review-agent decision plane (M8 Slice B; D5/§9) ---------------
|
|
1097
|
+
//
|
|
1098
|
+
// The mock single-agent store acts as BOTH composer and reviewer, so it surfaces
|
|
1099
|
+
// the reviewer decision surface + the two circuit breakers without modeling the link
|
|
1100
|
+
// table. hop_count is tracked in a side map (the wire Review shape doesn't carry it);
|
|
1101
|
+
// the breakers use the schema defaults (max_hops=3, review_deadline_s=86400). A draft
|
|
1102
|
+
// is "reviewer-held" iff in_review/chatting (the queue states a reviewer can decide).
|
|
1103
|
+
/** Get the reviewer's decision context for a review (mock; §9). */
|
|
1104
|
+
getReviewDecisionContext(id) {
|
|
1105
|
+
const review = this.reviews.get(id);
|
|
1106
|
+
if (!review)
|
|
1107
|
+
throw new NotFoundError(`Review not found: ${id}`);
|
|
1108
|
+
const turns = this.reviewTurns.get(id) ?? [];
|
|
1109
|
+
const hopCount = this.reviewHopCounts.get(id) ?? 0;
|
|
1110
|
+
const maxHops = 3;
|
|
1111
|
+
const deadlineMs = new Date(review.created_at).getTime() + 86400 * 1000;
|
|
1112
|
+
const deadline = new Date(deadlineMs).toISOString();
|
|
1113
|
+
const deadlinePassed = Date.now() >= deadlineMs;
|
|
1114
|
+
const hopsExhausted = hopCount >= maxHops;
|
|
1115
|
+
const forceToHuman = hopsExhausted || deadlinePassed;
|
|
1116
|
+
return {
|
|
1117
|
+
review,
|
|
1118
|
+
turns,
|
|
1119
|
+
hop_count: hopCount,
|
|
1120
|
+
max_hops: maxHops,
|
|
1121
|
+
review_deadline: deadline,
|
|
1122
|
+
deadline_passed: deadlinePassed,
|
|
1123
|
+
hops_exhausted: hopsExhausted,
|
|
1124
|
+
force_to_human: forceToHuman,
|
|
1125
|
+
force_reason: hopsExhausted
|
|
1126
|
+
? "max_hops_reached"
|
|
1127
|
+
: deadlinePassed
|
|
1128
|
+
? "review_deadline_passed"
|
|
1129
|
+
: undefined,
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Submit a reviewer decision (mock; §9). approve/edit → the platform "sends" with the
|
|
1134
|
+
* composer's creds (kind=sent, send_path=reviewer_approved); reject → back to the
|
|
1135
|
+
* composer (needs_review, hop_count++) UNLESS a breaker forces the human; escalate →
|
|
1136
|
+
* the human queue. revision/version are the CAS (409 STALE on mismatch, NO mutation).
|
|
1137
|
+
*/
|
|
1138
|
+
reviewerDecide(input) {
|
|
1139
|
+
const review = this.reviews.get(input.id);
|
|
1140
|
+
if (!review)
|
|
1141
|
+
throw new NotFoundError(`Review not found: ${input.id}`);
|
|
1142
|
+
if (review.state === "sent" || review.state === "auto_sent" || review.state === "cancelled") {
|
|
1143
|
+
throw new ConflictError(`cannot decide a terminal review (${review.state})`);
|
|
1144
|
+
}
|
|
1145
|
+
// needs_review is a legal source: a reviewer (or a human on the console) may
|
|
1146
|
+
// approve/reject straight from the QUEUE without opening the draft first —
|
|
1147
|
+
// needs_review -> approved and needs_review -> rejected are both real edges.
|
|
1148
|
+
if (!["needs_review", "in_review", "chatting"].includes(review.state)) {
|
|
1149
|
+
throw new WrongStateError(`reviewer_decide is not legal while the draft is in '${review.state}'.`, review);
|
|
1150
|
+
}
|
|
1151
|
+
if (review.revision !== input.revision) {
|
|
1152
|
+
throw new ConflictError("revision is stale; re-read the decision context and retry");
|
|
1153
|
+
}
|
|
1154
|
+
if (input.version !== undefined && review.version !== input.version) {
|
|
1155
|
+
throw new ConflictError("version is stale; re-read the decision context and retry");
|
|
1156
|
+
}
|
|
1157
|
+
const hopCount = this.reviewHopCounts.get(input.id) ?? 0;
|
|
1158
|
+
const deadlineMs = new Date(review.created_at).getTime() + 86400 * 1000;
|
|
1159
|
+
const breakerTripped = hopCount >= 3 || Date.now() >= deadlineMs;
|
|
1160
|
+
if (input.action === "approve" || input.action === "edit") {
|
|
1161
|
+
if (input.action === "edit") {
|
|
1162
|
+
if (input.subject !== undefined)
|
|
1163
|
+
review.sent_subject = input.subject;
|
|
1164
|
+
if (input.body !== undefined)
|
|
1165
|
+
review.sent_body_text = input.body;
|
|
1166
|
+
}
|
|
1167
|
+
review.version += 1;
|
|
1168
|
+
review.updated_at = new Date().toISOString();
|
|
1169
|
+
// The approval DISPATCH can fail at the mail boundary, and that failure is
|
|
1170
|
+
// the case a real agent most needs to hear about: before the terminal nudges
|
|
1171
|
+
// existed the composer was never told, because the delivery happened on the
|
|
1172
|
+
// REVIEWER's call, not its own.
|
|
1173
|
+
if (review.proposed_to.some((r) => r.toLowerCase() === SEEDED_SEND_FAILURE_RECIPIENT)) {
|
|
1174
|
+
review.state = "failed";
|
|
1175
|
+
review.send_error = MOCK_SEND_FAILURE_ERROR;
|
|
1176
|
+
this.commitReview(review);
|
|
1177
|
+
this.enqueueTerminalNudge(review, "send_failed");
|
|
1178
|
+
return { kind: "sent_to_human", review, sent: false, sent_to_human: false };
|
|
1179
|
+
}
|
|
1180
|
+
review.state = "sent";
|
|
1181
|
+
review.sent_message_id = nextId("msg");
|
|
1182
|
+
review.send_path = "reviewer_approved";
|
|
1183
|
+
review.sent_at = review.updated_at;
|
|
1184
|
+
this.commitReview(review);
|
|
1185
|
+
// The nudge targets the COMPOSER, not the reviewer: the reviewer already
|
|
1186
|
+
// knows what it decided; the composing agent is the one still waiting.
|
|
1187
|
+
this.enqueueTerminalNudge(review, "sent");
|
|
1188
|
+
return { kind: "sent", review, sent: true, message_id: review.sent_message_id, sent_to_human: false };
|
|
1189
|
+
}
|
|
1190
|
+
// reject / escalate → the human queue (needs_review). A reject within budget bumps
|
|
1191
|
+
// hop_count and goes back to the composer; a tripped breaker FORCES the human.
|
|
1192
|
+
review.state = "needs_review";
|
|
1193
|
+
review.version += 1;
|
|
1194
|
+
if (input.feedback !== undefined)
|
|
1195
|
+
review.decision_feedback = input.feedback;
|
|
1196
|
+
review.updated_at = new Date().toISOString();
|
|
1197
|
+
this.commitReview(review);
|
|
1198
|
+
let forcedByBreaker;
|
|
1199
|
+
if (input.action === "reject" && !breakerTripped) {
|
|
1200
|
+
this.reviewHopCounts.set(input.id, hopCount + 1);
|
|
1201
|
+
}
|
|
1202
|
+
else if (input.action === "reject" && breakerTripped) {
|
|
1203
|
+
forcedByBreaker = hopCount >= 3 ? "max_hops_reached" : "review_deadline_passed";
|
|
1204
|
+
}
|
|
1205
|
+
return { kind: "sent_to_human", review, sent: false, sent_to_human: true, forced_by_breaker: forcedByBreaker };
|
|
1206
|
+
}
|
|
1207
|
+
// ---- Category registry (Review Loop, D9/D10) --------------------------
|
|
1208
|
+
/**
|
|
1209
|
+
* Browse the registry (mock), newest-first, excluding merged/soft-deleted
|
|
1210
|
+
* (merged_into set). `match` is a pure lexical filter (every token must appear in
|
|
1211
|
+
* name+description) — NO LLM, mirroring the server.
|
|
1212
|
+
*/
|
|
1213
|
+
listCategories(match) {
|
|
1214
|
+
let items = [...this.categories.values()]
|
|
1215
|
+
.filter((c) => !c.merged_into)
|
|
1216
|
+
.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
1217
|
+
const tokens = (match ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
1218
|
+
if (tokens.length) {
|
|
1219
|
+
items = items.filter((c) => {
|
|
1220
|
+
const hay = `${c.name} ${c.description}`.toLowerCase();
|
|
1221
|
+
return tokens.every((t) => hay.includes(t));
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
return { items, total: items.length };
|
|
1225
|
+
}
|
|
1226
|
+
/** Get one category (mock); throws NotFound on an unknown id. */
|
|
1227
|
+
getCategory(id) {
|
|
1228
|
+
const cat = this.categories.get(id);
|
|
1229
|
+
if (!cat)
|
|
1230
|
+
throw new NotFoundError(`Category not found: ${id}`);
|
|
1231
|
+
return cat;
|
|
1232
|
+
}
|
|
1233
|
+
/** Propose a category (mock): stands immediately, author_kind=agent. */
|
|
1234
|
+
proposeCategory(input) {
|
|
1235
|
+
const name = input.name.trim();
|
|
1236
|
+
if (!name)
|
|
1237
|
+
throw new IntentRequiredError("name is required");
|
|
1238
|
+
const now = new Date().toISOString();
|
|
1239
|
+
const cat = {
|
|
1240
|
+
id: nextId("cat"),
|
|
1241
|
+
name,
|
|
1242
|
+
description: (input.description ?? "").trim(),
|
|
1243
|
+
scope: input.scope ?? "org_shared",
|
|
1244
|
+
state: "supervised",
|
|
1245
|
+
created_by_agent_id: this.agentId,
|
|
1246
|
+
author_kind: "agent",
|
|
1247
|
+
rule_high_water: 0,
|
|
1248
|
+
rules_version: 0,
|
|
1249
|
+
created_at: now,
|
|
1250
|
+
updated_at: now,
|
|
1251
|
+
};
|
|
1252
|
+
this.categories.set(cat.id, cat);
|
|
1253
|
+
return cat;
|
|
1254
|
+
}
|
|
1255
|
+
/** Rename / re-describe a category (mock) — metadata only (D10). */
|
|
1256
|
+
updateCategory(input) {
|
|
1257
|
+
const cat = this.categories.get(input.id);
|
|
1258
|
+
if (!cat)
|
|
1259
|
+
throw new NotFoundError(`Category not found: ${input.id}`);
|
|
1260
|
+
if (input.name !== undefined)
|
|
1261
|
+
cat.name = input.name.trim();
|
|
1262
|
+
if (input.description !== undefined)
|
|
1263
|
+
cat.description = input.description.trim();
|
|
1264
|
+
cat.updated_at = new Date().toISOString();
|
|
1265
|
+
this.categories.set(cat.id, cat);
|
|
1266
|
+
return cat;
|
|
1267
|
+
}
|
|
1268
|
+
// ---- Graduation + risk dial (Review Loop, D16/D6/D17) — agent READ + PROPOSE --
|
|
1269
|
+
/** The mock account-default risk dial (mirrors the server defaults). */
|
|
1270
|
+
accountDial() {
|
|
1271
|
+
return {
|
|
1272
|
+
min_confidence: 0.7,
|
|
1273
|
+
first_contact_gate: true,
|
|
1274
|
+
drift_demote_after: 3,
|
|
1275
|
+
canary_rate: 0.05,
|
|
1276
|
+
graduate_min_approvals: 20,
|
|
1277
|
+
graduate_min_age_hours: 24,
|
|
1278
|
+
auto_send_cap_per_day: 50,
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Read the effective risk dial (mock): the account default + every category with
|
|
1283
|
+
* an inherited (null override) effective dial. The mock category carries no risk-
|
|
1284
|
+
* dial overrides, so every category inherits — effective == account.
|
|
1285
|
+
*/
|
|
1286
|
+
getRiskDial() {
|
|
1287
|
+
const account = this.accountDial();
|
|
1288
|
+
const categories = [...this.categories.values()]
|
|
1289
|
+
.filter((c) => !c.merged_into)
|
|
1290
|
+
.map((c) => ({
|
|
1291
|
+
category_id: c.id,
|
|
1292
|
+
min_confidence: null,
|
|
1293
|
+
first_contact_gate: null,
|
|
1294
|
+
drift_demote_after: null,
|
|
1295
|
+
canary_rate: null,
|
|
1296
|
+
graduate_min_approvals: null,
|
|
1297
|
+
graduate_min_age_hours: null,
|
|
1298
|
+
effective: { ...account },
|
|
1299
|
+
}));
|
|
1300
|
+
return { account, categories };
|
|
1301
|
+
}
|
|
1302
|
+
/** The next rung up the graduation ladder ("" if none). */
|
|
1303
|
+
nextGraduationState(state) {
|
|
1304
|
+
if (state === "supervised")
|
|
1305
|
+
return "auto_notify";
|
|
1306
|
+
if (state === "auto_notify")
|
|
1307
|
+
return "auto_silent";
|
|
1308
|
+
return "";
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Read a category's graduation gate status (mock). The mock category has no
|
|
1312
|
+
* counters, so it reports zero clean approvals / zero drift against the account
|
|
1313
|
+
* defaults; can_graduate is true for supervised→auto_notify (no maturity gate).
|
|
1314
|
+
*/
|
|
1315
|
+
getGraduationStatus(categoryId) {
|
|
1316
|
+
const cat = this.categories.get(categoryId);
|
|
1317
|
+
if (!cat)
|
|
1318
|
+
throw new NotFoundError(`Category not found: ${categoryId}`);
|
|
1319
|
+
const dial = this.accountDial();
|
|
1320
|
+
const next = this.nextGraduationState(cat.state);
|
|
1321
|
+
const cleanApprovals = 0;
|
|
1322
|
+
const driftCount = 0;
|
|
1323
|
+
const approvalsMet = cleanApprovals >= dial.graduate_min_approvals;
|
|
1324
|
+
const ageMet = false; // a fresh mock category is younger than min_age_hours.
|
|
1325
|
+
const maturityMet = approvalsMet && ageMet;
|
|
1326
|
+
const canGraduate = next === "auto_notify" ? true : next === "auto_silent" ? maturityMet : false;
|
|
1327
|
+
return {
|
|
1328
|
+
category_id: cat.id,
|
|
1329
|
+
state: cat.state,
|
|
1330
|
+
next_state: next,
|
|
1331
|
+
never_graduate: false,
|
|
1332
|
+
clean_approval_count: cleanApprovals,
|
|
1333
|
+
graduate_min_approvals: dial.graduate_min_approvals,
|
|
1334
|
+
approvals_met: approvalsMet,
|
|
1335
|
+
age_hours: 0,
|
|
1336
|
+
graduate_min_age_hours: dial.graduate_min_age_hours,
|
|
1337
|
+
age_met: ageMet,
|
|
1338
|
+
maturity_gate_met: maturityMet,
|
|
1339
|
+
drift_count: driftCount,
|
|
1340
|
+
drift_demote_after: dial.drift_demote_after,
|
|
1341
|
+
can_graduate: canGraduate,
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Propose graduating a category (mock): records nothing mutating and returns the
|
|
1346
|
+
* current gate status. It does NOT flip the bit (D16) — the category state is
|
|
1347
|
+
* unchanged.
|
|
1348
|
+
*/
|
|
1349
|
+
proposeGraduation(categoryId, _evidence) {
|
|
1350
|
+
return this.getGraduationStatus(categoryId);
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Read the D19/§8 backlog-reconciliation status (mock): counts the QUEUED drafts in a
|
|
1354
|
+
* category that are stale vs current-enough against the current rules-version. The
|
|
1355
|
+
* mock has no per-draft composed_* stamps, so every queued draft reads current-enough;
|
|
1356
|
+
* the contract shape is exercised (the integer-compare is covered by the Go tests).
|
|
1357
|
+
*/
|
|
1358
|
+
getBacklogStatus(categoryId) {
|
|
1359
|
+
const cat = this.categories.get(categoryId);
|
|
1360
|
+
if (!cat)
|
|
1361
|
+
throw new NotFoundError(`Category not found: ${categoryId}`);
|
|
1362
|
+
let queued = 0;
|
|
1363
|
+
for (const review of this.reviews.values()) {
|
|
1364
|
+
if (review.category_id === categoryId &&
|
|
1365
|
+
(review.state === "needs_review" || review.state === "in_review" || review.state === "chatting")) {
|
|
1366
|
+
queued += 1;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
return {
|
|
1370
|
+
category_id: cat.id,
|
|
1371
|
+
state: cat.state,
|
|
1372
|
+
queued,
|
|
1373
|
+
current_enough: queued,
|
|
1374
|
+
stale: 0,
|
|
1375
|
+
current_category_rules_version: cat.rules_version,
|
|
1376
|
+
current_house_style_version: 0,
|
|
1377
|
+
staleness_tolerance: 0,
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Read the demand-driven pacing state (mock — M7 Slice B/§8): the cursor + effective
|
|
1382
|
+
* window/ceiling/interval + each queued draft's classification. The mock has no cursor
|
|
1383
|
+
* (nothing reviewed) and no composed_* stamps, so every queued draft reads in-window-
|
|
1384
|
+
* fresh until the window fills, then ahead; the contract shape is exercised (the
|
|
1385
|
+
* cursor/staleness mechanics are covered by the Go tests).
|
|
1386
|
+
*/
|
|
1387
|
+
getPacingState(categoryId) {
|
|
1388
|
+
const cat = this.categories.get(categoryId);
|
|
1389
|
+
if (!cat)
|
|
1390
|
+
throw new NotFoundError(`Category not found: ${categoryId}`);
|
|
1391
|
+
const lookaheadWindow = 3;
|
|
1392
|
+
const queuedReviews = [];
|
|
1393
|
+
for (const review of this.reviews.values()) {
|
|
1394
|
+
if (review.category_id === categoryId &&
|
|
1395
|
+
(review.state === "needs_review" || review.state === "in_review" || review.state === "chatting")) {
|
|
1396
|
+
queuedReviews.push({ id: review.id });
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
const items = queuedReviews.map((r, i) => ({
|
|
1400
|
+
review_id: r.id,
|
|
1401
|
+
state: i < lookaheadWindow ? "in_window_fresh" : "ahead",
|
|
1402
|
+
}));
|
|
1403
|
+
return {
|
|
1404
|
+
category_id: cat.id,
|
|
1405
|
+
cursor_advanced_count: 0,
|
|
1406
|
+
lookahead_window: lookaheadWindow,
|
|
1407
|
+
rework_batch_max: 10,
|
|
1408
|
+
nudge_min_interval_ms: 5000,
|
|
1409
|
+
queued: queuedReviews.length,
|
|
1410
|
+
in_window: Math.min(queuedReviews.length, lookaheadWindow),
|
|
1411
|
+
redrafting: 0,
|
|
1412
|
+
items,
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
// ---- Writing rules + house-style + precedence ladder + audit/undo ------
|
|
1416
|
+
/** rank a rule for the §7 ladder (mock mirror of the server's deterministic order). */
|
|
1417
|
+
ruleRank(r) {
|
|
1418
|
+
const hard = r.kind === "hard" ? 1 : 0;
|
|
1419
|
+
const spec = r.scope_agent_id ? 2 : r.scope === "category" ? 1 : 0;
|
|
1420
|
+
const human = r.author_kind === "human" ? 1 : 0;
|
|
1421
|
+
return [hard, spec, human, r.rev, r.priority];
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Get the ORDERED active rule set (mock). Applies the §7 precedence ladder and the
|
|
1425
|
+
* category-before-general concatenation, mirroring the server (NO LLM).
|
|
1426
|
+
*/
|
|
1427
|
+
getRules(input = {}) {
|
|
1428
|
+
const active = [...this.rules.values()].filter((r) => r.status === "active");
|
|
1429
|
+
const byRank = (a, b) => {
|
|
1430
|
+
const ra = this.ruleRank(a);
|
|
1431
|
+
const rb = this.ruleRank(b);
|
|
1432
|
+
for (let i = 0; i < ra.length; i += 1) {
|
|
1433
|
+
const da = ra[i] ?? 0;
|
|
1434
|
+
const db = rb[i] ?? 0;
|
|
1435
|
+
if (da !== db)
|
|
1436
|
+
return db - da;
|
|
1437
|
+
}
|
|
1438
|
+
return a.id < b.id ? -1 : 1;
|
|
1439
|
+
};
|
|
1440
|
+
let general = [];
|
|
1441
|
+
let category = [];
|
|
1442
|
+
if (!input.scope || input.scope === "general") {
|
|
1443
|
+
general = active.filter((r) => r.scope === "general").sort(byRank);
|
|
1444
|
+
}
|
|
1445
|
+
if (input.category_id && (!input.scope || input.scope === "category")) {
|
|
1446
|
+
category = active.filter((r) => r.scope === "category" && r.category_id === input.category_id).sort(byRank);
|
|
1447
|
+
}
|
|
1448
|
+
const items = [...category, ...general];
|
|
1449
|
+
return { items, total: items.length };
|
|
1450
|
+
}
|
|
1451
|
+
/** Save / edit a rule (mock) — append-only by supersession (D11). */
|
|
1452
|
+
saveRule(input) {
|
|
1453
|
+
const text = input.rule_text.trim();
|
|
1454
|
+
if (!text)
|
|
1455
|
+
throw new IntentRequiredError("rule_text is required");
|
|
1456
|
+
const categoryId = (input.category_id ?? "").trim();
|
|
1457
|
+
const scope = input.scope ?? (categoryId ? "category" : "general");
|
|
1458
|
+
if (scope === "general" && categoryId)
|
|
1459
|
+
throw new ConflictError("scope=general forbids a category_id");
|
|
1460
|
+
if (scope === "category" && !categoryId)
|
|
1461
|
+
throw new ConflictError("scope=category requires a category_id");
|
|
1462
|
+
const now = new Date().toISOString();
|
|
1463
|
+
if (input.supersedes_id) {
|
|
1464
|
+
const prior = this.rules.get(input.supersedes_id);
|
|
1465
|
+
if (!prior)
|
|
1466
|
+
throw new NotFoundError(`Rule not found: ${input.supersedes_id}`);
|
|
1467
|
+
prior.status = "superseded";
|
|
1468
|
+
this.rules.set(prior.id, prior);
|
|
1469
|
+
const next = {
|
|
1470
|
+
...prior,
|
|
1471
|
+
id: nextId("rule"),
|
|
1472
|
+
rev: prior.rev + 1,
|
|
1473
|
+
rule_text: text,
|
|
1474
|
+
kind: input.kind ?? prior.kind,
|
|
1475
|
+
priority: input.priority ?? prior.priority,
|
|
1476
|
+
status: "active",
|
|
1477
|
+
supersedes_id: prior.id,
|
|
1478
|
+
author_kind: "agent",
|
|
1479
|
+
created_at: now,
|
|
1480
|
+
updated_at: now,
|
|
1481
|
+
};
|
|
1482
|
+
this.rules.set(next.id, next);
|
|
1483
|
+
this.recordRuleAudit("supersede", next.id, ruleSnapshotJSON(prior), ruleSnapshotJSON(next));
|
|
1484
|
+
return next;
|
|
1485
|
+
}
|
|
1486
|
+
const rule = {
|
|
1487
|
+
id: nextId("rule"),
|
|
1488
|
+
// Agent-plane saves are ALWAYS project-layer, bound to the key's project;
|
|
1489
|
+
// creating org-layer/house-style rules is console/admin-only in v1.
|
|
1490
|
+
rule_layer: "project",
|
|
1491
|
+
org_id: MOCK_ORG_ID,
|
|
1492
|
+
project_id: MOCK_PROJECT_ID,
|
|
1493
|
+
lineage_id: nextId("rln"),
|
|
1494
|
+
rev: 1,
|
|
1495
|
+
scope,
|
|
1496
|
+
category_id: scope === "category" ? categoryId : undefined,
|
|
1497
|
+
scope_agent_id: input.scope_agent_id || undefined,
|
|
1498
|
+
rule_text: text,
|
|
1499
|
+
kind: input.kind ?? "soft",
|
|
1500
|
+
priority: input.priority ?? 0,
|
|
1501
|
+
status: "active",
|
|
1502
|
+
author_kind: "agent",
|
|
1503
|
+
created_at: now,
|
|
1504
|
+
updated_at: now,
|
|
1505
|
+
};
|
|
1506
|
+
this.rules.set(rule.id, rule);
|
|
1507
|
+
this.recordRuleAudit("create", rule.id, "{}", ruleSnapshotJSON(rule));
|
|
1508
|
+
return rule;
|
|
1509
|
+
}
|
|
1510
|
+
/** Promote a rule between the category and general layers (mock, via supersession). */
|
|
1511
|
+
promoteRule(id, toScope) {
|
|
1512
|
+
const prior = this.rules.get(id);
|
|
1513
|
+
if (!prior)
|
|
1514
|
+
throw new NotFoundError(`Rule not found: ${id}`);
|
|
1515
|
+
if (prior.scope === toScope)
|
|
1516
|
+
return prior;
|
|
1517
|
+
if (toScope === "category" && !prior.category_id)
|
|
1518
|
+
throw new ConflictError("promote to category needs a category");
|
|
1519
|
+
prior.status = "superseded";
|
|
1520
|
+
this.rules.set(prior.id, prior);
|
|
1521
|
+
const now = new Date().toISOString();
|
|
1522
|
+
const next = {
|
|
1523
|
+
...prior,
|
|
1524
|
+
id: nextId("rule"),
|
|
1525
|
+
lineage_id: nextId("rln"),
|
|
1526
|
+
rev: 1,
|
|
1527
|
+
scope: toScope,
|
|
1528
|
+
category_id: toScope === "category" ? prior.category_id : undefined,
|
|
1529
|
+
status: "active",
|
|
1530
|
+
supersedes_id: prior.id,
|
|
1531
|
+
author_kind: "agent",
|
|
1532
|
+
created_at: now,
|
|
1533
|
+
updated_at: now,
|
|
1534
|
+
};
|
|
1535
|
+
this.rules.set(next.id, next);
|
|
1536
|
+
this.recordRuleAudit("supersede", next.id, ruleSnapshotJSON(prior), ruleSnapshotJSON(next));
|
|
1537
|
+
return next;
|
|
1538
|
+
}
|
|
1539
|
+
/** Retire a rule (mock) — soft delete; history survives. */
|
|
1540
|
+
retireRule(id) {
|
|
1541
|
+
const rule = this.rules.get(id);
|
|
1542
|
+
if (!rule)
|
|
1543
|
+
throw new NotFoundError(`Rule not found: ${id}`);
|
|
1544
|
+
if (rule.status === "retired")
|
|
1545
|
+
return rule;
|
|
1546
|
+
rule.status = "retired";
|
|
1547
|
+
rule.updated_at = new Date().toISOString();
|
|
1548
|
+
this.rules.set(rule.id, rule);
|
|
1549
|
+
this.recordRuleAudit("retire", rule.id, ruleSnapshotJSON(rule), ruleSnapshotJSON(rule));
|
|
1550
|
+
return rule;
|
|
1551
|
+
}
|
|
1552
|
+
/** Read the rule/category change audit log (mock). */
|
|
1553
|
+
getRuleAudit(input = {}) {
|
|
1554
|
+
let items = [...this.ruleAudit.values()].sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
1555
|
+
if (input.entity_kind)
|
|
1556
|
+
items = items.filter((e) => e.entity_kind === input.entity_kind);
|
|
1557
|
+
if (input.entity_id)
|
|
1558
|
+
items = items.filter((e) => e.entity_id === input.entity_id);
|
|
1559
|
+
return { items, total: items.length };
|
|
1560
|
+
}
|
|
1561
|
+
/** Undo a rule change (mock) — restore the prior version; idempotent (re-undo 409). */
|
|
1562
|
+
undoRuleChange(udoId) {
|
|
1563
|
+
const entry = this.ruleAudit.get(udoId);
|
|
1564
|
+
if (!entry)
|
|
1565
|
+
throw new NotFoundError(`Audit row not found: ${udoId}`);
|
|
1566
|
+
if (entry.entity_kind !== "rule")
|
|
1567
|
+
throw new ConflictError("only rule entities are restorable here");
|
|
1568
|
+
if (entry.undone)
|
|
1569
|
+
throw new ConflictError("already undone");
|
|
1570
|
+
const head = this.rules.get(entry.entity_id);
|
|
1571
|
+
if (!head)
|
|
1572
|
+
throw new NotFoundError(`Rule head not found: ${entry.entity_id}`);
|
|
1573
|
+
entry.undone = true;
|
|
1574
|
+
this.ruleAudit.set(entry.id, entry);
|
|
1575
|
+
const before = JSON.parse(entry.before_json ?? "{}");
|
|
1576
|
+
head.status = "superseded";
|
|
1577
|
+
this.rules.set(head.id, head);
|
|
1578
|
+
const now = new Date().toISOString();
|
|
1579
|
+
const restored = {
|
|
1580
|
+
...head,
|
|
1581
|
+
id: nextId("rule"),
|
|
1582
|
+
rev: head.rev + 1,
|
|
1583
|
+
rule_text: before.rule_text ?? head.rule_text,
|
|
1584
|
+
status: before.rule_text ? "active" : "retired",
|
|
1585
|
+
supersedes_id: head.id,
|
|
1586
|
+
created_at: now,
|
|
1587
|
+
updated_at: now,
|
|
1588
|
+
};
|
|
1589
|
+
this.rules.set(restored.id, restored);
|
|
1590
|
+
this.recordRuleAudit("restore", restored.id, ruleSnapshotJSON(head), ruleSnapshotJSON(restored));
|
|
1591
|
+
return restored;
|
|
1592
|
+
}
|
|
1593
|
+
/** recordRuleAudit appends one change/undo audit row (mock). */
|
|
1594
|
+
recordRuleAudit(action, entityId, before, after) {
|
|
1595
|
+
const entry = {
|
|
1596
|
+
id: nextId("udo"),
|
|
1597
|
+
entity_kind: "rule",
|
|
1598
|
+
entity_id: entityId,
|
|
1599
|
+
action,
|
|
1600
|
+
actor_kind: "agent",
|
|
1601
|
+
actor_id: `agent:${this.agentId}`,
|
|
1602
|
+
before_json: before,
|
|
1603
|
+
after_json: after,
|
|
1604
|
+
undone: false,
|
|
1605
|
+
created_at: new Date().toISOString(),
|
|
1606
|
+
};
|
|
1607
|
+
this.ruleAudit.set(entry.id, entry);
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* enqueueReviewEvent appends a durable nudge for a review with the next
|
|
1611
|
+
* per-review monotonic seq (mock mirror of the server's enqueue-on-transition).
|
|
1612
|
+
* Used by the seed + (in a fuller mock) by transition handlers.
|
|
1613
|
+
*/
|
|
1614
|
+
enqueueReviewEvent(reviewId, reason, payload) {
|
|
1615
|
+
const list = this.reviewEvents.get(reviewId) ?? [];
|
|
1616
|
+
const last = list[list.length - 1];
|
|
1617
|
+
const seqNo = last ? last.seq + 1 : 1;
|
|
1618
|
+
const review = this.reviews.get(reviewId);
|
|
1619
|
+
const ev = {
|
|
1620
|
+
seq: seqNo,
|
|
1621
|
+
id: nextId("ndg"),
|
|
1622
|
+
reason,
|
|
1623
|
+
review_id: reviewId,
|
|
1624
|
+
category_id: review?.category_id,
|
|
1625
|
+
payload,
|
|
1626
|
+
created_at: new Date().toISOString(),
|
|
1627
|
+
};
|
|
1628
|
+
list.push(ev);
|
|
1629
|
+
this.reviewEvents.set(reviewId, list);
|
|
1630
|
+
return ev;
|
|
1631
|
+
}
|
|
1632
|
+
/** Drain the next un-acked review events (mock), FIFO per review + cursors. */
|
|
1633
|
+
listReviewEvents(input = {}) {
|
|
1634
|
+
const want = input.review_id?.trim();
|
|
1635
|
+
const events = [];
|
|
1636
|
+
const cursors = [];
|
|
1637
|
+
const touched = new Set();
|
|
1638
|
+
for (const [reviewId, list] of this.reviewEvents) {
|
|
1639
|
+
if (want && reviewId !== want)
|
|
1640
|
+
continue;
|
|
1641
|
+
const acked = this.reviewEventCursors.get(reviewId) ?? 0;
|
|
1642
|
+
for (const ev of list) {
|
|
1643
|
+
if (ev.seq > acked)
|
|
1644
|
+
events.push(ev);
|
|
1645
|
+
}
|
|
1646
|
+
if (list.some((ev) => ev.seq > acked))
|
|
1647
|
+
touched.add(reviewId);
|
|
1648
|
+
}
|
|
1649
|
+
// Stable FIFO order: by review id, then seq (best-effort across reviews).
|
|
1650
|
+
events.sort((a, b) => (a.review_id ?? "").localeCompare(b.review_id ?? "") || a.seq - b.seq);
|
|
1651
|
+
const limited = input.limit && input.limit > 0 ? events.slice(0, input.limit) : events;
|
|
1652
|
+
for (const reviewId of touched) {
|
|
1653
|
+
cursors.push({ review_id: reviewId, last_acked_seq: this.reviewEventCursors.get(reviewId) ?? 0 });
|
|
1654
|
+
}
|
|
1655
|
+
return { events: limited, cursors };
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Long-poll for a review event (mock). Offline there is nothing to wait FOR, so
|
|
1659
|
+
* it returns the immediate drain (empty when caught up) — matching the server's
|
|
1660
|
+
* "empty on timeout" contract.
|
|
1661
|
+
*/
|
|
1662
|
+
waitForReviewEvent(input = {}) {
|
|
1663
|
+
return this.listReviewEvents({ review_id: input.review_id, limit: input.limit });
|
|
1664
|
+
}
|
|
1665
|
+
/** Ack review events (mock): advance per-review cursors monotonically. */
|
|
1666
|
+
ackReviewEvent(input) {
|
|
1667
|
+
const cursors = [];
|
|
1668
|
+
for (const a of input.acks ?? []) {
|
|
1669
|
+
const reviewId = a.review_id?.trim();
|
|
1670
|
+
if (!reviewId)
|
|
1671
|
+
continue;
|
|
1672
|
+
const prev = this.reviewEventCursors.get(reviewId) ?? 0;
|
|
1673
|
+
const next = Math.max(prev, a.through_seq); // monotonic (exactly-once effect)
|
|
1674
|
+
this.reviewEventCursors.set(reviewId, next);
|
|
1675
|
+
cursors.push({ review_id: reviewId, last_acked_seq: next });
|
|
1676
|
+
}
|
|
1677
|
+
return { cursors };
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* createReviewRecord mints a needs_review row + the intent (agent_note) and
|
|
1681
|
+
* initial-draft (agent_draft) turns, mirroring the server's submit-time writes.
|
|
1682
|
+
*/
|
|
1683
|
+
createReviewRecord(opts) {
|
|
1684
|
+
const id = nextId("rr");
|
|
1685
|
+
const now = new Date().toISOString();
|
|
1686
|
+
const review = {
|
|
1687
|
+
id,
|
|
1688
|
+
state: "needs_review",
|
|
1689
|
+
mode: opts.mode ?? "review",
|
|
1690
|
+
effective_mode: opts.mode ?? "review",
|
|
1691
|
+
kind: opts.kind,
|
|
1692
|
+
from_address: opts.fromAddress,
|
|
1693
|
+
agent_id: this.agentId,
|
|
1694
|
+
category_id: opts.categoryId,
|
|
1695
|
+
intent_summary: opts.intent?.summary ?? "",
|
|
1696
|
+
intent_meta: opts.intent?.meta,
|
|
1697
|
+
revision: 0,
|
|
1698
|
+
version: 0,
|
|
1699
|
+
proposed_subject: opts.subject,
|
|
1700
|
+
proposed_body_text: opts.text,
|
|
1701
|
+
proposed_body_html: opts.html,
|
|
1702
|
+
proposed_to: opts.to,
|
|
1703
|
+
proposed_cc: opts.cc,
|
|
1704
|
+
proposed_bcc: opts.bcc,
|
|
1705
|
+
created_at: now,
|
|
1706
|
+
updated_at: now,
|
|
1707
|
+
};
|
|
1708
|
+
if (opts.replyThreadId)
|
|
1709
|
+
this.reviewThreads.set(id, opts.replyThreadId);
|
|
1710
|
+
if (opts.replyParentId)
|
|
1711
|
+
this.reviewParents.set(id, opts.replyParentId);
|
|
1712
|
+
this.commitReview(review);
|
|
1713
|
+
const turns = [];
|
|
1714
|
+
if (opts.intent?.summary?.trim()) {
|
|
1715
|
+
turns.push({
|
|
1716
|
+
id: nextId("turn"),
|
|
1717
|
+
seq: turns.length + 1,
|
|
1718
|
+
turn_type: "agent_note",
|
|
1719
|
+
actor_kind: "agent",
|
|
1720
|
+
actor_id: this.agentId,
|
|
1721
|
+
body: opts.intent.summary,
|
|
1722
|
+
metadata: { kind: "intent" },
|
|
1723
|
+
created_at: now,
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
turns.push({
|
|
1727
|
+
id: nextId("turn"),
|
|
1728
|
+
seq: turns.length + 1,
|
|
1729
|
+
turn_type: "agent_draft",
|
|
1730
|
+
actor_kind: "agent",
|
|
1731
|
+
actor_id: this.agentId,
|
|
1732
|
+
body: opts.text,
|
|
1733
|
+
revision: 0,
|
|
1734
|
+
created_at: now,
|
|
1735
|
+
});
|
|
1736
|
+
this.reviewTurns.set(id, turns);
|
|
1737
|
+
return review;
|
|
1738
|
+
}
|
|
1739
|
+
listMessages(opts) {
|
|
1740
|
+
const inbox = this.requireInbox(opts.inbox);
|
|
1741
|
+
let items = [...(this.messages.get(inbox.id) ?? [])].sort((a, b) => b.date.localeCompare(a.date));
|
|
1742
|
+
if (opts.unreadOnly)
|
|
1743
|
+
items = items.filter((m) => !m.seen);
|
|
1744
|
+
if (opts.from) {
|
|
1745
|
+
const q = opts.from.toLowerCase();
|
|
1746
|
+
items = items.filter((m) => m.from.email.toLowerCase().includes(q));
|
|
1747
|
+
}
|
|
1748
|
+
if (opts.to) {
|
|
1749
|
+
const q = opts.to.toLowerCase();
|
|
1750
|
+
items = items.filter((m) => m.to.some((a) => a.email.toLowerCase().includes(q)));
|
|
1751
|
+
}
|
|
1752
|
+
if (opts.subject) {
|
|
1753
|
+
const q = opts.subject.toLowerCase();
|
|
1754
|
+
items = items.filter((m) => m.subject.toLowerCase().includes(q));
|
|
1755
|
+
}
|
|
1756
|
+
const total = items.length;
|
|
1757
|
+
const offset = opts.offset ?? 0;
|
|
1758
|
+
const page = items.slice(offset, offset + (opts.limit ?? 20));
|
|
1759
|
+
const result = { items: page, total };
|
|
1760
|
+
if (offset + page.length < total)
|
|
1761
|
+
result.next_cursor = String(offset + page.length);
|
|
1762
|
+
return result;
|
|
1763
|
+
}
|
|
1764
|
+
/** Fetch a single message by id across all inboxes (mirrors GET /v1/messages/{id}). */
|
|
1765
|
+
getMessage(id) {
|
|
1766
|
+
for (const msgs of this.messages.values()) {
|
|
1767
|
+
const found = msgs.find((m) => m.id === id);
|
|
1768
|
+
if (found)
|
|
1769
|
+
return found;
|
|
1770
|
+
}
|
|
1771
|
+
throw new NotFoundError(`Message not found: ${id}`);
|
|
1772
|
+
}
|
|
1773
|
+
/** Toggle the \Seen flag for a message by id (mirrors PATCH .../messages/{id}). */
|
|
1774
|
+
markRead(id, read) {
|
|
1775
|
+
const msg = this.getMessage(id);
|
|
1776
|
+
msg.seen = read;
|
|
1777
|
+
return msg;
|
|
1778
|
+
}
|
|
1779
|
+
listThreads(opts) {
|
|
1780
|
+
const inbox = this.requireInbox(opts.inbox);
|
|
1781
|
+
const byThread = new Map();
|
|
1782
|
+
for (const m of this.messages.get(inbox.id) ?? []) {
|
|
1783
|
+
const arr = byThread.get(m.thread_id) ?? [];
|
|
1784
|
+
arr.push(m);
|
|
1785
|
+
byThread.set(m.thread_id, arr);
|
|
1786
|
+
}
|
|
1787
|
+
const threads = [...byThread.entries()].map(([id, msgs]) => this.toThread(inbox.address, id, msgs));
|
|
1788
|
+
threads.sort((a, b) => b.last_message_at.localeCompare(a.last_message_at));
|
|
1789
|
+
return { items: threads.slice(0, opts.limit ?? 20), total: threads.length };
|
|
1790
|
+
}
|
|
1791
|
+
/** Fetch one thread (with its messages, oldest-first) by id under an inbox. */
|
|
1792
|
+
getThread(idOrAddress, threadId) {
|
|
1793
|
+
const inbox = this.requireInbox(idOrAddress);
|
|
1794
|
+
const msgs = (this.messages.get(inbox.id) ?? [])
|
|
1795
|
+
.filter((m) => m.thread_id === threadId)
|
|
1796
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
1797
|
+
if (msgs.length === 0)
|
|
1798
|
+
throw new NotFoundError(`Thread not found: ${threadId}`);
|
|
1799
|
+
return { ...this.toThread(inbox.address, threadId, msgs), messages: msgs };
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* Delete a message by id (mirrors DELETE .../messages/{id}). The mock moves the
|
|
1803
|
+
* message to a Trash folder (soft delete) or removes it outright when expunge is
|
|
1804
|
+
* set or it already lives in Trash. Throws NotFoundError when absent.
|
|
1805
|
+
*/
|
|
1806
|
+
deleteMessage(idOrAddress, id, expunge) {
|
|
1807
|
+
const inbox = this.requireInbox(idOrAddress);
|
|
1808
|
+
const msgs = this.messages.get(inbox.id) ?? [];
|
|
1809
|
+
const msg = msgs.find((m) => m.id === id);
|
|
1810
|
+
if (!msg)
|
|
1811
|
+
throw new NotFoundError(`Message not found: ${id}`);
|
|
1812
|
+
const hard = expunge || msg.folder === "Trash";
|
|
1813
|
+
if (hard) {
|
|
1814
|
+
this.messages.set(inbox.id, msgs.filter((m) => m.id !== id));
|
|
1815
|
+
}
|
|
1816
|
+
else {
|
|
1817
|
+
msg.folder = "Trash";
|
|
1818
|
+
}
|
|
1819
|
+
return { id, deleted: true, expunged: hard, count: 1 };
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* Delete every message in a thread by id (mirrors DELETE .../threads/{id}).
|
|
1823
|
+
* Moves them to Trash (soft) or removes them (expunge / already in Trash).
|
|
1824
|
+
*/
|
|
1825
|
+
deleteThread(idOrAddress, threadId, expunge) {
|
|
1826
|
+
const inbox = this.requireInbox(idOrAddress);
|
|
1827
|
+
const msgs = this.messages.get(inbox.id) ?? [];
|
|
1828
|
+
const inThread = msgs.filter((m) => m.thread_id === threadId);
|
|
1829
|
+
if (inThread.length === 0)
|
|
1830
|
+
throw new NotFoundError(`Thread not found: ${threadId}`);
|
|
1831
|
+
const hard = expunge;
|
|
1832
|
+
if (hard) {
|
|
1833
|
+
this.messages.set(inbox.id, msgs.filter((m) => m.thread_id !== threadId));
|
|
1834
|
+
}
|
|
1835
|
+
else {
|
|
1836
|
+
for (const m of inThread)
|
|
1837
|
+
m.folder = "Trash";
|
|
1838
|
+
}
|
|
1839
|
+
return { id: threadId, deleted: true, expunged: hard, count: inThread.length };
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Batch mark read/unread and/or move folder for a list of message ids under one
|
|
1843
|
+
* inbox (mirrors PATCH .../messages/batch). Ids not present in the inbox are
|
|
1844
|
+
* reported in `failed`; the rest in `updated`.
|
|
1845
|
+
*/
|
|
1846
|
+
batchUpdateMessages(idOrAddress, ids, read, folder) {
|
|
1847
|
+
const inbox = this.requireInbox(idOrAddress);
|
|
1848
|
+
const msgs = this.messages.get(inbox.id) ?? [];
|
|
1849
|
+
const byId = new Map(msgs.map((m) => [m.id, m]));
|
|
1850
|
+
const updated = [];
|
|
1851
|
+
const failed = [];
|
|
1852
|
+
for (const id of ids) {
|
|
1853
|
+
const m = byId.get(id);
|
|
1854
|
+
if (!m) {
|
|
1855
|
+
failed.push(id);
|
|
1856
|
+
continue;
|
|
1857
|
+
}
|
|
1858
|
+
if (read !== undefined)
|
|
1859
|
+
m.seen = read;
|
|
1860
|
+
if (folder)
|
|
1861
|
+
m.folder = folder;
|
|
1862
|
+
updated.push(id);
|
|
1863
|
+
}
|
|
1864
|
+
return { updated, failed };
|
|
1865
|
+
}
|
|
1866
|
+
/** Build the canonical Thread wire shape (snippet, participant strings). */
|
|
1867
|
+
toThread(inboxAddr, id, msgs) {
|
|
1868
|
+
const sorted = [...msgs].sort((a, b) => a.date.localeCompare(b.date));
|
|
1869
|
+
const last = sorted.at(-1);
|
|
1870
|
+
const participants = dedupeAddresses(sorted.flatMap((m) => [m.from, ...m.to])).map((a) => a.name ? `${a.name} <${a.email}>` : a.email);
|
|
1871
|
+
return {
|
|
1872
|
+
id,
|
|
1873
|
+
inbox_id: inboxAddr,
|
|
1874
|
+
subject: normalizeThreadSubject(sorted[0]?.subject ?? "(no subject)"),
|
|
1875
|
+
message_count: sorted.length,
|
|
1876
|
+
participants,
|
|
1877
|
+
last_message_at: last.date,
|
|
1878
|
+
snippet: (last.text ?? last.html ?? "").slice(0, 140),
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
search(opts) {
|
|
1882
|
+
const q = opts.query.toLowerCase();
|
|
1883
|
+
const scope = opts.inbox ? [this.requireInbox(opts.inbox).id] : [...this.inboxes.keys()];
|
|
1884
|
+
const hits = [];
|
|
1885
|
+
for (const inboxId of scope) {
|
|
1886
|
+
for (const m of this.messages.get(inboxId) ?? []) {
|
|
1887
|
+
if (m.subject.toLowerCase().includes(q) ||
|
|
1888
|
+
sourceMessageBody(m).toLowerCase().includes(q) ||
|
|
1889
|
+
m.from.email.toLowerCase().includes(q)) {
|
|
1890
|
+
hits.push(m);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
hits.sort((a, b) => b.date.localeCompare(a.date));
|
|
1895
|
+
return { items: hits.slice(0, opts.limit ?? 20), total: hits.length };
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* Offline `wait_for_email`. Resolves as soon as a matching inbound message is
|
|
1899
|
+
* present. Because `sendEmail` queues an auto-reply ~1.2s out, the demo flow
|
|
1900
|
+
* resolves quickly; otherwise it resolves against an already-seeded OTP mail.
|
|
1901
|
+
*/
|
|
1902
|
+
async waitForEmail(opts) {
|
|
1903
|
+
const started = Date.now();
|
|
1904
|
+
const inbox = this.requireInbox(opts.inbox);
|
|
1905
|
+
const pollMs = opts.pollMs ?? 250;
|
|
1906
|
+
// Go regexp matching is case-sensitive. Support the documented leading
|
|
1907
|
+
// inline flag for the common explicit-insensitive case in offline fixtures.
|
|
1908
|
+
const re = opts.regex ? compileFixtureRegex(opts.regex) : undefined;
|
|
1909
|
+
const matches = (m) => {
|
|
1910
|
+
if (m.direction !== "inbound")
|
|
1911
|
+
return false;
|
|
1912
|
+
if (m.seen)
|
|
1913
|
+
return false;
|
|
1914
|
+
if (opts.from && !m.from.email.toLowerCase().includes(opts.from.toLowerCase()))
|
|
1915
|
+
return false;
|
|
1916
|
+
if (opts.subject && !m.subject.toLowerCase().includes(opts.subject.toLowerCase()))
|
|
1917
|
+
return false;
|
|
1918
|
+
if (re && !re.test(`${m.subject}\n${sourceMessageBody(m)}`))
|
|
1919
|
+
return false;
|
|
1920
|
+
return true;
|
|
1921
|
+
};
|
|
1922
|
+
for (;;) {
|
|
1923
|
+
const candidate = (this.messages.get(inbox.id) ?? []).find(matches);
|
|
1924
|
+
if (candidate) {
|
|
1925
|
+
candidate.seen = true;
|
|
1926
|
+
const signals = extractSignals(sourceMessageBody(candidate), opts.linkHint);
|
|
1927
|
+
const result = {
|
|
1928
|
+
matched: true,
|
|
1929
|
+
message: candidate,
|
|
1930
|
+
waited_ms: Date.now() - started,
|
|
1931
|
+
};
|
|
1932
|
+
if (signals.otp_code)
|
|
1933
|
+
result.otp_code = signals.otp_code;
|
|
1934
|
+
if (signals.verification_link)
|
|
1935
|
+
result.verification_link = signals.verification_link;
|
|
1936
|
+
return result;
|
|
1937
|
+
}
|
|
1938
|
+
if (Date.now() - started >= opts.timeoutMs) {
|
|
1939
|
+
return { matched: false, waited_ms: Date.now() - started };
|
|
1940
|
+
}
|
|
1941
|
+
await delay(pollMs);
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
// ---- internals --------------------------------------------------------
|
|
1945
|
+
resolveInbox(idOrAddress) {
|
|
1946
|
+
if (this.inboxes.has(idOrAddress))
|
|
1947
|
+
return this.inboxes.get(idOrAddress);
|
|
1948
|
+
const key = idOrAddress.toLowerCase();
|
|
1949
|
+
return [...this.inboxes.values()].find((i) => i.address.toLowerCase() === key);
|
|
1950
|
+
}
|
|
1951
|
+
requireInbox(idOrAddress) {
|
|
1952
|
+
const inbox = this.resolveInbox(idOrAddress);
|
|
1953
|
+
if (!inbox) {
|
|
1954
|
+
throw new NotFoundError(`No inbox matches "${idOrAddress}". Create one with create_inbox.`);
|
|
1955
|
+
}
|
|
1956
|
+
return inbox;
|
|
1957
|
+
}
|
|
1958
|
+
appendMessage(inboxId, opts) {
|
|
1959
|
+
const date = new Date(Date.now() - opts.ageMinutes * 60_000).toISOString();
|
|
1960
|
+
const inboxAddr = this.inboxes.get(inboxId)?.address ?? inboxId;
|
|
1961
|
+
const msg = {
|
|
1962
|
+
id: nextId("msg"),
|
|
1963
|
+
thread_id: opts.threadId,
|
|
1964
|
+
inbox: inboxAddr,
|
|
1965
|
+
direction: opts.direction,
|
|
1966
|
+
from: opts.fromName ? { name: opts.fromName, email: opts.fromEmail } : { email: opts.fromEmail },
|
|
1967
|
+
to: opts.to.map((email) => ({ email })),
|
|
1968
|
+
subject: opts.subject,
|
|
1969
|
+
text: opts.text,
|
|
1970
|
+
html: opts.html ?? null,
|
|
1971
|
+
extracted_text: opts.text?.trim() || null,
|
|
1972
|
+
extracted_html: opts.html?.trim() || null,
|
|
1973
|
+
date,
|
|
1974
|
+
message_id: `<${nextId("mid")}@smtp.extrovert.dev>`,
|
|
1975
|
+
seen: opts.direction === "outbound",
|
|
1976
|
+
folder: opts.direction === "inbound" ? "INBOX" : "Sent",
|
|
1977
|
+
};
|
|
1978
|
+
if (opts.cc)
|
|
1979
|
+
msg.cc = opts.cc.map((email) => ({ email }));
|
|
1980
|
+
const arr = this.messages.get(inboxId) ?? [];
|
|
1981
|
+
arr.push(msg);
|
|
1982
|
+
this.messages.set(inboxId, arr);
|
|
1983
|
+
if (opts.attachments && opts.attachments.length > 0) {
|
|
1984
|
+
this.attachments.set(msg.id, opts.attachments.map((a, i) => ({
|
|
1985
|
+
meta: {
|
|
1986
|
+
id: `att_${i + 1}_${msg.id}`,
|
|
1987
|
+
filename: a.filename,
|
|
1988
|
+
content_type: a.content_type || "application/octet-stream",
|
|
1989
|
+
size: base64ByteLength(a.content_base64),
|
|
1990
|
+
},
|
|
1991
|
+
content_base64: a.content_base64,
|
|
1992
|
+
})));
|
|
1993
|
+
}
|
|
1994
|
+
return msg;
|
|
1995
|
+
}
|
|
1996
|
+
/** List a message's attachment metadata (mirrors the list endpoint). */
|
|
1997
|
+
listAttachments(messageId) {
|
|
1998
|
+
this.getMessage(messageId); // throws NotFoundError if absent
|
|
1999
|
+
const items = (this.attachments.get(messageId) ?? []).map((a) => a.meta);
|
|
2000
|
+
return { items, total: items.length };
|
|
2001
|
+
}
|
|
2002
|
+
/** Fetch one attachment's bytes + metadata (mirrors the download endpoint). */
|
|
2003
|
+
getAttachment(messageId, attachmentId) {
|
|
2004
|
+
const stored = (this.attachments.get(messageId) ?? []).find((a) => a.meta.id === attachmentId);
|
|
2005
|
+
if (!stored)
|
|
2006
|
+
throw new NotFoundError(`Attachment not found: ${attachmentId}`);
|
|
2007
|
+
return {
|
|
2008
|
+
filename: stored.meta.filename,
|
|
2009
|
+
content_type: stored.meta.content_type,
|
|
2010
|
+
content_base64: stored.content_base64,
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
// ---- webhooks ---------------------------------------------------------
|
|
2014
|
+
/** Register a webhook; returns the row WITH the one-time signing secret. */
|
|
2015
|
+
registerWebhook(input) {
|
|
2016
|
+
// Idempotency replay: a repeat with the same client id returns the first row.
|
|
2017
|
+
const idemKey = input.clientId?.trim() ? `webhook.create:${input.clientId.trim()}` : "";
|
|
2018
|
+
if (idemKey) {
|
|
2019
|
+
const existingId = this.idempotency.get(idemKey);
|
|
2020
|
+
const existing = existingId ? this.webhooks.get(existingId) : undefined;
|
|
2021
|
+
if (existing)
|
|
2022
|
+
return { ...existing };
|
|
2023
|
+
}
|
|
2024
|
+
const id = nextId("whk");
|
|
2025
|
+
const secret = `whsec_${shortLabel()}${Math.random().toString(36).slice(2, 14)}`;
|
|
2026
|
+
const webhook = {
|
|
2027
|
+
id,
|
|
2028
|
+
url: input.url,
|
|
2029
|
+
events: input.events && input.events.length ? input.events : ["message.received"],
|
|
2030
|
+
inbox: input.inbox ?? null,
|
|
2031
|
+
agent_id: this.agentId,
|
|
2032
|
+
secret,
|
|
2033
|
+
secret_prefix: secret.slice(0, "whsec_".length + 6),
|
|
2034
|
+
active: true,
|
|
2035
|
+
created_at: new Date().toISOString(),
|
|
2036
|
+
};
|
|
2037
|
+
this.webhooks.set(id, webhook);
|
|
2038
|
+
if (idemKey)
|
|
2039
|
+
this.idempotency.set(idemKey, id);
|
|
2040
|
+
return { ...webhook };
|
|
2041
|
+
}
|
|
2042
|
+
/** List webhooks (secret redacted). */
|
|
2043
|
+
listWebhooks() {
|
|
2044
|
+
const items = [...this.webhooks.values()].map((w) => redactWebhookSecret(w));
|
|
2045
|
+
return { items, total: items.length };
|
|
2046
|
+
}
|
|
2047
|
+
/** Get one webhook (secret redacted). Throws NotFoundError when absent. */
|
|
2048
|
+
getWebhook(id) {
|
|
2049
|
+
const w = this.webhooks.get(id);
|
|
2050
|
+
if (!w)
|
|
2051
|
+
throw new NotFoundError(`Webhook not found: ${id}`);
|
|
2052
|
+
return redactWebhookSecret(w);
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* Update a webhook in place (secret redacted in the response). Every field is
|
|
2056
|
+
* optional; an unset field leaves the stored value untouched (PATCH semantics).
|
|
2057
|
+
* Throws NotFoundError when absent.
|
|
2058
|
+
*/
|
|
2059
|
+
updateWebhook(id, input) {
|
|
2060
|
+
const w = this.webhooks.get(id);
|
|
2061
|
+
if (!w)
|
|
2062
|
+
throw new NotFoundError(`Webhook not found: ${id}`);
|
|
2063
|
+
if (input.url !== undefined)
|
|
2064
|
+
w.url = input.url;
|
|
2065
|
+
if (input.events !== undefined) {
|
|
2066
|
+
w.events = input.events.length ? input.events : ["message.received"];
|
|
2067
|
+
}
|
|
2068
|
+
if (input.inbox !== undefined)
|
|
2069
|
+
w.inbox = input.inbox === "" ? null : input.inbox;
|
|
2070
|
+
if (input.active !== undefined)
|
|
2071
|
+
w.active = input.active;
|
|
2072
|
+
this.webhooks.set(id, w);
|
|
2073
|
+
return redactWebhookSecret(w);
|
|
2074
|
+
}
|
|
2075
|
+
/** Delete a webhook. Throws NotFoundError when absent. */
|
|
2076
|
+
deleteWebhook(id) {
|
|
2077
|
+
if (!this.webhooks.delete(id))
|
|
2078
|
+
throw new NotFoundError(`Webhook not found: ${id}`);
|
|
2079
|
+
return { id, deleted: true };
|
|
2080
|
+
}
|
|
2081
|
+
// ---- contact allow/block lists (Slice 3) ------------------------------
|
|
2082
|
+
/** Add one allow/block entry scoped to an inbox. */
|
|
2083
|
+
addContactListEntry(inbox, input) {
|
|
2084
|
+
this.requireInbox(inbox);
|
|
2085
|
+
const pattern = normalizeContactPattern(input.pattern ?? "");
|
|
2086
|
+
if (!pattern)
|
|
2087
|
+
throw new NotFoundError("pattern is required (address or domain)");
|
|
2088
|
+
const entry = {
|
|
2089
|
+
id: nextId("lst"),
|
|
2090
|
+
inbox: this.requireInbox(inbox).address,
|
|
2091
|
+
kind: input.kind,
|
|
2092
|
+
direction: input.direction ?? "send",
|
|
2093
|
+
pattern,
|
|
2094
|
+
created_at: new Date().toISOString(),
|
|
2095
|
+
};
|
|
2096
|
+
this.contactLists.set(entry.id, entry);
|
|
2097
|
+
return { ...entry };
|
|
2098
|
+
}
|
|
2099
|
+
/** List the entries governing an inbox (inbox-specific + account-wide). */
|
|
2100
|
+
listContactListEntries(inbox) {
|
|
2101
|
+
const address = this.requireInbox(inbox).address;
|
|
2102
|
+
const items = [...this.contactLists.values()].filter((e) => e.inbox === null || e.inbox === address);
|
|
2103
|
+
return { items, total: items.length };
|
|
2104
|
+
}
|
|
2105
|
+
/** Delete a contact-list entry by id. Throws NotFoundError when absent. */
|
|
2106
|
+
deleteContactListEntry(_inbox, id) {
|
|
2107
|
+
if (!this.contactLists.delete(id))
|
|
2108
|
+
throw new NotFoundError(`Contact list entry not found: ${id}`);
|
|
2109
|
+
return { id, deleted: true };
|
|
2110
|
+
}
|
|
2111
|
+
// ---- domains (Slice 5) ------------------------------------------------
|
|
2112
|
+
/** Onboard a domain. Mirrors the server's per-mode record set + status. */
|
|
2113
|
+
onboardDomain(input) {
|
|
2114
|
+
const name = input.domain.trim().toLowerCase();
|
|
2115
|
+
const mode = input.mode ?? "ns_delegated";
|
|
2116
|
+
// A project_id assertion must match the key's bound project (403 on mismatch),
|
|
2117
|
+
// mirroring the SDK mock + the real server. `scope` is accepted offline (the
|
|
2118
|
+
// live API binds visibility to the key's project); the mock does not otherwise
|
|
2119
|
+
// model cross-project isolation.
|
|
2120
|
+
void input.scope;
|
|
2121
|
+
assertProjectMatch(input.project_id);
|
|
2122
|
+
const existing = this.domains.get(name);
|
|
2123
|
+
if (existing)
|
|
2124
|
+
return { ...existing };
|
|
2125
|
+
const domain = {
|
|
2126
|
+
id: nextId("dom"),
|
|
2127
|
+
domain: name,
|
|
2128
|
+
mode,
|
|
2129
|
+
verification_status: mode === "shared" ? "verified" : mode === "manual" ? "pending" : "verifying",
|
|
2130
|
+
dkim_status: mode === "shared" ? "configured" : mode === "manual" ? "pending" : "configured",
|
|
2131
|
+
shared: mode === "shared",
|
|
2132
|
+
created_at: new Date().toISOString(),
|
|
2133
|
+
records: mode === "manual" || mode === "ns_delegated" ? domainRecordSet(name) : undefined,
|
|
2134
|
+
delegation_ns: mode === "ns_delegated" ? domainDelegationNS(name) : undefined,
|
|
2135
|
+
instruction: mode === "shared"
|
|
2136
|
+
? "Shared domain ready. No DNS changes required."
|
|
2137
|
+
: "Add the records, then trigger verification.",
|
|
2138
|
+
};
|
|
2139
|
+
this.domains.set(name, domain);
|
|
2140
|
+
return { ...domain };
|
|
2141
|
+
}
|
|
2142
|
+
/** List onboarded domains (records omitted on the summary, mirroring the server). */
|
|
2143
|
+
listDomains() {
|
|
2144
|
+
const items = [...this.domains.values()].map((d) => domainSummary(d));
|
|
2145
|
+
return { items, total: items.length };
|
|
2146
|
+
}
|
|
2147
|
+
/** Get one domain's detail + the records to set, inline. Throws NotFoundError when absent. */
|
|
2148
|
+
getDomain(domain) {
|
|
2149
|
+
const d = this.domains.get(domain.trim().toLowerCase());
|
|
2150
|
+
if (!d)
|
|
2151
|
+
throw new NotFoundError(`Domain not found: ${domain}`);
|
|
2152
|
+
return { ...d };
|
|
2153
|
+
}
|
|
2154
|
+
/** Trigger/refresh verification; returns the (re-read) detail. Throws when absent. */
|
|
2155
|
+
verifyDomain(domain) {
|
|
2156
|
+
const d = this.domains.get(domain.trim().toLowerCase());
|
|
2157
|
+
if (!d)
|
|
2158
|
+
throw new NotFoundError(`Domain not found: ${domain}`);
|
|
2159
|
+
return { ...d };
|
|
2160
|
+
}
|
|
2161
|
+
/**
|
|
2162
|
+
* Offboard (remove) a domain. Throws NotFoundError when absent. Mirrors the live
|
|
2163
|
+
* API's async contract: it returns an accepted teardown job (there is no job
|
|
2164
|
+
* runner in-fixture, so the row is removed synchronously and a synthetic
|
|
2165
|
+
* succeeded job is reported).
|
|
2166
|
+
*/
|
|
2167
|
+
offboardDomain(domain) {
|
|
2168
|
+
const name = domain.trim().toLowerCase();
|
|
2169
|
+
if (!this.domains.delete(name))
|
|
2170
|
+
throw new NotFoundError(`Domain not found: ${domain}`);
|
|
2171
|
+
const jobId = `job-offboard-${name}`;
|
|
2172
|
+
const ts = new Date().toISOString();
|
|
2173
|
+
this.jobs.set(jobId, {
|
|
2174
|
+
object: "job",
|
|
2175
|
+
id: jobId,
|
|
2176
|
+
type: "domain_offboard",
|
|
2177
|
+
status: "succeeded",
|
|
2178
|
+
created_at: ts,
|
|
2179
|
+
updated_at: ts,
|
|
2180
|
+
finished_at: ts,
|
|
2181
|
+
});
|
|
2182
|
+
return { domain: name, job_id: jobId, status: "succeeded", status_url: `/v1/jobs/${jobId}` };
|
|
2183
|
+
}
|
|
2184
|
+
/** Get one async job's poll status (mirrors `GET /v1/jobs/{job_id}`). Throws NotFoundError when absent. */
|
|
2185
|
+
getJob(jobId) {
|
|
2186
|
+
const job = this.jobs.get(jobId);
|
|
2187
|
+
if (!job)
|
|
2188
|
+
throw new NotFoundError(`Job not found: ${jobId}`);
|
|
2189
|
+
return { ...job };
|
|
2190
|
+
}
|
|
2191
|
+
// ---- suppressions (recipient opt-outs / list-unsubscribe) --------------
|
|
2192
|
+
/**
|
|
2193
|
+
* Pre-check whether the caller's org suppresses a recipient (mirrors
|
|
2194
|
+
* `GET /v1/suppressions?recipient=…`): `{recipient, suppressed, rows}` over the
|
|
2195
|
+
* active (non-revoked) org rows for that canonicalized recipient.
|
|
2196
|
+
*/
|
|
2197
|
+
precheckSuppression(recipient) {
|
|
2198
|
+
const canonical = canonicalRecipient(recipient);
|
|
2199
|
+
const rows = [...this.suppressions.values()]
|
|
2200
|
+
.filter((s) => !s.revoked && s.recipient === canonical)
|
|
2201
|
+
.map((s) => ({ ...s }));
|
|
2202
|
+
return { recipient: canonical, suppressed: rows.length > 0, rows };
|
|
2203
|
+
}
|
|
2204
|
+
/** Offline deliverability rollup (mirrors `GET /v1/reputation`): healthy, no data. */
|
|
2205
|
+
getReputation() {
|
|
2206
|
+
return {
|
|
2207
|
+
object: "reputation",
|
|
2208
|
+
org_id: MOCK_ORG_ID,
|
|
2209
|
+
status: "unknown",
|
|
2210
|
+
sending_status: "unknown",
|
|
2211
|
+
providers: [],
|
|
2212
|
+
metrics: { sends: 0, bounces: 0, complaints: 0, bounce_rate: 0, complaint_rate: 0 },
|
|
2213
|
+
open_findings: 0,
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
/** Offline findings list (mirrors `GET /v1/reputation/findings`): empty. */
|
|
2217
|
+
listDeliverabilityFindings(_input = {}) {
|
|
2218
|
+
return { items: [], total: 0 };
|
|
2219
|
+
}
|
|
2220
|
+
/** List the caller's own org suppression rows (mirrors the paged `GET /v1/suppressions`). */
|
|
2221
|
+
listSuppressions(input) {
|
|
2222
|
+
let rows = [...this.suppressions.values()];
|
|
2223
|
+
if (input.scope)
|
|
2224
|
+
rows = rows.filter((s) => s.scope === input.scope);
|
|
2225
|
+
if (!input.include_revoked)
|
|
2226
|
+
rows = rows.filter((s) => !s.revoked);
|
|
2227
|
+
rows.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
2228
|
+
const total = rows.length;
|
|
2229
|
+
const offset = input.cursor ? Math.max(0, Number.parseInt(input.cursor, 10) || 0) : 0;
|
|
2230
|
+
const limit = input.limit ?? 50;
|
|
2231
|
+
const items = rows.slice(offset, offset + limit).map((s) => ({ ...s }));
|
|
2232
|
+
const page = { items, total };
|
|
2233
|
+
if (offset + items.length < total)
|
|
2234
|
+
page.next_cursor = String(offset + items.length);
|
|
2235
|
+
return page;
|
|
2236
|
+
}
|
|
2237
|
+
/**
|
|
2238
|
+
* Revoke one org-scope suppression row (mirrors `POST /v1/suppressions/{id}/revoke`);
|
|
2239
|
+
* a reason is required. Throws NotFoundError when the id is unknown or not the
|
|
2240
|
+
* caller's own org row (global/shared rows are platform-operator only → 404).
|
|
2241
|
+
*/
|
|
2242
|
+
revokeSuppression(id, reason) {
|
|
2243
|
+
const row = this.suppressions.get(id);
|
|
2244
|
+
if (!row || row.scope !== "org" || row.revoked) {
|
|
2245
|
+
throw new NotFoundError(`Suppression not found: ${id}`);
|
|
2246
|
+
}
|
|
2247
|
+
row.revoked = true;
|
|
2248
|
+
row.revoked_at = new Date().toISOString();
|
|
2249
|
+
row.revoked_by = "agent:" + this.agentId;
|
|
2250
|
+
row.revoke_reason = reason;
|
|
2251
|
+
this.suppressions.set(id, row);
|
|
2252
|
+
return { ...row };
|
|
2253
|
+
}
|
|
2254
|
+
/**
|
|
2255
|
+
* Reject the WHOLE send if ANY recipient has an active org-scope suppression,
|
|
2256
|
+
* naming exactly the suppressed addresses (never the scope/origin) so the caller
|
|
2257
|
+
* can drop them and retry — mirroring the live `recipient_suppressed` (422) path.
|
|
2258
|
+
*/
|
|
2259
|
+
enforceSuppression(recipients) {
|
|
2260
|
+
const active = new Set([...this.suppressions.values()].filter((s) => !s.revoked).map((s) => s.recipient));
|
|
2261
|
+
if (active.size === 0)
|
|
2262
|
+
return;
|
|
2263
|
+
const hit = [];
|
|
2264
|
+
for (const rcpt of recipients) {
|
|
2265
|
+
const canonical = canonicalRecipient(rcpt);
|
|
2266
|
+
if (canonical && active.has(canonical) && !hit.includes(canonical))
|
|
2267
|
+
hit.push(canonical);
|
|
2268
|
+
}
|
|
2269
|
+
if (hit.length > 0)
|
|
2270
|
+
throw new SuppressedError(hit);
|
|
2271
|
+
}
|
|
2272
|
+
/**
|
|
2273
|
+
* Enforce the send-direction contact lists for an inbox: reject a block-listed
|
|
2274
|
+
* recipient, or any recipient outside the allowlist when allowlist mode is on.
|
|
2275
|
+
*/
|
|
2276
|
+
enforceSendPolicy(from, recipients) {
|
|
2277
|
+
const entries = [...this.contactLists.values()].filter((e) => e.direction === "send" && (e.inbox === null || e.inbox === from));
|
|
2278
|
+
if (entries.length === 0)
|
|
2279
|
+
return;
|
|
2280
|
+
const blocks = entries.filter((e) => e.kind === "block");
|
|
2281
|
+
const allows = entries.filter((e) => e.kind === "allow");
|
|
2282
|
+
for (const rcpt of recipients) {
|
|
2283
|
+
const addr = normalizeContactPattern(rcpt);
|
|
2284
|
+
if (!addr)
|
|
2285
|
+
continue;
|
|
2286
|
+
if (blocks.some((b) => contactEntryMatches(b.pattern, addr))) {
|
|
2287
|
+
throw new BlockedError(`${rcpt} is block-listed`);
|
|
2288
|
+
}
|
|
2289
|
+
if (allows.length > 0 && !allows.some((a) => contactEntryMatches(a.pattern, addr))) {
|
|
2290
|
+
throw new BlockedError(`${rcpt} is not on the allow list`);
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
/** Drop a believable inbound OTP reply into the thread shortly after a send. */
|
|
2295
|
+
queueAutoReply(inbox, original) {
|
|
2296
|
+
const code = String(Math.floor(100000 + Math.random() * 900000));
|
|
2297
|
+
setTimeout(() => {
|
|
2298
|
+
if (!this.inboxes.has(inbox.id))
|
|
2299
|
+
return;
|
|
2300
|
+
this.appendMessage(inbox.id, {
|
|
2301
|
+
direction: "inbound",
|
|
2302
|
+
fromName: "Acme Security",
|
|
2303
|
+
fromEmail: "no-reply@acme.example",
|
|
2304
|
+
to: [inbox.address],
|
|
2305
|
+
subject: `Your verification code`,
|
|
2306
|
+
text: `Hello,\n\nYour Acme verification code is: ${code}\n\nIt expires in 10 minutes. If you did not request this, ignore this email.`,
|
|
2307
|
+
html: `<p>Hello,</p><p>Your Acme verification code is: <b>${code}</b></p><p><a href="https://acme.example/verify?token=${code}abc&u=${encodeURIComponent(inbox.address)}">Verify your email</a></p>`,
|
|
2308
|
+
threadId: original.thread_id,
|
|
2309
|
+
ageMinutes: 0,
|
|
2310
|
+
});
|
|
2311
|
+
}, 1200).unref?.();
|
|
2312
|
+
}
|
|
2313
|
+
seed() {
|
|
2314
|
+
const inbox = this.createInbox({
|
|
2315
|
+
username: "agent7",
|
|
2316
|
+
domain: "smtp.extrovert.dev",
|
|
2317
|
+
displayName: "Extrovert Demo Agent",
|
|
2318
|
+
});
|
|
2319
|
+
// Seed one active org-scope suppression so the recipient opt-out surface
|
|
2320
|
+
// (check_suppression / list_suppressions / revoke_suppression) and the
|
|
2321
|
+
// recipient_suppressed send-rejection path have deterministic offline data.
|
|
2322
|
+
const supId = nextId("sup");
|
|
2323
|
+
this.suppressions.set(supId, {
|
|
2324
|
+
id: supId,
|
|
2325
|
+
recipient: SEEDED_SUPPRESSED_RECIPIENT,
|
|
2326
|
+
recipient_raw: SEEDED_SUPPRESSED_RECIPIENT,
|
|
2327
|
+
scope: "org",
|
|
2328
|
+
source: "manual",
|
|
2329
|
+
reactivation_count: 0,
|
|
2330
|
+
created_at: new Date().toISOString(),
|
|
2331
|
+
revoked: false,
|
|
2332
|
+
});
|
|
2333
|
+
const seeds = [
|
|
2334
|
+
{
|
|
2335
|
+
fromName: "Stripe",
|
|
2336
|
+
fromEmail: "verify@stripe.com",
|
|
2337
|
+
subject: "Confirm your email address",
|
|
2338
|
+
text: "Welcome! Your confirmation code is 481920. Or click the button below to verify.",
|
|
2339
|
+
html: '<p>Your confirmation code is <b>481920</b>.</p><p><a href="https://dashboard.stripe.com/verify?code=481920&id=evt_9">Confirm email</a></p>',
|
|
2340
|
+
ageMinutes: 3,
|
|
2341
|
+
},
|
|
2342
|
+
{
|
|
2343
|
+
fromName: "GitHub",
|
|
2344
|
+
fromEmail: "noreply@github.com",
|
|
2345
|
+
subject: "[GitHub] Please verify your device",
|
|
2346
|
+
text: "A sign-in attempt requires verification. Your authentication code is GH-204815.",
|
|
2347
|
+
ageMinutes: 41,
|
|
2348
|
+
},
|
|
2349
|
+
{
|
|
2350
|
+
fromName: "HTML Sender",
|
|
2351
|
+
fromEmail: "html-only@example.test",
|
|
2352
|
+
subject: "HTML-only verification",
|
|
2353
|
+
text: null,
|
|
2354
|
+
html: '<p>Your HTMLONLY verification code is <strong>731942</strong>.</p><p><a href="https://example.test/verify?token=htmlonly">Verify</a></p>',
|
|
2355
|
+
ageMinutes: 90,
|
|
2356
|
+
},
|
|
2357
|
+
{
|
|
2358
|
+
fromName: "Linear",
|
|
2359
|
+
fromEmail: "notifications@linear.app",
|
|
2360
|
+
subject: "You were assigned POS-128",
|
|
2361
|
+
text: "Keith assigned you an issue: 'Wire wait_for_email into the MCP server'. Due Friday.",
|
|
2362
|
+
ageMinutes: 220,
|
|
2363
|
+
},
|
|
2364
|
+
];
|
|
2365
|
+
for (const s of seeds) {
|
|
2366
|
+
const msg = this.appendMessage(inbox.id, {
|
|
2367
|
+
direction: "inbound",
|
|
2368
|
+
fromName: s.fromName,
|
|
2369
|
+
fromEmail: s.fromEmail,
|
|
2370
|
+
to: [inbox.address],
|
|
2371
|
+
subject: s.subject,
|
|
2372
|
+
text: s.text,
|
|
2373
|
+
html: s.html,
|
|
2374
|
+
threadId: nextId("thr"),
|
|
2375
|
+
ageMinutes: s.ageMinutes,
|
|
2376
|
+
});
|
|
2377
|
+
msg.seen = false;
|
|
2378
|
+
}
|
|
2379
|
+
// Seed a review with one pending durable nudge so the realtime drain/ack
|
|
2380
|
+
// surface (list_review_events / wait_for_review_event / ack_review_event) has a
|
|
2381
|
+
// deterministic event to exercise offline: a human rejected a draft with
|
|
2382
|
+
// feedback before the agent connected, leaving work on the authoritative queue.
|
|
2383
|
+
const seededReview = this.createReviewRecord({
|
|
2384
|
+
kind: "send",
|
|
2385
|
+
fromAddress: inbox.address,
|
|
2386
|
+
subject: "Re-engage cold lead at Acme",
|
|
2387
|
+
text: "Let me know your thoughts.",
|
|
2388
|
+
to: ["vp@acme.com"],
|
|
2389
|
+
intent: { summary: "re-engage cold lead", meta: { goal: "book_meeting" } },
|
|
2390
|
+
});
|
|
2391
|
+
this.enqueueReviewEvent(seededReview.id, "rejected", {
|
|
2392
|
+
decision: "rejected",
|
|
2393
|
+
comment: "be more pushy, we need MRR",
|
|
2394
|
+
});
|
|
2395
|
+
// Seed a review already OPEN for review (a reviewer opened it) so the M5 chat
|
|
2396
|
+
// surface (post_review_chat / submit_revision / get_review_feedback) has a
|
|
2397
|
+
// chattable draft offline. A human comment turn gives get_review_feedback data.
|
|
2398
|
+
const inReviewReview = this.createReviewRecord({
|
|
2399
|
+
kind: "send",
|
|
2400
|
+
fromAddress: inbox.address,
|
|
2401
|
+
subject: "Pilot proposal",
|
|
2402
|
+
text: "Here is the Q3 pilot proposal.",
|
|
2403
|
+
to: ["vp@acme.com"],
|
|
2404
|
+
intent: { summary: "send pilot proposal", meta: { goal: "book_meeting" } },
|
|
2405
|
+
});
|
|
2406
|
+
inReviewReview.state = "in_review";
|
|
2407
|
+
this.commitReview(inReviewReview);
|
|
2408
|
+
const seededTurns = this.reviewTurns.get(inReviewReview.id) ?? [];
|
|
2409
|
+
seededTurns.push({
|
|
2410
|
+
id: nextId("turn"),
|
|
2411
|
+
seq: seededTurns.length + 1,
|
|
2412
|
+
turn_type: "human_comment",
|
|
2413
|
+
actor_kind: "human",
|
|
2414
|
+
actor_id: "user_demo",
|
|
2415
|
+
body: "tighten the opening line",
|
|
2416
|
+
created_at: new Date().toISOString(),
|
|
2417
|
+
});
|
|
2418
|
+
this.reviewTurns.set(inReviewReview.id, seededTurns);
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
/** Drop the one-time signing secret from a stored webhook for read responses. */
|
|
2422
|
+
function redactWebhookSecret(w) {
|
|
2423
|
+
const { secret: _omit, ...rest } = w;
|
|
2424
|
+
return rest;
|
|
2425
|
+
}
|
|
2426
|
+
/** The MX/SPF/DMARC/DKIM record set a customer must set (mirrors the Go manualRecordSet). */
|
|
2427
|
+
function domainRecordSet(domain) {
|
|
2428
|
+
const dkimSuffix = domain.replace(/\./g, "-");
|
|
2429
|
+
return [
|
|
2430
|
+
{ name: domain, type: "MX", value: "smtp.extrovert.dev", priority: 10, ttl: 3600 },
|
|
2431
|
+
{ name: domain, type: "TXT", value: "v=spf1 include:spf.protection.outlook.com -all", ttl: 3600 },
|
|
2432
|
+
{ name: `_dmarc.${domain}`, type: "TXT", value: "v=DMARC1; p=none; rua=mailto:dmarc@smtp.extrovert.dev", ttl: 3600 },
|
|
2433
|
+
{ name: `selector1._domainkey.${domain}`, type: "CNAME", value: `selector1-${dkimSuffix}._domainkey.azurecomm.net`, ttl: 3600 },
|
|
2434
|
+
{ name: `selector2._domainkey.${domain}`, type: "CNAME", value: `selector2-${dkimSuffix}._domainkey.azurecomm.net`, ttl: 3600 },
|
|
2435
|
+
];
|
|
2436
|
+
}
|
|
2437
|
+
/** The single NS delegation (one row per public nameserver) for ns_delegated mode. */
|
|
2438
|
+
function domainDelegationNS(domain) {
|
|
2439
|
+
return [
|
|
2440
|
+
{ name: domain, type: "NS", value: "ns1.extrovert.dev", ttl: 300 },
|
|
2441
|
+
{ name: domain, type: "NS", value: "ns2.extrovert.dev", ttl: 300 },
|
|
2442
|
+
];
|
|
2443
|
+
}
|
|
2444
|
+
/** The list-view summary: status fields without the inline record set. */
|
|
2445
|
+
function domainSummary(d) {
|
|
2446
|
+
const { records: _r, delegation_ns: _d, instruction: _i, ...rest } = d;
|
|
2447
|
+
return rest;
|
|
2448
|
+
}
|
|
2449
|
+
/** Thrown when an inbox/thread cannot be resolved (maps to API 404). */
|
|
2450
|
+
export class NotFoundError extends Error {
|
|
2451
|
+
constructor(message) {
|
|
2452
|
+
super(message);
|
|
2453
|
+
this.name = "NotFoundError";
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
/** Thrown when a send is rejected by an inbox's contact lists (maps to API 403). */
|
|
2457
|
+
export class BlockedError extends Error {
|
|
2458
|
+
constructor(message) {
|
|
2459
|
+
super(message);
|
|
2460
|
+
this.name = "BlockedError";
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
/**
|
|
2464
|
+
* Thrown when a send is rejected because one or more recipients have opted out
|
|
2465
|
+
* (list-unsubscribe / suppression; maps to API 422 `recipient_suppressed`). The
|
|
2466
|
+
* message names exactly the suppressed addresses so the agent can drop them and
|
|
2467
|
+
* retry; `recipients` carries the same list machine-readably.
|
|
2468
|
+
*/
|
|
2469
|
+
export class SuppressedError extends Error {
|
|
2470
|
+
recipients;
|
|
2471
|
+
constructor(recipients) {
|
|
2472
|
+
super(`recipient(s) suppressed (opted out): ${recipients.join(", ")}`);
|
|
2473
|
+
this.name = "SuppressedError";
|
|
2474
|
+
this.recipients = recipients;
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
/** Thrown when a project_id assertion does not match the key's bound project (maps to API 403). */
|
|
2478
|
+
export class ForbiddenError extends Error {
|
|
2479
|
+
constructor(message) {
|
|
2480
|
+
super(message);
|
|
2481
|
+
this.name = "ForbiddenError";
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
/**
|
|
2485
|
+
* Thrown when an org-tier key issues a bare list that needs an explicit breadth
|
|
2486
|
+
* pick (maps to API 400 `breadth_required`; redesign §4.1). The message names the
|
|
2487
|
+
* next call (a project id or the org wildcard).
|
|
2488
|
+
*/
|
|
2489
|
+
export class BreadthRequiredError extends Error {
|
|
2490
|
+
constructor(message) {
|
|
2491
|
+
super(message);
|
|
2492
|
+
this.name = "BreadthRequiredError";
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
/**
|
|
2496
|
+
* Thrown when a send/reply/forward is refused because the account's review policy
|
|
2497
|
+
* requires an intent (maps to API 422 `intent_required`; D3).
|
|
2498
|
+
*
|
|
2499
|
+
* `problemErrors` carries the SAME `{field, code, detail}` hints the live problem
|
|
2500
|
+
* body does — including the `retry_with` example — because the offline error is
|
|
2501
|
+
* useless as practice if it is less actionable than the real one.
|
|
2502
|
+
*/
|
|
2503
|
+
export class IntentRequiredError extends Error {
|
|
2504
|
+
problemErrors;
|
|
2505
|
+
constructor(message, problemErrors) {
|
|
2506
|
+
super(message);
|
|
2507
|
+
this.name = "IntentRequiredError";
|
|
2508
|
+
this.problemErrors = problemErrors;
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
/** Thrown on a conflicting/idempotent-replay mutation (maps to API 409). */
|
|
2512
|
+
export class ConflictError extends Error {
|
|
2513
|
+
constructor(message) {
|
|
2514
|
+
super(message);
|
|
2515
|
+
this.name = "ConflictError";
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
/**
|
|
2519
|
+
* Thrown when `text` and its deprecated `body` alias disagree (maps to API 400
|
|
2520
|
+
* `conflicting_alias`). There is no safe guess: picking a winner would relay the
|
|
2521
|
+
* wrong bytes from the customer's own domain to a real recipient.
|
|
2522
|
+
*/
|
|
2523
|
+
export class ConflictingAliasError extends Error {
|
|
2524
|
+
problemErrors;
|
|
2525
|
+
constructor(message) {
|
|
2526
|
+
super(message);
|
|
2527
|
+
this.name = "ConflictingAliasError";
|
|
2528
|
+
this.problemErrors = [{ field: "text", code: "conflicting_alias", detail: message }];
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
/**
|
|
2532
|
+
* Base for the review-loop 409s that carry the RECOVERY FACTS as problem fields:
|
|
2533
|
+
* the current state / revision / version, plus one `allowed_action` per legal verb.
|
|
2534
|
+
* A stale-CAS retry therefore needs no extra `get_review`, and a wrong-state agent
|
|
2535
|
+
* is told what IS legal instead of retrying the same verb forever.
|
|
2536
|
+
*/
|
|
2537
|
+
export class ReviewConflictError extends ConflictError {
|
|
2538
|
+
problemErrors;
|
|
2539
|
+
constructor(message, review) {
|
|
2540
|
+
super(message);
|
|
2541
|
+
this.problemErrors = [
|
|
2542
|
+
{ field: "state", code: review.state, detail: "the draft's current state" },
|
|
2543
|
+
{ field: "revision", code: String(review.revision), detail: "current revision — use as parent_revision" },
|
|
2544
|
+
{ field: "version", code: String(review.version), detail: "current row version" },
|
|
2545
|
+
...(review.sent_message_id
|
|
2546
|
+
? [{ field: "sent_message_id", code: review.sent_message_id, detail: "the message that already went out" }]
|
|
2547
|
+
: []),
|
|
2548
|
+
...allowedAgentActions(review.state).map((a) => ({
|
|
2549
|
+
field: "allowed_action",
|
|
2550
|
+
code: a,
|
|
2551
|
+
detail: "legal from the current state",
|
|
2552
|
+
})),
|
|
2553
|
+
];
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
/**
|
|
2557
|
+
* 409 `stale` — the `(revision[,version])` you named is no longer current and
|
|
2558
|
+
* NOTHING was mutated. The ONE 409 worth retrying: re-read, re-apply your edit on
|
|
2559
|
+
* top of the other party's, resubmit with the new parent_revision. Bounded (<=3).
|
|
2560
|
+
*/
|
|
2561
|
+
export class StaleError extends ReviewConflictError {
|
|
2562
|
+
constructor(message, review) {
|
|
2563
|
+
super(message, review);
|
|
2564
|
+
this.name = "StaleError";
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
/**
|
|
2568
|
+
* 409 `wrong_state` — this VERB is illegal from the current state, but the draft
|
|
2569
|
+
* is still live. NEVER retry the same verb; read the `allowed_action` hints and
|
|
2570
|
+
* pick a legal one.
|
|
2571
|
+
*/
|
|
2572
|
+
export class WrongStateError extends ReviewConflictError {
|
|
2573
|
+
constructor(message, review) {
|
|
2574
|
+
super(message, review);
|
|
2575
|
+
this.name = "WrongStateError";
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
/**
|
|
2579
|
+
* 409 `terminal` — sent / auto_sent / cancelled. Nothing will ever succeed on this
|
|
2580
|
+
* review. STOP; a `front_run_next` nudge is waiting in the drain.
|
|
2581
|
+
*/
|
|
2582
|
+
export class TerminalError extends ReviewConflictError {
|
|
2583
|
+
constructor(message, review) {
|
|
2584
|
+
super(message, review);
|
|
2585
|
+
this.name = "TerminalError";
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
/**
|
|
2589
|
+
* Canonicalize a recipient address for suppression matching: pull the address out
|
|
2590
|
+
* of a "Name <addr>" form, NFC-normalize, trim, and lower-case (mirrors the Go
|
|
2591
|
+
* canonicalization closely enough for the offline mock).
|
|
2592
|
+
*/
|
|
2593
|
+
function canonicalRecipient(raw) {
|
|
2594
|
+
let value = raw.trim();
|
|
2595
|
+
const m = value.match(/<([^>]+)>/);
|
|
2596
|
+
if (m && m[1])
|
|
2597
|
+
value = m[1];
|
|
2598
|
+
return value.normalize("NFC").trim().toLowerCase();
|
|
2599
|
+
}
|
|
2600
|
+
/**
|
|
2601
|
+
* Normalize a contact pattern or recipient: lower-case/trim, pull the address
|
|
2602
|
+
* out of a "Name <addr>" form, and strip a leading "@" from a domain pattern.
|
|
2603
|
+
*/
|
|
2604
|
+
function normalizeContactPattern(raw) {
|
|
2605
|
+
let value = raw.trim();
|
|
2606
|
+
const m = value.match(/<([^>]+)>/);
|
|
2607
|
+
if (m && m[1])
|
|
2608
|
+
value = m[1];
|
|
2609
|
+
value = value.trim().toLowerCase();
|
|
2610
|
+
return value.startsWith("@") ? value.slice(1) : value;
|
|
2611
|
+
}
|
|
2612
|
+
/**
|
|
2613
|
+
* Match a normalized pattern against a normalized recipient address. A pattern
|
|
2614
|
+
* with an "@" is a full-address match; a bare domain matches any address in it.
|
|
2615
|
+
*/
|
|
2616
|
+
function contactEntryMatches(pattern, addr) {
|
|
2617
|
+
if (!pattern || !addr)
|
|
2618
|
+
return false;
|
|
2619
|
+
if (pattern.includes("@"))
|
|
2620
|
+
return pattern === addr;
|
|
2621
|
+
const at = addr.lastIndexOf("@");
|
|
2622
|
+
const domain = at >= 0 ? addr.slice(at + 1) : addr;
|
|
2623
|
+
return domain === pattern;
|
|
2624
|
+
}
|
|
2625
|
+
function reSubject(subject) {
|
|
2626
|
+
return /^re:/i.test(subject) ? subject : `Re: ${normalizeThreadSubject(subject)}`;
|
|
2627
|
+
}
|
|
2628
|
+
function fwdSubject(subject) {
|
|
2629
|
+
return /^fwd?:/i.test(subject) ? subject : `Fwd: ${normalizeThreadSubject(subject)}`;
|
|
2630
|
+
}
|
|
2631
|
+
/**
|
|
2632
|
+
* The forwarded body: the agent's optional note, then the quoted parent. Built at
|
|
2633
|
+
* SUBMIT time (not at approval) so the human reviews the exact bytes that go out —
|
|
2634
|
+
* re-deriving it from the live parent at approval would silently discard the
|
|
2635
|
+
* reviewer's edit.
|
|
2636
|
+
*/
|
|
2637
|
+
function forwardBody(note, parent) {
|
|
2638
|
+
return `${note ?? ""}\n\n---------- Forwarded message ----------\nFrom: ${fmtFrom(parent)}\nSubject: ${parent.subject}\n\n${parent.text}`;
|
|
2639
|
+
}
|
|
2640
|
+
/** Strip leading Re:/Fwd: prefixes for a thread's display subject. */
|
|
2641
|
+
function normalizeThreadSubject(subject) {
|
|
2642
|
+
let s = subject.trim();
|
|
2643
|
+
for (;;) {
|
|
2644
|
+
const next = s.replace(/^(re|fwd|fw)\s*:\s*/i, "");
|
|
2645
|
+
if (next === s)
|
|
2646
|
+
break;
|
|
2647
|
+
s = next;
|
|
2648
|
+
}
|
|
2649
|
+
return s.trim();
|
|
2650
|
+
}
|
|
2651
|
+
function fmtFrom(m) {
|
|
2652
|
+
return m.from.name ? `${m.from.name} <${m.from.email}>` : m.from.email;
|
|
2653
|
+
}
|
|
2654
|
+
function dedupeAddresses(addresses) {
|
|
2655
|
+
const seen = new Set();
|
|
2656
|
+
const out = [];
|
|
2657
|
+
for (const a of addresses) {
|
|
2658
|
+
const key = a.email.toLowerCase();
|
|
2659
|
+
if (seen.has(key))
|
|
2660
|
+
continue;
|
|
2661
|
+
seen.add(key);
|
|
2662
|
+
out.push(a);
|
|
2663
|
+
}
|
|
2664
|
+
return out;
|
|
2665
|
+
}
|
|
2666
|
+
function delay(ms) {
|
|
2667
|
+
return new Promise((resolve) => {
|
|
2668
|
+
const t = setTimeout(resolve, ms);
|
|
2669
|
+
t.unref?.();
|
|
2670
|
+
});
|
|
2671
|
+
}
|
|
2672
|
+
function compileFixtureRegex(pattern) {
|
|
2673
|
+
const explicitInsensitive = pattern.startsWith("(?i)");
|
|
2674
|
+
const source = explicitInsensitive ? pattern.slice(4) : pattern;
|
|
2675
|
+
return new RegExp(source, explicitInsensitive ? "i" : undefined);
|
|
2676
|
+
}
|
|
2677
|
+
/** Decoded byte length of a base64 string (mirrors the Go-reported size). */
|
|
2678
|
+
function base64ByteLength(b64) {
|
|
2679
|
+
const clean = b64.replace(/[\r\n\s]/g, "");
|
|
2680
|
+
if (clean.length === 0)
|
|
2681
|
+
return 0;
|
|
2682
|
+
const padding = clean.endsWith("==") ? 2 : clean.endsWith("=") ? 1 : 0;
|
|
2683
|
+
return Math.floor((clean.length * 3) / 4) - padding;
|
|
2684
|
+
}
|
|
2685
|
+
//# sourceMappingURL=fixtures.js.map
|