@pithy-sh/support 0.1.2 → 0.1.4
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/dist/ai/classify.d.ts +92 -0
- package/dist/ai/classify.d.ts.map +1 -0
- package/dist/ai/classify.js +153 -0
- package/dist/attachment/store.d.ts +55 -0
- package/dist/attachment/store.d.ts.map +1 -0
- package/dist/attachment/store.js +69 -0
- package/dist/audit/actions.d.ts +70 -0
- package/dist/audit/actions.d.ts.map +1 -0
- package/dist/audit/actions.js +70 -0
- package/dist/capability.d.ts +95 -0
- package/dist/capability.d.ts.map +1 -0
- package/dist/capability.js +189 -0
- package/dist/client/projection.d.ts +58 -0
- package/dist/client/projection.d.ts.map +1 -0
- package/dist/client/projection.js +2 -0
- package/dist/config/config.d.ts +217 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +171 -0
- package/dist/data/attachment.d.ts +32 -0
- package/dist/data/attachment.d.ts.map +1 -0
- package/dist/data/attachment.js +34 -0
- package/dist/data/billingScope.d.ts +32 -0
- package/dist/data/billingScope.d.ts.map +1 -0
- package/dist/data/billingScope.js +34 -0
- package/dist/data/categories.d.ts +56 -0
- package/dist/data/categories.d.ts.map +1 -0
- package/dist/data/categories.js +99 -0
- package/dist/data/classification.d.ts +36 -0
- package/dist/data/classification.d.ts.map +1 -0
- package/dist/data/classification.js +30 -0
- package/dist/data/enums.d.ts +84 -0
- package/dist/data/enums.d.ts.map +1 -0
- package/dist/data/enums.js +72 -0
- package/dist/data/flag.d.ts +27 -0
- package/dist/data/flag.d.ts.map +1 -0
- package/dist/data/flag.js +29 -0
- package/dist/data/message.d.ts +83 -0
- package/dist/data/message.d.ts.map +1 -0
- package/dist/data/message.js +86 -0
- package/dist/data/tables.d.ts +162 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/data/tables.js +46 -0
- package/dist/data/thread.d.ts +79 -0
- package/dist/data/thread.d.ts.map +1 -0
- package/dist/data/thread.js +67 -0
- package/dist/error/errors.d.ts +66 -0
- package/dist/error/errors.d.ts.map +1 -0
- package/dist/error/errors.js +84 -0
- package/dist/http/guards.d.ts +46 -0
- package/dist/http/guards.d.ts.map +1 -0
- package/dist/http/guards.js +55 -0
- package/dist/http/handlers.d.ts +91 -0
- package/dist/http/handlers.d.ts.map +1 -0
- package/dist/http/handlers.js +250 -0
- package/dist/http/resolve.d.ts +46 -0
- package/dist/http/resolve.d.ts.map +1 -0
- package/dist/http/resolve.js +74 -0
- package/dist/http/responses.d.ts +831 -0
- package/dist/http/responses.d.ts.map +1 -0
- package/dist/http/responses.js +305 -0
- package/dist/http/routes.d.ts +111 -0
- package/dist/http/routes.d.ts.map +1 -0
- package/dist/http/routes.js +64 -0
- package/dist/http/schemas.d.ts +123 -0
- package/dist/http/schemas.d.ts.map +1 -0
- package/dist/http/schemas.js +91 -0
- package/dist/http/scopes.d.ts +58 -0
- package/dist/http/scopes.d.ts.map +1 -0
- package/dist/http/scopes.js +109 -0
- package/dist/http/views.d.ts +38 -0
- package/dist/http/views.d.ts.map +1 -0
- package/dist/http/views.js +146 -0
- package/dist/inbound/authenticity.d.ts +83 -0
- package/dist/inbound/authenticity.d.ts.map +1 -0
- package/dist/inbound/authenticity.js +63 -0
- package/dist/inbound/guard.d.ts +76 -0
- package/dist/inbound/guard.d.ts.map +1 -0
- package/dist/inbound/guard.js +80 -0
- package/dist/inbound/handler.d.ts +30 -0
- package/dist/inbound/handler.d.ts.map +1 -0
- package/dist/inbound/handler.js +84 -0
- package/dist/inbound/ingest.d.ts +106 -0
- package/dist/inbound/ingest.d.ts.map +1 -0
- package/dist/inbound/ingest.js +328 -0
- package/dist/inbound/recipient.d.ts +18 -0
- package/dist/inbound/recipient.d.ts.map +1 -0
- package/dist/inbound/recipient.js +48 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/link/sender.d.ts +162 -0
- package/dist/link/sender.d.ts.map +1 -0
- package/dist/link/sender.js +202 -0
- package/dist/migrations/0001_threads.d.ts +28 -0
- package/dist/migrations/0001_threads.d.ts.map +1 -0
- package/dist/migrations/0001_threads.js +105 -0
- package/dist/mime/address.d.ts +24 -0
- package/dist/mime/address.d.ts.map +1 -0
- package/dist/mime/address.js +30 -0
- package/dist/mime/parse.d.ts +98 -0
- package/dist/mime/parse.d.ts.map +1 -0
- package/dist/mime/parse.js +148 -0
- package/dist/mime/sanitize.d.ts +27 -0
- package/dist/mime/sanitize.d.ts.map +1 -0
- package/dist/mime/sanitize.js +214 -0
- package/dist/mime/threading.d.ts +81 -0
- package/dist/mime/threading.d.ts.map +1 -0
- package/dist/mime/threading.js +113 -0
- package/dist/mime/truncate.d.ts +19 -0
- package/dist/mime/truncate.d.ts.map +1 -0
- package/dist/mime/truncate.js +49 -0
- package/dist/provision/provisionSupport.d.ts +160 -0
- package/dist/provision/provisionSupport.d.ts.map +1 -0
- package/dist/provision/provisionSupport.js +112 -0
- package/dist/provision/resolveSupportConfig.d.ts +47 -0
- package/dist/provision/resolveSupportConfig.d.ts.map +1 -0
- package/dist/provision/resolveSupportConfig.js +24 -0
- package/dist/reply/send.d.ts +125 -0
- package/dist/reply/send.d.ts.map +1 -0
- package/dist/reply/send.js +155 -0
- package/dist/reply/snippets.d.ts +81 -0
- package/dist/reply/snippets.d.ts.map +1 -0
- package/dist/reply/snippets.js +137 -0
- package/dist/secret/registry.d.ts +20 -0
- package/dist/secret/registry.d.ts.map +1 -0
- package/dist/secret/registry.js +23 -0
- package/dist/seeds/example.d.ts +5 -0
- package/dist/seeds/example.d.ts.map +1 -0
- package/dist/seeds/example.js +352 -0
- package/dist/store/paging.d.ts +20 -0
- package/dist/store/paging.d.ts.map +1 -0
- package/dist/store/paging.js +22 -0
- package/dist/store/search.d.ts +69 -0
- package/dist/store/search.d.ts.map +1 -0
- package/dist/store/search.js +162 -0
- package/dist/store/searchIndex.d.ts +55 -0
- package/dist/store/searchIndex.d.ts.map +1 -0
- package/dist/store/searchIndex.js +68 -0
- package/dist/store/threads.d.ts +149 -0
- package/dist/store/threads.d.ts.map +1 -0
- package/dist/store/threads.js +199 -0
- package/dist/submission/encoding.d.ts +23 -0
- package/dist/submission/encoding.d.ts.map +1 -0
- package/dist/submission/encoding.js +57 -0
- package/dist/submission/guard.d.ts +73 -0
- package/dist/submission/guard.d.ts.map +1 -0
- package/dist/submission/guard.js +48 -0
- package/dist/submission/submit.d.ts +154 -0
- package/dist/submission/submit.d.ts.map +1 -0
- package/dist/submission/submit.js +321 -0
- package/dist/version.generated.d.ts +7 -0
- package/dist/version.generated.d.ts.map +1 -0
- package/dist/version.generated.js +9 -0
- package/dist/workflows/classify.d.ts +65 -0
- package/dist/workflows/classify.d.ts.map +1 -0
- package/dist/workflows/classify.js +70 -0
- package/dist/workflows/retryPolicy.d.ts +41 -0
- package/dist/workflows/retryPolicy.d.ts.map +1 -0
- package/dist/workflows/retryPolicy.js +45 -0
- package/dist/workflows/specs.d.ts +45 -0
- package/dist/workflows/specs.d.ts.map +1 -0
- package/dist/workflows/specs.js +48 -0
- package/dist/workflows/worker.d.ts +50 -0
- package/dist/workflows/worker.d.ts.map +1 -0
- package/dist/workflows/worker.js +39 -0
- package/package.json +24 -15
- package/src/http/handlers.ts +1 -1
- package/src/submission/encoding.ts +1 -1
- package/src/submission/submit.ts +1 -1
- package/src/version.generated.ts +1 -1
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { SupportInvalidCategoryError, SupportNotFoundError, SupportRejectedError } from "../error/errors.js";
|
|
4
|
+
import { categoryEnum } from "../data/categories.js";
|
|
5
|
+
import { SupportAttachment } from "../data/attachment.js";
|
|
6
|
+
import { SupportMessage } from "../data/message.js";
|
|
7
|
+
import { SupportThread, UNCLASSIFIED } from "../data/thread.js";
|
|
8
|
+
import { SUPPORT_ATTACHMENTS_TABLE, SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE } from "../data/tables.js";
|
|
9
|
+
import { attachmentKey, putAttachment, sha256Hex } from "../attachment/store.js";
|
|
10
|
+
import { SupportAuditActions } from "../audit/actions.js";
|
|
11
|
+
import { indexMessage } from "../store/search.js";
|
|
12
|
+
import { safeFilename } from "../mime/parse.js";
|
|
13
|
+
import { checkAccountRate, checkAttachment } from "./guard.js";
|
|
14
|
+
import { InternalError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
15
|
+
import { chunkRowsByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
|
|
16
|
+
//#region src/submission/submit.ts
|
|
17
|
+
/** Record a refusal. Never throws — a guard decision must not become a 500 on top of a 429. */
|
|
18
|
+
async function auditRejection(deps, reason, detail, userId) {
|
|
19
|
+
try {
|
|
20
|
+
await deps.emit({
|
|
21
|
+
action: SupportAuditActions.submissionRejected,
|
|
22
|
+
outcome: "denied",
|
|
23
|
+
severity: "warning",
|
|
24
|
+
actorType: "user",
|
|
25
|
+
actorId: userId,
|
|
26
|
+
resourceType: "support_inbox",
|
|
27
|
+
metadata: {
|
|
28
|
+
reason,
|
|
29
|
+
channel: "app"
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
deps.log.warn("support submission refused", {
|
|
33
|
+
reason,
|
|
34
|
+
detail,
|
|
35
|
+
userId
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
deps.log.warn("support submission rejection audit dropped", {
|
|
39
|
+
reason,
|
|
40
|
+
detail,
|
|
41
|
+
error
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the thread a submission continues.
|
|
47
|
+
*
|
|
48
|
+
* **Both conditions, and a 404 rather than a 403 when either fails.** The thread must be this
|
|
49
|
+
* account's and it must be an app thread: a 403 would confirm that the id names a real conversation,
|
|
50
|
+
* which on an inbox of other people's correspondence is the disclosure the check exists to prevent —
|
|
51
|
+
* so an id belonging to somebody else is indistinguishable from one belonging to nobody.
|
|
52
|
+
*
|
|
53
|
+
* An `email` thread is excluded even when it carries this account's `userId`, and that exclusion is
|
|
54
|
+
* the point rather than an oversight: that link was matched from an address in a header nobody proved,
|
|
55
|
+
* so treating it as ownership would let whoever currently holds an address read a conversation that
|
|
56
|
+
* merely *claims* to be theirs.
|
|
57
|
+
*/
|
|
58
|
+
async function resolveOwnThread(db, threadId, userId) {
|
|
59
|
+
const thread = await db.selectFrom(SUPPORT_THREADS_TABLE).select(["id"]).where("id", "=", threadId).where("userId", "=", userId).where("channel", "=", "app").executeTakeFirst();
|
|
60
|
+
if (!thread) throw new SupportNotFoundError({ detail: `no app thread ${threadId} owned by ${userId}` });
|
|
61
|
+
return thread;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Check a submitter's declared category, before anything else and before any I/O.
|
|
65
|
+
*
|
|
66
|
+
* **An undeclared key is refused, never stored and never downgraded.** Storing it would make
|
|
67
|
+
* `declared_category` a client-writable vocabulary: one client shipping `Billing` or `billng` and the
|
|
68
|
+
* console's filter silently misses those threads forever, while the inbox grows a long tail of one-off
|
|
69
|
+
* keys nobody declared. Downgrading to `uncategorized` is the worse of the two remaining options,
|
|
70
|
+
* because it makes a broken chooser indistinguishable from a submitter who genuinely chose nothing —
|
|
71
|
+
* the exact collapse this column exists to prevent, arrived at from the other side.
|
|
72
|
+
*
|
|
73
|
+
* It is refused rather than tolerated **because the writer can be fixed.** `classifyMessage` falls back
|
|
74
|
+
* to `uncategorized` on a label the model invented, and that is right there: a model cannot be told it
|
|
75
|
+
* was wrong at the call site. A client can, and a 400 is how it gets told — the chooser was built from
|
|
76
|
+
* a taxonomy the adopter declared, so a value outside it is that client's bug and should be loud.
|
|
77
|
+
*
|
|
78
|
+
* **A category may not ride along with a `threadId`.** A thread already carries what it was filed
|
|
79
|
+
* under, and a second, later claim has no honest meaning: ignoring it is a chooser that does nothing —
|
|
80
|
+
* which is the whole defect this seam exists to close — and honoring it would let a follow-up rewrite
|
|
81
|
+
* the premise a conversation was opened on, the way `subject` deliberately cannot. So it is refused,
|
|
82
|
+
* loudly, and a chooser stays what it is: something offered on the form that opens a request.
|
|
83
|
+
*
|
|
84
|
+
* **Nothing the submitter wrote reaches `message`.** The offending value goes in `detail`, which the
|
|
85
|
+
* HTTP codec strips — this capability's whole input is text somebody else chose, and an error that
|
|
86
|
+
* echoed it back would make the error channel a reflection surface.
|
|
87
|
+
*/
|
|
88
|
+
function checkDeclaredCategory(deps, input) {
|
|
89
|
+
const declared = input.declaredCategory;
|
|
90
|
+
if (declared === void 0) return;
|
|
91
|
+
if (input.threadId !== void 0) throw new ValidationError({
|
|
92
|
+
message: "A category is chosen when a request is opened, not on a later message.",
|
|
93
|
+
action: "Send the category with the first message, or leave it off to continue the conversation.",
|
|
94
|
+
detail: `submission declared a category alongside threadId ${input.threadId}`
|
|
95
|
+
});
|
|
96
|
+
if (!categoryEnum(deps.categories).safeParse(declared).success) throw new SupportInvalidCategoryError({
|
|
97
|
+
message: "That is not a category this app offers.",
|
|
98
|
+
action: "Choose one of the categories the app declares, or send the request without one.",
|
|
99
|
+
detail: `submitted category ${JSON.stringify(declared)} is not in the effective taxonomy (${Object.keys(deps.categories).join(", ")})`
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** How many columns one `pithy_support_attachments` row binds — derived, so a new column re-chunks. */
|
|
103
|
+
const SUPPORT_ATTACHMENT_COLUMNS = Object.keys(SupportAttachment.shape).length;
|
|
104
|
+
/**
|
|
105
|
+
* Check every attachment before one byte is written.
|
|
106
|
+
*
|
|
107
|
+
* All-or-nothing, and refused rather than skipped — the opposite of the mail path, and the difference
|
|
108
|
+
* is who is on the other end. An oversize part of an email is dropped because the sender is gone and
|
|
109
|
+
* the message is still worth keeping; a submitter is right there, holding the screenshot that is the
|
|
110
|
+
* most valuable thing the report carries, and silently storing the report without it means the first
|
|
111
|
+
* reply asks for a file they believed they had already sent.
|
|
112
|
+
*/
|
|
113
|
+
function checkAttachments(deps, attachments) {
|
|
114
|
+
const bounds = deps.config.submission.attachments;
|
|
115
|
+
if (attachments.length === 0) return;
|
|
116
|
+
if (!bounds.enabled) throw new ValidationError({
|
|
117
|
+
message: "This app does not accept attachments on a support request.",
|
|
118
|
+
action: "Send the report without a file, and describe what you would have attached.",
|
|
119
|
+
detail: "submission.attachments.enabled is false"
|
|
120
|
+
});
|
|
121
|
+
if (attachments.length > bounds.maxCount) throw new ValidationError({
|
|
122
|
+
message: `A support request may carry at most ${bounds.maxCount} attachments.`,
|
|
123
|
+
action: `Send at most ${bounds.maxCount} files.`,
|
|
124
|
+
detail: `submission declared ${attachments.length} attachments, over the ${bounds.maxCount} bound`
|
|
125
|
+
});
|
|
126
|
+
for (const attachment of attachments) {
|
|
127
|
+
const verdict = checkAttachment(deps.config.submission, {
|
|
128
|
+
contentType: attachment.contentType,
|
|
129
|
+
bytes: attachment.bytes.byteLength
|
|
130
|
+
});
|
|
131
|
+
if (verdict.accepted) continue;
|
|
132
|
+
throw new ValidationError({
|
|
133
|
+
message: verdict.reason === "attachment_too_large" ? `An attachment may be at most ${bounds.maxBytes} bytes.` : "That kind of file cannot be attached to a support request.",
|
|
134
|
+
action: verdict.reason === "attachment_too_large" ? "Attach a smaller file." : `Attach one of: ${bounds.allowedContentTypes.join(", ")}.`,
|
|
135
|
+
detail: verdict.detail
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Store the checked attachments. Returns how many rows were written. */
|
|
140
|
+
async function storeAttachments(deps, attachments, threadId, messageId, now) {
|
|
141
|
+
const bucket = deps.bucket;
|
|
142
|
+
if (attachments.length === 0) return 0;
|
|
143
|
+
if (!bucket) {
|
|
144
|
+
deps.log.warn("support submission attachments dropped — no bucket bound", {
|
|
145
|
+
threadId,
|
|
146
|
+
messageId
|
|
147
|
+
});
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
const rows = [];
|
|
151
|
+
for (const attachment of attachments) {
|
|
152
|
+
const id = deps.newId();
|
|
153
|
+
const key = attachmentKey(threadId, id);
|
|
154
|
+
await putAttachment(bucket, key, attachment.bytes);
|
|
155
|
+
rows.push({
|
|
156
|
+
id,
|
|
157
|
+
messageId,
|
|
158
|
+
threadId,
|
|
159
|
+
filename: safeFilename(attachment.filename),
|
|
160
|
+
contentType: attachment.contentType,
|
|
161
|
+
size: attachment.bytes.byteLength,
|
|
162
|
+
sha256: await sha256Hex(attachment.bytes),
|
|
163
|
+
storageKey: key,
|
|
164
|
+
contentId: null,
|
|
165
|
+
inline: false,
|
|
166
|
+
createdAt: now
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const encoded = rows.map((row) => SupportAttachment.encode(row));
|
|
170
|
+
for (const chunk of chunkRowsByBoundParameters(encoded, SUPPORT_ATTACHMENT_COLUMNS)) await deps.db.insertInto(SUPPORT_ATTACHMENTS_TABLE).values(chunk).execute();
|
|
171
|
+
return rows.length;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Mint an RFC 5322 message id for a submission.
|
|
175
|
+
*
|
|
176
|
+
* **Without this the conversation forks the first time the customer answers.** A reply to an app thread
|
|
177
|
+
* leaves by email carrying `In-Reply-To`, the customer's client puts that id in its `References`, and
|
|
178
|
+
* `parentCandidates` looks it up in `pithy_support_messages` — so a submission stored with no id of its
|
|
179
|
+
* own is a thread whose every answer opens a new one. The whole reason `mime/threading.ts` refuses to
|
|
180
|
+
* match on subject is that fragmented conversations are the failure mode worth engineering against.
|
|
181
|
+
*
|
|
182
|
+
* The domain comes from the inbox address, because that is the domain the reply will actually leave
|
|
183
|
+
* from; with no address configured there is no reply and therefore nothing to thread.
|
|
184
|
+
*/
|
|
185
|
+
function submissionMessageId(messageId, inboxAddress) {
|
|
186
|
+
const domain = inboxAddress?.split("@")[1];
|
|
187
|
+
return domain ? `${messageId}@${domain}` : null;
|
|
188
|
+
}
|
|
189
|
+
/** Take one in-app submission. */
|
|
190
|
+
async function submitFeedback(deps, input) {
|
|
191
|
+
const now = deps.now();
|
|
192
|
+
checkDeclaredCategory(deps, input);
|
|
193
|
+
const account = await deps.resolveAccount(input.userId);
|
|
194
|
+
if (!account) throw new InternalError({
|
|
195
|
+
message: "Your account could not be read.",
|
|
196
|
+
action: "Retry, and contact support by email if it keeps happening.",
|
|
197
|
+
detail: `no pithy_auth_users row for authenticated user ${input.userId} — is @pithy-sh/auth composed on this Worker?`
|
|
198
|
+
});
|
|
199
|
+
const rate = await checkAccountRate(deps.db, deps.config.submission, {
|
|
200
|
+
userId: input.userId,
|
|
201
|
+
now
|
|
202
|
+
});
|
|
203
|
+
if (!rate.accepted) {
|
|
204
|
+
await auditRejection(deps, rate.reason, rate.detail, input.userId);
|
|
205
|
+
throw new SupportRejectedError({
|
|
206
|
+
message: "You have sent too many support requests recently.",
|
|
207
|
+
action: "Wait an hour and try again, or reply to the email on an existing request.",
|
|
208
|
+
detail: rate.detail
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
checkAttachments(deps, input.attachments);
|
|
212
|
+
const existing = input.threadId ? await resolveOwnThread(deps.db, input.threadId, input.userId) : void 0;
|
|
213
|
+
const threadId = existing?.id ?? deps.newId();
|
|
214
|
+
const newThread = existing === void 0;
|
|
215
|
+
const messageId = deps.newId();
|
|
216
|
+
const inbox = deps.config.reply.replyToAddress ?? deps.config.inboundAddresses[0] ?? null;
|
|
217
|
+
const message = {
|
|
218
|
+
id: messageId,
|
|
219
|
+
threadId,
|
|
220
|
+
direction: "inbound",
|
|
221
|
+
channel: "app",
|
|
222
|
+
submittedByUserId: input.userId,
|
|
223
|
+
context: input.context ?? null,
|
|
224
|
+
mimeMessageId: submissionMessageId(messageId, inbox),
|
|
225
|
+
mimeInReplyTo: null,
|
|
226
|
+
mimeReferences: null,
|
|
227
|
+
fromAddress: account.email,
|
|
228
|
+
fromName: account.name ?? null,
|
|
229
|
+
toAddress: inbox,
|
|
230
|
+
subject: input.subject,
|
|
231
|
+
textBody: input.body,
|
|
232
|
+
htmlBody: null,
|
|
233
|
+
emailJobId: null,
|
|
234
|
+
rawKey: null,
|
|
235
|
+
rawBytes: null,
|
|
236
|
+
receivedAt: now,
|
|
237
|
+
createdAt: now
|
|
238
|
+
};
|
|
239
|
+
await deps.db.insertInto(SUPPORT_MESSAGES_TABLE).values(SupportMessage.encode(message)).execute();
|
|
240
|
+
try {
|
|
241
|
+
if (newThread) {
|
|
242
|
+
const thread = {
|
|
243
|
+
id: threadId,
|
|
244
|
+
channel: "app",
|
|
245
|
+
inboxAddress: inbox,
|
|
246
|
+
subject: input.subject,
|
|
247
|
+
fromAddress: account.email,
|
|
248
|
+
fromName: account.name ?? null,
|
|
249
|
+
senderAuthenticated: true,
|
|
250
|
+
userId: input.userId,
|
|
251
|
+
accountLinkSource: "session",
|
|
252
|
+
declaredCategory: input.declaredCategory ?? null,
|
|
253
|
+
...UNCLASSIFIED,
|
|
254
|
+
archived: false,
|
|
255
|
+
archivedAt: null,
|
|
256
|
+
archivedBy: null,
|
|
257
|
+
messageCount: 1,
|
|
258
|
+
firstMessageAt: now,
|
|
259
|
+
lastMessageAt: now,
|
|
260
|
+
createdAt: now,
|
|
261
|
+
updatedAt: now
|
|
262
|
+
};
|
|
263
|
+
await deps.db.insertInto(SUPPORT_THREADS_TABLE).values(SupportThread.encode(thread)).execute();
|
|
264
|
+
} else await deps.db.updateTable(SUPPORT_THREADS_TABLE).set((eb) => ({
|
|
265
|
+
messageCount: eb("messageCount", "+", 1),
|
|
266
|
+
lastMessageAt: now.getTime(),
|
|
267
|
+
updatedAt: now.getTime(),
|
|
268
|
+
archived: 0,
|
|
269
|
+
archivedAt: null,
|
|
270
|
+
archivedBy: null
|
|
271
|
+
})).where("id", "=", threadId).execute();
|
|
272
|
+
} catch (error) {
|
|
273
|
+
await deps.db.deleteFrom(SUPPORT_MESSAGES_TABLE).where("id", "=", messageId).execute().catch(() => {
|
|
274
|
+
deps.log.error("support submission orphaned — thread write failed and the message could not be removed", {
|
|
275
|
+
messageId,
|
|
276
|
+
threadId
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
if (deps.fts) try {
|
|
282
|
+
await indexMessage(deps.db, {
|
|
283
|
+
threadId,
|
|
284
|
+
messageId,
|
|
285
|
+
subject: input.subject,
|
|
286
|
+
body: input.body
|
|
287
|
+
});
|
|
288
|
+
} catch (error) {
|
|
289
|
+
deps.log.warn("support submission not indexed", {
|
|
290
|
+
messageId,
|
|
291
|
+
error
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
let attachments = 0;
|
|
295
|
+
try {
|
|
296
|
+
attachments = await storeAttachments(deps, input.attachments, threadId, messageId, now);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
deps.log.warn("support submission attachments not stored", {
|
|
299
|
+
messageId,
|
|
300
|
+
error
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
const classifying = deps.config.ai.enabled;
|
|
304
|
+
if (classifying) try {
|
|
305
|
+
await deps.dispatchClassify(messageId);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
deps.log.warn("support submission classification dispatch failed", {
|
|
308
|
+
messageId,
|
|
309
|
+
error
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
threadId,
|
|
314
|
+
messageId,
|
|
315
|
+
newThread,
|
|
316
|
+
attachments,
|
|
317
|
+
classifying
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
//#endregion
|
|
321
|
+
export { submissionMessageId, submitFeedback };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/** This package's npm name — the join key against a release feed. */
|
|
4
|
+
export declare const PACKAGE_NAME = "@pithy-sh/support";
|
|
5
|
+
/** This package's version, stamped from its own package.json at generation time. */
|
|
6
|
+
export declare const PACKAGE_VERSION = "0.1.4";
|
|
7
|
+
//# sourceMappingURL=version.generated.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version.generated.d.ts","sourceRoot":"","sources":["../src/version.generated.ts"],"names":[],"mappings":"AAWA,qEAAqE;AACrE,eAAO,MAAM,YAAY,sBAAsB,CAAC;AAEhD,oFAAoF;AACpF,eAAO,MAAM,eAAe,UAAU,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//#region src/version.generated.ts
|
|
4
|
+
/** This package's npm name — the join key against a release feed. */
|
|
5
|
+
const PACKAGE_NAME = "@pithy-sh/support";
|
|
6
|
+
/** This package's version, stamped from its own package.json at generation time. */
|
|
7
|
+
const PACKAGE_VERSION = "0.1.4";
|
|
8
|
+
//#endregion
|
|
9
|
+
export { PACKAGE_NAME, PACKAGE_VERSION };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { SupportAi } from "../ai/classify";
|
|
4
|
+
import type { SupportAiConfig } from "../config/config";
|
|
5
|
+
import type { SupportCategories } from "../data/categories";
|
|
6
|
+
import { type SupportDatabase } from "../data/tables";
|
|
7
|
+
/**
|
|
8
|
+
* The classification orchestration — the pure, testable core the Workflow step runs.
|
|
9
|
+
*
|
|
10
|
+
* Reads a message, asks the model about it, **appends** a classification row, and denormalizes the
|
|
11
|
+
* answer onto its thread. The two writes are deliberate and asymmetric: the history is append-only
|
|
12
|
+
* so a model upgrade is visible in the data rather than a silent rewrite, and the thread carries only
|
|
13
|
+
* the current answer because that is what the inbox query reads.
|
|
14
|
+
*
|
|
15
|
+
* Idempotent by construction. Re-running over the same message appends a second row and overwrites
|
|
16
|
+
* the same three thread columns with a fresh judgment — which is what makes a Workflow retry, a
|
|
17
|
+
* manual reclassify, and a post-upgrade backfill the same operation.
|
|
18
|
+
*/
|
|
19
|
+
/** Everything classification needs, all injectable. */
|
|
20
|
+
export interface ClassifyDeps {
|
|
21
|
+
/** The support tables. */
|
|
22
|
+
db: SupportDatabase;
|
|
23
|
+
/** The Workers AI binding. */
|
|
24
|
+
ai: SupportAi;
|
|
25
|
+
/** The effective taxonomy — the defaults plus the adopter's. */
|
|
26
|
+
categories: SupportCategories;
|
|
27
|
+
/** Model, bounds, and temperature. */
|
|
28
|
+
ai_config: SupportAiConfig;
|
|
29
|
+
/**
|
|
30
|
+
* Archive a thread the classifier calls `spam`, so it never reaches the open inbox.
|
|
31
|
+
*
|
|
32
|
+
* **Archived, never deleted.** The classifier is wrong sometimes, and a pass that destroyed mail
|
|
33
|
+
* would be untrustworthy the first time it was — whereas an archived thread is still readable under
|
|
34
|
+
* the archived filter and one click from being back. That is what makes `spam` a filter rather than
|
|
35
|
+
* a bin, and it is why this is safe to have on by default.
|
|
36
|
+
*/
|
|
37
|
+
archiveSpam: boolean;
|
|
38
|
+
/** Generate a row id. */
|
|
39
|
+
newId: () => string;
|
|
40
|
+
/** Now. */
|
|
41
|
+
now: () => Date;
|
|
42
|
+
}
|
|
43
|
+
/** What one classification run did. `null` when the message no longer exists. */
|
|
44
|
+
export interface ClassifyOutcome {
|
|
45
|
+
/** The thread the classification landed on. */
|
|
46
|
+
threadId: string;
|
|
47
|
+
/** The category chosen. */
|
|
48
|
+
category: string;
|
|
49
|
+
/** The model's confidence. */
|
|
50
|
+
confidence: number;
|
|
51
|
+
/** The model that produced it. */
|
|
52
|
+
model: string;
|
|
53
|
+
/** Whether this run archived the thread because it classified as spam. */
|
|
54
|
+
archivedAsSpam: boolean;
|
|
55
|
+
}
|
|
56
|
+
/** Classify one stored message and write the result. Returns null when the message is gone. */
|
|
57
|
+
export declare function runClassification(deps: ClassifyDeps, messageId: string): Promise<ClassifyOutcome | null>;
|
|
58
|
+
/**
|
|
59
|
+
* The message a manual reclassify runs against: a thread's most recent **inbound** message.
|
|
60
|
+
*
|
|
61
|
+
* Inbound, not latest — a thread whose last message is our own reply would otherwise be reclassified
|
|
62
|
+
* on the text the adopter wrote, which says nothing about what the customer wanted.
|
|
63
|
+
*/
|
|
64
|
+
export declare function latestInboundMessageId(db: SupportDatabase, threadId: string): Promise<string | undefined>;
|
|
65
|
+
//# sourceMappingURL=classify.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"classify.d.ts","sourceRoot":"","sources":["../../src/workflows/classify.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAG5D,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AAExB;;;;;;;;;;;GAWG;AAEH,uDAAuD;AACvD,MAAM,WAAW,YAAY;IAC3B,0BAA0B;IAC1B,EAAE,EAAE,eAAe,CAAC;IACpB,8BAA8B;IAC9B,EAAE,EAAE,SAAS,CAAC;IACd,gEAAgE;IAChE,UAAU,EAAE,iBAAiB,CAAC;IAC9B,sCAAsC;IACtC,SAAS,EAAE,eAAe,CAAC;IAC3B;;;;;;;OAOG;IACH,WAAW,EAAE,OAAO,CAAC;IACrB,yBAAyB;IACzB,KAAK,EAAE,MAAM,MAAM,CAAC;IACpB,WAAW;IACX,GAAG,EAAE,MAAM,IAAI,CAAC;CACjB;AAED,iFAAiF;AACjF,MAAM,WAAW,eAAe;IAC9B,+CAA+C;IAC/C,QAAQ,EAAE,MAAM,CAAC;IACjB,2BAA2B;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,8BAA8B;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,kCAAkC;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,0EAA0E;IAC1E,cAAc,EAAE,OAAO,CAAC;CACzB;AAED,+FAA+F;AAC/F,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAwE9G;AAED;;;;;GAKG;AACH,wBAAsB,sBAAsB,CAAC,EAAE,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAc/G"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import "../data/enums.js";
|
|
4
|
+
import { SupportClassification } from "../data/classification.js";
|
|
5
|
+
import { SUPPORT_CLASSIFICATIONS_TABLE, SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE } from "../data/tables.js";
|
|
6
|
+
import { classifyMessage } from "../ai/classify.js";
|
|
7
|
+
//#region src/workflows/classify.ts
|
|
8
|
+
/** Classify one stored message and write the result. Returns null when the message is gone. */
|
|
9
|
+
async function runClassification(deps, messageId) {
|
|
10
|
+
const message = await deps.db.selectFrom(SUPPORT_MESSAGES_TABLE).select([
|
|
11
|
+
"id",
|
|
12
|
+
"threadId",
|
|
13
|
+
"subject",
|
|
14
|
+
"textBody"
|
|
15
|
+
]).where("id", "=", messageId).executeTakeFirst();
|
|
16
|
+
if (!message) return null;
|
|
17
|
+
const result = await classifyMessage(deps.ai, {
|
|
18
|
+
categories: deps.categories,
|
|
19
|
+
subject: message.subject,
|
|
20
|
+
body: message.textBody,
|
|
21
|
+
model: deps.ai_config.model,
|
|
22
|
+
maxChars: deps.ai_config.maxChars,
|
|
23
|
+
temperature: deps.ai_config.temperature
|
|
24
|
+
});
|
|
25
|
+
const now = deps.now();
|
|
26
|
+
await deps.db.insertInto(SUPPORT_CLASSIFICATIONS_TABLE).values(SupportClassification.encode({
|
|
27
|
+
id: deps.newId(),
|
|
28
|
+
threadId: message.threadId,
|
|
29
|
+
messageId: message.id,
|
|
30
|
+
category: result.category,
|
|
31
|
+
priority: result.priority,
|
|
32
|
+
sentiment: result.sentiment,
|
|
33
|
+
confidence: result.confidence,
|
|
34
|
+
model: result.model,
|
|
35
|
+
createdAt: now
|
|
36
|
+
})).execute();
|
|
37
|
+
const archivedAsSpam = deps.archiveSpam && result.category === "spam";
|
|
38
|
+
await deps.db.updateTable(SUPPORT_THREADS_TABLE).set({
|
|
39
|
+
category: result.category,
|
|
40
|
+
priority: result.priority,
|
|
41
|
+
sentiment: result.sentiment,
|
|
42
|
+
confidence: result.confidence,
|
|
43
|
+
model: result.model,
|
|
44
|
+
classifiedAt: now.getTime(),
|
|
45
|
+
updatedAt: now.getTime(),
|
|
46
|
+
...archivedAsSpam ? {
|
|
47
|
+
archived: 1,
|
|
48
|
+
archivedAt: now.getTime(),
|
|
49
|
+
archivedBy: result.model
|
|
50
|
+
} : {}
|
|
51
|
+
}).where("id", "=", message.threadId).execute();
|
|
52
|
+
return {
|
|
53
|
+
threadId: message.threadId,
|
|
54
|
+
category: result.category,
|
|
55
|
+
confidence: result.confidence,
|
|
56
|
+
model: result.model,
|
|
57
|
+
archivedAsSpam
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The message a manual reclassify runs against: a thread's most recent **inbound** message.
|
|
62
|
+
*
|
|
63
|
+
* Inbound, not latest — a thread whose last message is our own reply would otherwise be reclassified
|
|
64
|
+
* on the text the adopter wrote, which says nothing about what the customer wanted.
|
|
65
|
+
*/
|
|
66
|
+
async function latestInboundMessageId(db, threadId) {
|
|
67
|
+
return (await db.selectFrom(SUPPORT_MESSAGES_TABLE).select(["id"]).where("threadId", "=", threadId).where("direction", "=", "inbound").orderBy("receivedAt", "desc").orderBy("id", "desc").limit(1).executeTakeFirst())?.id;
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
export { latestInboundMessageId, runClassification };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { WorkflowRetryPolicy } from "@pithy-sh/core/src/workflow/faults";
|
|
4
|
+
/**
|
|
5
|
+
* **What a classification retries, and what it refuses to.**
|
|
6
|
+
*
|
|
7
|
+
* `ai/classify.ts` already drew this line and drew it correctly: a model that returns prose, an invented
|
|
8
|
+
* label, or an envelope nobody documented yields `uncategorized` at zero confidence and never throws,
|
|
9
|
+
* because throwing would burn a retry budget on a model that is going to say the same thing next time.
|
|
10
|
+
* The one case it deliberately does not swallow is a binding that is *unavailable*. This is that
|
|
11
|
+
* sentence in the form the durable step reads (pithy-sh/pithy#348).
|
|
12
|
+
*
|
|
13
|
+
* ## Retryable, and why
|
|
14
|
+
*
|
|
15
|
+
* - **`core/upstream_failed`** — Workers AI rejected the call. Raised at exactly one place, the wrap
|
|
16
|
+
* around `ai.run` in `classifyMessage`, and it means the model did not answer at all. That is the
|
|
17
|
+
* whole population of transient faults in a classification, and the code is what lets the step tell
|
|
18
|
+
* it from a bad answer — a raw throw would be `unclassified`, and unclassified is terminal.
|
|
19
|
+
* - **A transient D1 fault** — the message read, the appended history row, the thread denormalization.
|
|
20
|
+
* Classified in core by `withD1Retry`, never restated here.
|
|
21
|
+
*
|
|
22
|
+
* ## Terminal, and why
|
|
23
|
+
*
|
|
24
|
+
* - **`support/not_found`** — a thread or message that is gone. `runClassification` does not even raise
|
|
25
|
+
* it for the message it was started for: a missing row returns `null`, because an instance outliving
|
|
26
|
+
* the row that started it is the ordinary way a rollback looks.
|
|
27
|
+
* - **`support/classification_failed`** — the AI binding is absent from the env. A binding does not
|
|
28
|
+
* appear on the fourth attempt; this wants `pithy support provision`.
|
|
29
|
+
* - **`support/invalid_category`** — the adopter's taxonomy will not validate. Config, and identical
|
|
30
|
+
* next time.
|
|
31
|
+
* - **`support/unparseable_message`, `support/rejected`, `support/reply_failed`** — all inbound-path and
|
|
32
|
+
* reply-path refusals. None runs inside the classify step.
|
|
33
|
+
* - **`validation/invalid_input`** — a payload the schema refuses.
|
|
34
|
+
*
|
|
35
|
+
* **Re-running is free, which is what makes the retryable entry safe.** Classification is idempotent by
|
|
36
|
+
* construction: a second pass appends a second history row and overwrites the same three thread columns
|
|
37
|
+
* with a fresh judgment, which is why a Workflow retry, a manual reclassify, and a post-upgrade
|
|
38
|
+
* backfill are the same operation.
|
|
39
|
+
*/
|
|
40
|
+
export declare const supportWorkflowRetry: WorkflowRetryPolicy;
|
|
41
|
+
//# sourceMappingURL=retryPolicy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retryPolicy.d.ts","sourceRoot":"","sources":["../../src/workflows/retryPolicy.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oCAAoC,CAAC;AAE9E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,oBAAoB,EAAE,mBAMlC,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//#region src/workflows/retryPolicy.ts
|
|
4
|
+
/**
|
|
5
|
+
* **What a classification retries, and what it refuses to.**
|
|
6
|
+
*
|
|
7
|
+
* `ai/classify.ts` already drew this line and drew it correctly: a model that returns prose, an invented
|
|
8
|
+
* label, or an envelope nobody documented yields `uncategorized` at zero confidence and never throws,
|
|
9
|
+
* because throwing would burn a retry budget on a model that is going to say the same thing next time.
|
|
10
|
+
* The one case it deliberately does not swallow is a binding that is *unavailable*. This is that
|
|
11
|
+
* sentence in the form the durable step reads (pithy-sh/pithy#348).
|
|
12
|
+
*
|
|
13
|
+
* ## Retryable, and why
|
|
14
|
+
*
|
|
15
|
+
* - **`core/upstream_failed`** — Workers AI rejected the call. Raised at exactly one place, the wrap
|
|
16
|
+
* around `ai.run` in `classifyMessage`, and it means the model did not answer at all. That is the
|
|
17
|
+
* whole population of transient faults in a classification, and the code is what lets the step tell
|
|
18
|
+
* it from a bad answer — a raw throw would be `unclassified`, and unclassified is terminal.
|
|
19
|
+
* - **A transient D1 fault** — the message read, the appended history row, the thread denormalization.
|
|
20
|
+
* Classified in core by `withD1Retry`, never restated here.
|
|
21
|
+
*
|
|
22
|
+
* ## Terminal, and why
|
|
23
|
+
*
|
|
24
|
+
* - **`support/not_found`** — a thread or message that is gone. `runClassification` does not even raise
|
|
25
|
+
* it for the message it was started for: a missing row returns `null`, because an instance outliving
|
|
26
|
+
* the row that started it is the ordinary way a rollback looks.
|
|
27
|
+
* - **`support/classification_failed`** — the AI binding is absent from the env. A binding does not
|
|
28
|
+
* appear on the fourth attempt; this wants `pithy support provision`.
|
|
29
|
+
* - **`support/invalid_category`** — the adopter's taxonomy will not validate. Config, and identical
|
|
30
|
+
* next time.
|
|
31
|
+
* - **`support/unparseable_message`, `support/rejected`, `support/reply_failed`** — all inbound-path and
|
|
32
|
+
* reply-path refusals. None runs inside the classify step.
|
|
33
|
+
* - **`validation/invalid_input`** — a payload the schema refuses.
|
|
34
|
+
*
|
|
35
|
+
* **Re-running is free, which is what makes the retryable entry safe.** Classification is idempotent by
|
|
36
|
+
* construction: a second pass appends a second history row and overwrites the same three thread columns
|
|
37
|
+
* with a fresh judgment, which is why a Workflow retry, a manual reclassify, and a post-upgrade
|
|
38
|
+
* backfill are the same operation.
|
|
39
|
+
*/
|
|
40
|
+
const supportWorkflowRetry = {
|
|
41
|
+
capability: "support",
|
|
42
|
+
retryable: { "core/upstream_failed": "The model did not answer at all; classification is idempotent, so asking again costs one call." }
|
|
43
|
+
};
|
|
44
|
+
//#endregion
|
|
45
|
+
export { supportWorkflowRetry };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { WorkflowRegistry } from "@pithy-sh/core/src/workflow/spec";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
/**
|
|
6
|
+
* The one durable job support owns, declared once.
|
|
7
|
+
*
|
|
8
|
+
* **Classification is a Workflow, not part of the `email()` handler**, and that is a hard constraint
|
|
9
|
+
* rather than a preference. An inbound handler runs under a tight CPU budget; a model call is
|
|
10
|
+
* hundreds of milliseconds of wall time it cannot spend, and a model that is slow or briefly
|
|
11
|
+
* unavailable would take the *persistence* of the message down with it. So the handler does the
|
|
12
|
+
* cheap, must-not-fail half — parse, bound, store — and hands off. A classification that fails
|
|
13
|
+
* retries against a message that is already safely in D1.
|
|
14
|
+
*
|
|
15
|
+
* `optional: true`: the Workflow lives in the prebuilt support worker that `pithy support provision`
|
|
16
|
+
* deploys, so a project that has not provisioned must still boot and still receive mail. An absent
|
|
17
|
+
* binding degrades to a logged skip and a thread that stays `uncategorized` — which is exactly the
|
|
18
|
+
* state a classification is allowed to be in.
|
|
19
|
+
*/
|
|
20
|
+
/** The capability name — the first segment of the dispatch key and of every deployed workflow name. */
|
|
21
|
+
export declare const SUPPORT_CAPABILITY = "support";
|
|
22
|
+
/** Which message to classify. */
|
|
23
|
+
export declare const SupportClassifyParams: z.ZodObject<{
|
|
24
|
+
messageId: z.ZodString;
|
|
25
|
+
}, z.core.$strip>;
|
|
26
|
+
export type SupportClassifyParams = z.infer<typeof SupportClassifyParams>;
|
|
27
|
+
/** Support's durable jobs, keyed by job name. The key is the second segment of the `support/<job>` dispatch key. */
|
|
28
|
+
export declare const supportWorkflows: {
|
|
29
|
+
readonly classify: {
|
|
30
|
+
readonly binding: "SUPPORT_CLASSIFY";
|
|
31
|
+
readonly className: "SupportClassifyWorkflow";
|
|
32
|
+
readonly params: z.ZodObject<{
|
|
33
|
+
messageId: z.ZodString;
|
|
34
|
+
}, z.core.$strip>;
|
|
35
|
+
readonly optional: true;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Support's jobs as a dispatch registry, keyed `support/<job>`. Built here rather than through
|
|
40
|
+
* `composeWorkflows` because the inbound handler dispatches from inside the capability, before any
|
|
41
|
+
* project-wide registry exists — and the key format comes from core's {@link workflowKey} either
|
|
42
|
+
* way, so the two cannot drift.
|
|
43
|
+
*/
|
|
44
|
+
export declare const supportWorkflowRegistry: WorkflowRegistry;
|
|
45
|
+
//# sourceMappingURL=specs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"specs.d.ts","sourceRoot":"","sources":["../../src/workflows/specs.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAmB,MAAM,kCAAkC,CAAC;AAC1F,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;;;;;;;GAcG;AAEH,uGAAuG;AACvG,eAAO,MAAM,kBAAkB,YAAY,CAAC;AAE5C,iCAAiC;AACjC,eAAO,MAAM,qBAAqB;;iBASsE,CAAC;AACzG,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAE1E,oHAAoH;AACpH,eAAO,MAAM,gBAAgB;;iBAEzB,OAAO,EAAE,kBAAkB;iBAC3B,SAAS,EAAE,yBAAyB;iBACpC,MAAM;;;iBACN,QAAQ;;CAEwB,CAAC;AAErC;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,EAAE,gBAKrC,CAAC"}
|