@pithy-sh/support 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +68 -0
- package/pithy.manifest.json +40 -0
- package/src/ai/classify.ts +239 -0
- package/src/attachment/store.ts +78 -0
- package/src/audit/actions.ts +71 -0
- package/src/capability.ts +293 -0
- package/src/client/projection.ts +60 -0
- package/src/cloudflare-test.d.ts +15 -0
- package/src/config/config.ts +400 -0
- package/src/data/attachment.ts +65 -0
- package/src/data/billingScope.ts +32 -0
- package/src/data/categories.ts +117 -0
- package/src/data/classification.ts +50 -0
- package/src/data/enums.ts +90 -0
- package/src/data/flag.ts +37 -0
- package/src/data/message.ts +224 -0
- package/src/data/tables.ts +58 -0
- package/src/data/thread.ts +138 -0
- package/src/error/errors.ts +133 -0
- package/src/http/guards.ts +59 -0
- package/src/http/handlers.ts +418 -0
- package/src/http/resolve.ts +109 -0
- package/src/http/responses.ts +506 -0
- package/src/http/routes.ts +272 -0
- package/src/http/schemas.ts +251 -0
- package/src/http/scopes.ts +117 -0
- package/src/http/views.ts +169 -0
- package/src/inbound/authenticity.ts +114 -0
- package/src/inbound/guard.ts +127 -0
- package/src/inbound/handler.ts +102 -0
- package/src/inbound/ingest.ts +548 -0
- package/src/inbound/recipient.ts +67 -0
- package/src/index.ts +63 -0
- package/src/link/sender.ts +334 -0
- package/src/migrations/0001_threads.ts +296 -0
- package/src/mime/address.ts +37 -0
- package/src/mime/parse.ts +299 -0
- package/src/mime/sanitize.ts +253 -0
- package/src/mime/threading.ts +127 -0
- package/src/mime/truncate.ts +55 -0
- package/src/provision/provisionSupport.ts +179 -0
- package/src/provision/resolveSupportConfig.ts +67 -0
- package/src/reply/send.ts +322 -0
- package/src/reply/snippets.ts +167 -0
- package/src/secret/registry.ts +24 -0
- package/src/seeds/example.ts +385 -0
- package/src/store/paging.ts +22 -0
- package/src/store/search.ts +197 -0
- package/src/store/searchIndex.ts +71 -0
- package/src/store/threads.ts +452 -0
- package/src/submission/encoding.ts +66 -0
- package/src/submission/guard.ts +120 -0
- package/src/submission/submit.ts +539 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/classify.ts +164 -0
- package/src/workflows/retryPolicy.ts +48 -0
- package/src/workflows/specs.ts +61 -0
- package/src/workflows/worker.ts +82 -0
- package/src/workflows/wrangler.jsonc +46 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { SupportAi } from "../ai/classify";
|
|
5
|
+
import { classifyMessage } from "../ai/classify";
|
|
6
|
+
import type { SupportAiConfig } from "../config/config";
|
|
7
|
+
import type { SupportCategories } from "../data/categories";
|
|
8
|
+
import { SupportClassification } from "../data/classification";
|
|
9
|
+
import { SPAM } from "../data/enums";
|
|
10
|
+
import {
|
|
11
|
+
SUPPORT_CLASSIFICATIONS_TABLE,
|
|
12
|
+
SUPPORT_MESSAGES_TABLE,
|
|
13
|
+
SUPPORT_THREADS_TABLE,
|
|
14
|
+
type SupportDatabase,
|
|
15
|
+
} from "../data/tables";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The classification orchestration — the pure, testable core the Workflow step runs.
|
|
19
|
+
*
|
|
20
|
+
* Reads a message, asks the model about it, **appends** a classification row, and denormalizes the
|
|
21
|
+
* answer onto its thread. The two writes are deliberate and asymmetric: the history is append-only
|
|
22
|
+
* so a model upgrade is visible in the data rather than a silent rewrite, and the thread carries only
|
|
23
|
+
* the current answer because that is what the inbox query reads.
|
|
24
|
+
*
|
|
25
|
+
* Idempotent by construction. Re-running over the same message appends a second row and overwrites
|
|
26
|
+
* the same three thread columns with a fresh judgment — which is what makes a Workflow retry, a
|
|
27
|
+
* manual reclassify, and a post-upgrade backfill the same operation.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Everything classification needs, all injectable. */
|
|
31
|
+
export interface ClassifyDeps {
|
|
32
|
+
/** The support tables. */
|
|
33
|
+
db: SupportDatabase;
|
|
34
|
+
/** The Workers AI binding. */
|
|
35
|
+
ai: SupportAi;
|
|
36
|
+
/** The effective taxonomy — the defaults plus the adopter's. */
|
|
37
|
+
categories: SupportCategories;
|
|
38
|
+
/** Model, bounds, and temperature. */
|
|
39
|
+
ai_config: SupportAiConfig;
|
|
40
|
+
/**
|
|
41
|
+
* Archive a thread the classifier calls `spam`, so it never reaches the open inbox.
|
|
42
|
+
*
|
|
43
|
+
* **Archived, never deleted.** The classifier is wrong sometimes, and a pass that destroyed mail
|
|
44
|
+
* would be untrustworthy the first time it was — whereas an archived thread is still readable under
|
|
45
|
+
* the archived filter and one click from being back. That is what makes `spam` a filter rather than
|
|
46
|
+
* a bin, and it is why this is safe to have on by default.
|
|
47
|
+
*/
|
|
48
|
+
archiveSpam: boolean;
|
|
49
|
+
/** Generate a row id. */
|
|
50
|
+
newId: () => string;
|
|
51
|
+
/** Now. */
|
|
52
|
+
now: () => Date;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What one classification run did. `null` when the message no longer exists. */
|
|
56
|
+
export interface ClassifyOutcome {
|
|
57
|
+
/** The thread the classification landed on. */
|
|
58
|
+
threadId: string;
|
|
59
|
+
/** The category chosen. */
|
|
60
|
+
category: string;
|
|
61
|
+
/** The model's confidence. */
|
|
62
|
+
confidence: number;
|
|
63
|
+
/** The model that produced it. */
|
|
64
|
+
model: string;
|
|
65
|
+
/** Whether this run archived the thread because it classified as spam. */
|
|
66
|
+
archivedAsSpam: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Classify one stored message and write the result. Returns null when the message is gone. */
|
|
70
|
+
export async function runClassification(deps: ClassifyDeps, messageId: string): Promise<ClassifyOutcome | null> {
|
|
71
|
+
const message = await deps.db
|
|
72
|
+
.selectFrom(SUPPORT_MESSAGES_TABLE)
|
|
73
|
+
.select(["id", "threadId", "subject", "textBody"])
|
|
74
|
+
.where("id", "=", messageId)
|
|
75
|
+
.executeTakeFirst();
|
|
76
|
+
// Not an error. A Workflow instance can outlive the row that started it, and a retry after a
|
|
77
|
+
// rollback is the ordinary way that happens.
|
|
78
|
+
if (!message) return null;
|
|
79
|
+
|
|
80
|
+
const result = await classifyMessage(deps.ai, {
|
|
81
|
+
categories: deps.categories,
|
|
82
|
+
subject: message.subject,
|
|
83
|
+
body: message.textBody,
|
|
84
|
+
model: deps.ai_config.model,
|
|
85
|
+
maxChars: deps.ai_config.maxChars,
|
|
86
|
+
temperature: deps.ai_config.temperature,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const now = deps.now();
|
|
90
|
+
|
|
91
|
+
await deps.db
|
|
92
|
+
.insertInto(SUPPORT_CLASSIFICATIONS_TABLE)
|
|
93
|
+
.values(
|
|
94
|
+
SupportClassification.encode({
|
|
95
|
+
id: deps.newId(),
|
|
96
|
+
threadId: message.threadId,
|
|
97
|
+
messageId: message.id,
|
|
98
|
+
category: result.category,
|
|
99
|
+
priority: result.priority,
|
|
100
|
+
sentiment: result.sentiment,
|
|
101
|
+
confidence: result.confidence,
|
|
102
|
+
model: result.model,
|
|
103
|
+
createdAt: now,
|
|
104
|
+
}),
|
|
105
|
+
)
|
|
106
|
+
.execute();
|
|
107
|
+
|
|
108
|
+
// Spam gets archived in the same write that classifies it, not a second pass — otherwise there is a
|
|
109
|
+
// window where it sits in somebody's open inbox, which is the whole thing this avoids.
|
|
110
|
+
const archivedAsSpam = deps.archiveSpam && result.category === SPAM;
|
|
111
|
+
|
|
112
|
+
await deps.db
|
|
113
|
+
.updateTable(SUPPORT_THREADS_TABLE)
|
|
114
|
+
.set({
|
|
115
|
+
category: result.category,
|
|
116
|
+
priority: result.priority,
|
|
117
|
+
sentiment: result.sentiment,
|
|
118
|
+
confidence: result.confidence,
|
|
119
|
+
model: result.model,
|
|
120
|
+
classifiedAt: now.getTime(),
|
|
121
|
+
updatedAt: now.getTime(),
|
|
122
|
+
...(archivedAsSpam
|
|
123
|
+
? {
|
|
124
|
+
archived: 1 as const,
|
|
125
|
+
archivedAt: now.getTime(),
|
|
126
|
+
// The model, named as the actor. An operator looking at why a thread is archived should
|
|
127
|
+
// see that nobody decided it — and `archivedBy` is exactly where they will look.
|
|
128
|
+
archivedBy: result.model,
|
|
129
|
+
}
|
|
130
|
+
: {}),
|
|
131
|
+
})
|
|
132
|
+
.where("id", "=", message.threadId)
|
|
133
|
+
.execute();
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
threadId: message.threadId,
|
|
137
|
+
category: result.category,
|
|
138
|
+
confidence: result.confidence,
|
|
139
|
+
model: result.model,
|
|
140
|
+
archivedAsSpam,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The message a manual reclassify runs against: a thread's most recent **inbound** message.
|
|
146
|
+
*
|
|
147
|
+
* Inbound, not latest — a thread whose last message is our own reply would otherwise be reclassified
|
|
148
|
+
* on the text the adopter wrote, which says nothing about what the customer wanted.
|
|
149
|
+
*/
|
|
150
|
+
export async function latestInboundMessageId(db: SupportDatabase, threadId: string): Promise<string | undefined> {
|
|
151
|
+
const row = await db
|
|
152
|
+
.selectFrom(SUPPORT_MESSAGES_TABLE)
|
|
153
|
+
.select(["id"])
|
|
154
|
+
.where("threadId", "=", threadId)
|
|
155
|
+
.where("direction", "=", "inbound")
|
|
156
|
+
.orderBy("receivedAt", "desc")
|
|
157
|
+
// Tiebroken on id: two messages can share a millisecond (a redelivery burst, or a fixed clock in
|
|
158
|
+
// a test), and without it "the latest inbound message" is whichever the database felt like —
|
|
159
|
+
// so a reclassify or a reply could thread against the wrong parent.
|
|
160
|
+
.orderBy("id", "desc")
|
|
161
|
+
.limit(1)
|
|
162
|
+
.executeTakeFirst();
|
|
163
|
+
return row?.id;
|
|
164
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { WorkflowRetryPolicy } from "@pithy-sh/core/src/workflow/faults";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* **What a classification retries, and what it refuses to.**
|
|
8
|
+
*
|
|
9
|
+
* `ai/classify.ts` already drew this line and drew it correctly: a model that returns prose, an invented
|
|
10
|
+
* label, or an envelope nobody documented yields `uncategorized` at zero confidence and never throws,
|
|
11
|
+
* because throwing would burn a retry budget on a model that is going to say the same thing next time.
|
|
12
|
+
* The one case it deliberately does not swallow is a binding that is *unavailable*. This is that
|
|
13
|
+
* sentence in the form the durable step reads (pithy-sh/pithy#348).
|
|
14
|
+
*
|
|
15
|
+
* ## Retryable, and why
|
|
16
|
+
*
|
|
17
|
+
* - **`core/upstream_failed`** — Workers AI rejected the call. Raised at exactly one place, the wrap
|
|
18
|
+
* around `ai.run` in `classifyMessage`, and it means the model did not answer at all. That is the
|
|
19
|
+
* whole population of transient faults in a classification, and the code is what lets the step tell
|
|
20
|
+
* it from a bad answer — a raw throw would be `unclassified`, and unclassified is terminal.
|
|
21
|
+
* - **A transient D1 fault** — the message read, the appended history row, the thread denormalization.
|
|
22
|
+
* Classified in core by `withD1Retry`, never restated here.
|
|
23
|
+
*
|
|
24
|
+
* ## Terminal, and why
|
|
25
|
+
*
|
|
26
|
+
* - **`support/not_found`** — a thread or message that is gone. `runClassification` does not even raise
|
|
27
|
+
* it for the message it was started for: a missing row returns `null`, because an instance outliving
|
|
28
|
+
* the row that started it is the ordinary way a rollback looks.
|
|
29
|
+
* - **`support/classification_failed`** — the AI binding is absent from the env. A binding does not
|
|
30
|
+
* appear on the fourth attempt; this wants `pithy support provision`.
|
|
31
|
+
* - **`support/invalid_category`** — the adopter's taxonomy will not validate. Config, and identical
|
|
32
|
+
* next time.
|
|
33
|
+
* - **`support/unparseable_message`, `support/rejected`, `support/reply_failed`** — all inbound-path and
|
|
34
|
+
* reply-path refusals. None runs inside the classify step.
|
|
35
|
+
* - **`validation/invalid_input`** — a payload the schema refuses.
|
|
36
|
+
*
|
|
37
|
+
* **Re-running is free, which is what makes the retryable entry safe.** Classification is idempotent by
|
|
38
|
+
* construction: a second pass appends a second history row and overwrites the same three thread columns
|
|
39
|
+
* with a fresh judgment, which is why a Workflow retry, a manual reclassify, and a post-upgrade
|
|
40
|
+
* backfill are the same operation.
|
|
41
|
+
*/
|
|
42
|
+
export const supportWorkflowRetry: WorkflowRetryPolicy = {
|
|
43
|
+
capability: "support",
|
|
44
|
+
retryable: {
|
|
45
|
+
"core/upstream_failed":
|
|
46
|
+
"The model did not answer at all; classification is idempotent, so asking again costs one call.",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { workflowKey } from "@pithy-sh/core/src/workflow/naming";
|
|
5
|
+
import type { WorkflowRegistry, WorkflowSpecMap } from "@pithy-sh/core/src/workflow/spec";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The one durable job support owns, declared once.
|
|
10
|
+
*
|
|
11
|
+
* **Classification is a Workflow, not part of the `email()` handler**, and that is a hard constraint
|
|
12
|
+
* rather than a preference. An inbound handler runs under a tight CPU budget; a model call is
|
|
13
|
+
* hundreds of milliseconds of wall time it cannot spend, and a model that is slow or briefly
|
|
14
|
+
* unavailable would take the *persistence* of the message down with it. So the handler does the
|
|
15
|
+
* cheap, must-not-fail half — parse, bound, store — and hands off. A classification that fails
|
|
16
|
+
* retries against a message that is already safely in D1.
|
|
17
|
+
*
|
|
18
|
+
* `optional: true`: the Workflow lives in the prebuilt support worker that `pithy support provision`
|
|
19
|
+
* deploys, so a project that has not provisioned must still boot and still receive mail. An absent
|
|
20
|
+
* binding degrades to a logged skip and a thread that stays `uncategorized` — which is exactly the
|
|
21
|
+
* state a classification is allowed to be in.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The capability name — the first segment of the dispatch key and of every deployed workflow name. */
|
|
25
|
+
export const SUPPORT_CAPABILITY = "support";
|
|
26
|
+
|
|
27
|
+
/** Which message to classify. */
|
|
28
|
+
export const SupportClassifyParams = z
|
|
29
|
+
.object({
|
|
30
|
+
messageId: z
|
|
31
|
+
.string()
|
|
32
|
+
.min(1)
|
|
33
|
+
.describe(
|
|
34
|
+
"The `pithy_support_messages.id` to classify. The Workflow reads that message, asks the model about it, appends a classification row, and denormalizes the answer onto its thread.",
|
|
35
|
+
),
|
|
36
|
+
})
|
|
37
|
+
.describe("The instance parameters of the classification Workflow — the one message it runs against.");
|
|
38
|
+
export type SupportClassifyParams = z.infer<typeof SupportClassifyParams>;
|
|
39
|
+
|
|
40
|
+
/** Support's durable jobs, keyed by job name. The key is the second segment of the `support/<job>` dispatch key. */
|
|
41
|
+
export const supportWorkflows = {
|
|
42
|
+
classify: {
|
|
43
|
+
binding: "SUPPORT_CLASSIFY",
|
|
44
|
+
className: "SupportClassifyWorkflow",
|
|
45
|
+
params: SupportClassifyParams,
|
|
46
|
+
optional: true,
|
|
47
|
+
},
|
|
48
|
+
} as const satisfies WorkflowSpecMap;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Support's jobs as a dispatch registry, keyed `support/<job>`. Built here rather than through
|
|
52
|
+
* `composeWorkflows` because the inbound handler dispatches from inside the capability, before any
|
|
53
|
+
* project-wide registry exists — and the key format comes from core's {@link workflowKey} either
|
|
54
|
+
* way, so the two cannot drift.
|
|
55
|
+
*/
|
|
56
|
+
export const supportWorkflowRegistry: WorkflowRegistry = Object.fromEntries(
|
|
57
|
+
Object.entries(supportWorkflows).map(([job, spec]) => {
|
|
58
|
+
const key = workflowKey(SUPPORT_CAPABILITY, job);
|
|
59
|
+
return [key, { key, capability: SUPPORT_CAPABILITY, job, spec }];
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
|
|
5
|
+
import { NonRetryableError } from "cloudflare:workflows";
|
|
6
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
7
|
+
import { classifiedSteps } from "@pithy-sh/core/src/workflow/faults";
|
|
8
|
+
import { workflowHostEntry } from "@pithy-sh/core/src/workflow/hostEntry";
|
|
9
|
+
import type { SupportAi } from "../ai/classify";
|
|
10
|
+
import { SupportConfig } from "../config/config";
|
|
11
|
+
import { resolveCategories } from "../data/categories";
|
|
12
|
+
import { supportDatabase } from "../data/tables";
|
|
13
|
+
import { runClassification } from "./classify";
|
|
14
|
+
import { supportWorkflowRetry } from "./retryPolicy";
|
|
15
|
+
import { SUPPORT_CAPABILITY } from "./specs";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The prebuilt support worker. `pithy support provision` deploys one per environment; the adopter
|
|
19
|
+
* authors no code for it. It hosts the classification Workflow — a thin durable shell around the
|
|
20
|
+
* tested orchestration in `classify.ts`. The app worker receives the mail, stores it, and starts an
|
|
21
|
+
* instance.
|
|
22
|
+
*
|
|
23
|
+
* This module imports `cloudflare:workers`, so it runs only in the Workers runtime and is excluded
|
|
24
|
+
* from the node `.describe()` meta-test.
|
|
25
|
+
*
|
|
26
|
+
* **The effective taxonomy arrives as config, not as code.** `SUPPORT_CONFIG` carries the adopter's
|
|
27
|
+
* own categories, so their `tournament_dispute` reaches the prompt without this worker importing
|
|
28
|
+
* anything of theirs — which it could not do anyway, since it is deployed from this package.
|
|
29
|
+
*
|
|
30
|
+
* **The default export is what makes this an ES module** (#426). It exports one Workflow class and has
|
|
31
|
+
* no cron, so until now it exported no default — and wrangler infers a worker's module format from
|
|
32
|
+
* exactly that, so the build read it as a service worker and refused `cloudflare:workers` outright.
|
|
33
|
+
* The host did not build, `pithy dev` carried on past it, and classification silently never ran. The
|
|
34
|
+
* refusal it exports is the honest body for a host with no request surface; see
|
|
35
|
+
* `@pithy-sh/core/src/workflow/hostEntry`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** The support worker's env: the app database, the AI binding, and the serialized config. */
|
|
39
|
+
export interface SupportWorkerEnv {
|
|
40
|
+
/** The app database the support tables live in. */
|
|
41
|
+
DB: D1Database;
|
|
42
|
+
/** The Workers AI binding. */
|
|
43
|
+
AI: SupportAi;
|
|
44
|
+
/** The resolved support config as a JSON string, filled at provision. */
|
|
45
|
+
SUPPORT_CONFIG?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Classify one stored message and write the result. */
|
|
49
|
+
export class SupportClassifyWorkflow extends WorkflowEntrypoint<SupportWorkerEnv, { messageId: string }> {
|
|
50
|
+
override async run(event: WorkflowEvent<{ messageId: string }>, step: WorkflowStep): Promise<void> {
|
|
51
|
+
const config = SupportConfig.parse(this.env.SUPPORT_CONFIG ? JSON.parse(this.env.SUPPORT_CONFIG) : {});
|
|
52
|
+
const deps = {
|
|
53
|
+
db: supportDatabase(this.env.DB),
|
|
54
|
+
ai: this.env.AI,
|
|
55
|
+
categories: resolveCategories(config.categories),
|
|
56
|
+
ai_config: config.ai,
|
|
57
|
+
archiveSpam: config.guard.archiveSpam,
|
|
58
|
+
newId: () => crypto.randomUUID(),
|
|
59
|
+
now: () => new Date(),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// One step. The unit of retry is the whole judgment, because a classification that half-ran —
|
|
63
|
+
// a history row with no thread update — would leave the inbox disagreeing with its own audit
|
|
64
|
+
// trail, and re-running the model is cheap enough that splitting it buys nothing.
|
|
65
|
+
// Under `supportWorkflowRetry`: a model that could not be reached re-drives, and everything else
|
|
66
|
+
// fails at once. `classifyMessage` already refuses to throw on a bad *answer*, so the only fault
|
|
67
|
+
// that reaches this classifier as retryable is the binding not answering. See `retryPolicy.ts`.
|
|
68
|
+
await classifiedSteps(step, supportWorkflowRetry, NonRetryableError).do(
|
|
69
|
+
`classify-${event.payload.messageId}`,
|
|
70
|
+
async () => {
|
|
71
|
+
await runClassification(deps, event.payload.messageId);
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The module's default export, and therefore its format. See `hostEntry` for why a Workflow host needs
|
|
79
|
+
* one at all, and why this one refuses rather than being empty: nothing reaches this worker over HTTP —
|
|
80
|
+
* the app worker stores the message and starts an instance on the `SUPPORT_CLASSIFY` binding.
|
|
81
|
+
*/
|
|
82
|
+
export default workflowHostEntry(SUPPORT_CAPABILITY);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
// The prebuilt support classification worker. Like the media and email workers, this is a TEMPLATE,
|
|
3
|
+
// not a wrangler env-stanza file: staging and prod are separate workers. `pithy support
|
|
4
|
+
// provision` resolves it into one complete config per environment — filling the `<...>` placeholders
|
|
5
|
+
// — and deploys each with `wrangler deploy --config <resolved>`. The adopter authors none of it.
|
|
6
|
+
//
|
|
7
|
+
// It hosts the one classification Workflow. The app worker receives the mail, stores it, and starts
|
|
8
|
+
// an instance; this worker is what talks to the model. That split is the whole reason classification
|
|
9
|
+
// is durable: an inbound handler has a tight CPU budget and a model call does not fit in it.
|
|
10
|
+
// Resolved per project and env → <project>-staging-support / <project>-prod-support. Worker
|
|
11
|
+
// script names are account-scoped, so the project segment is what stops a second Pithy project's
|
|
12
|
+
// deploy overwriting this one's running worker instead of colliding with it.
|
|
13
|
+
"name": "pithy-support",
|
|
14
|
+
"main": "./worker.ts",
|
|
15
|
+
// The compatibility date every Worker in this repository runs on. Stated once in the repository
|
|
16
|
+
// root's `compatibility.ts` and copied here because JSONC cannot import it —
|
|
17
|
+
// `cli/src/ci/compatibilityDates.test.ts` fails on any Worker older than it.
|
|
18
|
+
"compatibility_date": "2026-06-01",
|
|
19
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
20
|
+
|
|
21
|
+
// No public URL. The support routes live in the app worker; this one is reached only by Workflow
|
|
22
|
+
// dispatch.
|
|
23
|
+
"workers_dev": false,
|
|
24
|
+
|
|
25
|
+
// The app database the support tables live in. No secrets database: this worker reads a message and
|
|
26
|
+
// writes a classification, and neither needs a credential — the AI binding is the whole dependency.
|
|
27
|
+
"d1_databases": [{ "binding": "DB", "database_name": "pithy-app", "database_id": "<filled-at-provision>" }],
|
|
28
|
+
|
|
29
|
+
// The Workers AI binding — classification runs on the adopter's own inference, on their own bill,
|
|
30
|
+
// and their customers' support mail never leaves their account.
|
|
31
|
+
"ai": { "binding": "AI" },
|
|
32
|
+
|
|
33
|
+
// The classification Workflow this worker hosts. `class_name` matches the exported
|
|
34
|
+
// WorkflowEntrypoint subclass; `binding` is what the app worker dispatches to after storing a message.
|
|
35
|
+
"workflows": [
|
|
36
|
+
{ "binding": "SUPPORT_CLASSIFY", "name": "pithy-support-classify", "class_name": "SupportClassifyWorkflow" }
|
|
37
|
+
],
|
|
38
|
+
|
|
39
|
+
"vars": {
|
|
40
|
+
// The resolved SupportConfig as one JSON blob, filled at provision from the app's support() config.
|
|
41
|
+
// The worker parses and validates it; absent falls back to the defaults. It carries the effective
|
|
42
|
+
// taxonomy, so an adopter's own categories reach the prompt without this worker importing their code.
|
|
43
|
+
"SUPPORT_CONFIG": "<filled-at-provision>",
|
|
44
|
+
"ENVIRONMENT": "<filled-at-provision>"
|
|
45
|
+
}
|
|
46
|
+
}
|