History

Clean cloud service boundaries

Rhys Sullivan committed 6 months ago

Commit 586955b1 parent b039b6e3 files · +116 −55Browse files

Unified · Split

3 files
  1. apps/cloud/src/mcp-flow.test.ts +20 −6
  2. apps/cloud/src/services/autumn.ts +19 −13
  3. apps/cloud/src/services/slack.ts +77 −36
apps/cloud/src/mcp-flow.test.ts+20−6
@@ -137,7 +137,7 @@ const seedOrg = async (id: string, name = "MCP Flow Org"): Promise<void> => { · 136 unmodified lines
137beforeAll(() => {137beforeAll(() => {
138 // Env presence guard — avoids confusing errors downstream if the test138 // Env presence guard — avoids confusing errors downstream if the test
139 // wrangler forgot to bind something the DO needs.139 // wrangler forgot to bind something the DO needs.
140 if (!env.MCP_SESSION) throw new Error("MCP_SESSION binding missing from test wrangler");140 expect(env.MCP_SESSION, "MCP_SESSION binding missing from test wrangler").toBeDefined();
141});141});
142 142
143afterAll(() => undefined);143afterAll(() => undefined);
@@ -384,18 +384,32 @@ describe("/mcp session restore", () => { · 240 unmodified lines
384 const getResponse = await mcpGet({ bearer, sessionId: sessionId! });384 const getResponse = await mcpGet({ bearer, sessionId: sessionId! });
385 expect(getResponse.status).toBe(200);385 expect(getResponse.status).toBe(200);
386 expect(getResponse.headers.get("content-type") ?? "").toContain("text/event-stream");386 expect(getResponse.headers.get("content-type") ?? "").toContain("text/event-stream");
387 await getResponse.body?.cancel().catch(() => undefined);387 const responseBody = getResponse.body;
388 if (responseBody) {
389 await Effect.runPromise(
390 Effect.ignore(
391 Effect.tryPromise({
392 try: () => responseBody.cancel(),
393 catch: () => "ResponseBodyCancelFailed" as const,
394 }),
395 ),
396 );
397 }
388 398
389 const response = await Promise.race([399 const postResult = await Promise.race([
390 mcpPost({400 mcpPost({
391 bearer,401 bearer,
392 sessionId,402 sessionId,
393 body: TOOLS_LIST_REQUEST,403 body: TOOLS_LIST_REQUEST,
394 }),404 }).then((response) => ({ kind: "response" as const, response })),
395 new Promise<never>((_, reject) =>405 new Promise<{ readonly kind: "timeout" }>((resolve) =>
396 setTimeout(() => reject(new Error("POST did not return after GET restore")), 5_000),406 setTimeout(() => resolve({ kind: "timeout" }), 5_000),
397 ),407 ),
398 ]);408 ]);
409 expect(postResult).toEqual(expect.objectContaining({ kind: "response" }));
410 if (postResult.kind !== "response") return;
411
412 const response = postResult.response;
399 expect(response.status).toBe(200);413 expect(response.status).toBe(200);
400 expect(response.headers.get("content-type") ?? "").toContain("application/json");414 expect(response.headers.get("content-type") ?? "").toContain("application/json");
401 const body = (await response.json()) as {415 const body = (await response.json()) as {
apps/cloud/src/services/autumn.ts+19−13
@@ -12,7 +12,8 @@ import { Context, Data, Effect, Layer } from "effect"; · 11 unmodified lines
12// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
13 13
14export class AutumnError extends Data.TaggedError("AutumnError")<{14export class AutumnError extends Data.TaggedError("AutumnError")<{
15 cause: unknown;15 message: string;
16 cause?: unknown;
16}> {}17}> {}
17 18
18// ---------------------------------------------------------------------------19// ---------------------------------------------------------------------------
@@ -38,8 +39,8 @@ const make = Effect.sync(() => { · 19 unmodified lines
38 const secretKey = env.AUTUMN_SECRET_KEY;39 const secretKey = env.AUTUMN_SECRET_KEY;
39 40
40 if (!secretKey) {41 if (!secretKey) {
41 const notConfigured = Effect.die(42 const notConfigured = Effect.fail(
42 new Error("Autumn not configured — AUTUMN_SECRET_KEY is empty"),43 new AutumnError({ message: "Autumn not configured: AUTUMN_SECRET_KEY is empty" }),
43 );44 );
44 return {45 return {
45 use: () => notConfigured,46 use: () => notConfigured,
@@ -52,22 +53,27 @@ const make = Effect.sync(() => { · 6 unmodified lines
52 const use = <A>(fn: (client: Autumn) => Promise<A>) =>53 const use = <A>(fn: (client: Autumn) => Promise<A>) =>
53 Effect.tryPromise({54 Effect.tryPromise({
54 try: () => fn(client),55 try: () => fn(client),
55 catch: (cause) => new AutumnError({ cause }),56 catch: (cause) => new AutumnError({ message: "Autumn SDK request failed", cause }),
56 }).pipe(Effect.withSpan(`autumn.${fn.name ?? "use"}`));57 }).pipe(Effect.withSpan(`autumn.${fn.name ?? "use"}`));
57 58
58 const trackExecution = (organizationId: string) =>59 const trackExecution = (organizationId: string) =>
59 Effect.gen(function* () {60 Effect.gen(function* () {
60 yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId });61 yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId });
61 const outcome = yield* Effect.result(62 yield* use((c) =>
62 use((c) => c.track({ customerId: organizationId, featureId: "executions", value: 1 })),63 c.track({ customerId: organizationId, featureId: "executions", value: 1 }),
64 ).pipe(
65 Effect.catchTag("AutumnError", (error) =>
66 Effect.gen(function* () {
67 // Silent billing data loss is worth paging on — autumn.trackExecution
68 // is fire-and-forget so the caller doesn't handle it themselves.
69 yield* Effect.sync(() => {
70 console.error("[billing] track failed:", error);
71 Sentry.captureException(error);
72 });
73 yield* Effect.annotateCurrentSpan({ "autumn.track.failed": true });
74 }),
75 ),
63 );76 );
64 if (outcome._tag === "Failure") {
65 // Silent billing data loss is worth paging on — autumn.trackExecution
66 // is fire-and-forget so the caller doesn't handle it themselves.
67 console.error("[billing] track failed:", outcome.failure);
68 Sentry.captureException(outcome.failure);
69 yield* Effect.annotateCurrentSpan({ "autumn.track.failed": true });
70 }
71 }).pipe(Effect.withSpan("autumn.trackExecution"));77 }).pipe(Effect.withSpan("autumn.trackExecution"));
72 78
73 return { use, trackExecution } satisfies IAutumnService;79 return { use, trackExecution } satisfies IAutumnService;
apps/cloud/src/services/slack.ts+77−36
@@ -5,7 +5,7 @@ · 4 unmodified lines
5// ---------------------------------------------------------------------------5// ---------------------------------------------------------------------------
6 6
7import { env } from "cloudflare:workers";7import { env } from "cloudflare:workers";
8import { Context, Data, Effect, Layer } from "effect";8import { Context, Data, Effect, Layer, Schema } from "effect";
9 9
10export class SlackError extends Data.TaggedError("SlackError")<{10export class SlackError extends Data.TaggedError("SlackError")<{
11 method: string;11 method: string;
@@ -40,41 +40,82 @@ const randomSuffix = (): string => { · 28 unmodified lines
40 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");40 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
41};41};
42 42
43type SlackResponse = { ok: boolean; error?: string } & Record<string, unknown>;43const SlackErrorResponse = Schema.Struct({
44 ok: Schema.Literal(false),
45 error: Schema.optional(Schema.String),
46});
47
48const SlackChannel = Schema.Struct({ id: Schema.String, name: Schema.String });
49
50const SlackPostMessageResponse = Schema.Struct({ ok: Schema.Literal(true) });
51
52const SlackCreateChannelResponse = Schema.Struct({
53 ok: Schema.Literal(true),
54 channel: SlackChannel,
55});
56
57const SlackSharedInviteResponse = Schema.Struct({
58 ok: Schema.Literal(true),
59 invite_id: Schema.String,
60 url: Schema.String,
61});
44 62
45const make = Effect.sync(() => {63const make = Effect.sync(() => {
46 const token = env.SLACK_BOT_TOKEN;64 const token = env.SLACK_BOT_TOKEN;
47 65
48 if (!token) {66 if (!token) {
49 const notConfigured = (method: string) =>67 const notConfigured = (method: string) =>
50 Effect.fail(68 Effect.fail(new SlackError({ method, error: "SLACK_BOT_TOKEN is not configured" }));
51 new SlackError({ method, error: "SLACK_BOT_TOKEN is not configured" }),
52 );
53 return {69 return {
54 createConnectInvite: () => notConfigured("createConnectInvite"),70 createConnectInvite: () => notConfigured("createConnectInvite"),
55 } satisfies ISlackService;71 } satisfies ISlackService;
56 }72 }
57 73
58 const call = <A extends SlackResponse>(method: string, body: Record<string, unknown>) =>74 const call = <A extends { ok: true }>(
59 Effect.tryPromise({75 method: string,
60 try: async (): Promise<A> => {76 body: Record<string, unknown>,
61 const res = await fetch(`https://slack.com/api/${method}`, {77 successSchema: Schema.Decoder<A>,
62 method: "POST",78 ) =>
63 headers: {79 Effect.gen(function* () {
64 "content-type": "application/json; charset=utf-8",80 const json = yield* Effect.tryPromise({
65 authorization: `Bearer ${token}`,81 try: async (): Promise<unknown> => {
66 },82 const res = await fetch(`https://slack.com/api/${method}`, {
67 body: JSON.stringify(body),83 method: "POST",
68 });84 headers: {
69 const json = (await res.json()) as A;85 "content-type": "application/json; charset=utf-8",
70 if (!json.ok) throw new Error(json.error ?? "unknown_slack_error");86 authorization: `Bearer ${token}`,
71 return json;87 },
72 },88 body: JSON.stringify(body),
73 catch: (cause) =>89 });
74 new SlackError({90 return res.json();
91 },
92 catch: () =>
93 new SlackError({
94 method,
95 error: "Failed to read Slack API response",
96 }),
97 });
98
99 const response = yield* Schema.decodeUnknownEffect(
100 Schema.Union([successSchema, SlackErrorResponse]),
101 )(json).pipe(
102 Effect.mapError(
103 () =>
104 new SlackError({
105 method,
106 error: "Unexpected Slack API response",
107 }),
108 ),
109 );
110
111 if (!response.ok) {
112 return yield* new SlackError({
75 method,113 method,
76 error: cause instanceof Error ? cause.message : String(cause),114 error: response.error ?? "unknown_slack_error",
77 }),115 });
116 }
117
118 return response;
78 }).pipe(Effect.withSpan(`slack.${method}`));119 }).pipe(Effect.withSpan(`slack.${method}`));
79 120
80 const createConnectInvite: ISlackService["createConnectInvite"] = ({121 const createConnectInvite: ISlackService["createConnectInvite"] = ({
@@ -87,10 +128,7 @@ const make = Effect.sync(() => { · 6 unmodified lines
87 // Slack channel names: lowercase, no spaces, max 80 chars, unique per workspace.128 // Slack channel names: lowercase, no spaces, max 80 chars, unique per workspace.
88 const baseName = `shared-${slugifyEmail(email)}`.slice(0, 80);129 const baseName = `shared-${slugifyEmail(email)}`.slice(0, 80);
89 const tryCreate = (n: string) =>130 const tryCreate = (n: string) =>
90 call<SlackResponse & { channel: { id: string; name: string } }>(131 call("conversations.create", { name: n, is_private: false }, SlackCreateChannelResponse);
91 "conversations.create",
92 { name: n, is_private: false },
93 );
94 132
95 const created = yield* tryCreate(baseName).pipe(133 const created = yield* tryCreate(baseName).pipe(
96 Effect.catchTag("SlackError", (err) =>134 Effect.catchTag("SlackError", (err) =>
@@ -108,12 +146,16 @@ const make = Effect.sync(() => { · 11 unmodified lines
108 note ? `Note: ${note}` : null,146 note ? `Note: ${note}` : null,
109 ].filter(Boolean) as string[];147 ].filter(Boolean) as string[];
110 148
111 yield* call<SlackResponse>("chat.postMessage", {149 yield* call(
112 channel: channel.id,150 "chat.postMessage",
113 text: helloLines.join("\n"),151 {
114 });152 channel: channel.id,
153 text: helloLines.join("\n"),
154 },
155 SlackPostMessageResponse,
156 );
115 157
116 const invite = yield* call<SlackResponse & { invite_id: string; url: string }>(158 const invite = yield* call(
117 "conversations.inviteShared",159 "conversations.inviteShared",
118 {160 {
119 channel: channel.id,161 channel: channel.id,
@@ -122,15 +164,14 @@ const make = Effect.sync(() => { · 2 unmodified lines
122 // which is what we want for a focused 1:1 support conversation.164 // which is what we want for a focused 1:1 support conversation.
123 external_limited: true,165 external_limited: true,
124 },166 },
167 SlackSharedInviteResponse,
125 );168 );
126 169
127 return {170 return {
128 channel: { id: channel.id, name: channel.name },171 channel: { id: channel.id, name: channel.name },
129 invite: { invite_id: invite.invite_id, url: invite.url },172 invite: { invite_id: invite.invite_id, url: invite.url },
130 };173 };
131 }).pipe(174 }).pipe(Effect.withSpan("slack.createConnectInvite", { attributes: { "slack.email": email } }));
132 Effect.withSpan("slack.createConnectInvite", { attributes: { "slack.email": email } }),
133 );
134 175
135 return { createConnectInvite } satisfies ISlackService;176 return { createConnectInvite } satisfies ISlackService;
136});177});

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t