History

feat(sources): persist auth-related sync failures

Rhys Sullivan committed 7 months ago

Commit cd677391 parent dbff25f5 files · +107 −11Browse files

Unified · Split

5 files
  1. packages/platform/sdk/src/runtime/sources/source-store.ts +5 −1
  2. packages/platform/sdk/src/runtime/sources/source-store/lifecycle.ts +5 −1
  3. packages/platform/sdk/src/sources/operations.test.ts +68 −2
  4. packages/platform/sdk/src/sources/operations.ts +17 −4
  5. packages/platform/server/src/index.test.ts +12 −3
packages/platform/sdk/src/runtime/sources/source-store.ts+5−1
@@ -59,7 +59,10 @@ type RuntimeSourceStoreShape = { · 58 unmodified lines
5959 }) => ReturnType<typeof removeSourceByIdWithDeps>;
6060 persistSource: (
6161 source: Source,
62− options?: { actorScopeId?: ScopeId | null },
62+ options?: {
63+ actorScopeId?: ScopeId | null;
64+ lastError?: string | null;
65+ },
6366 ) => ReturnType<typeof persistSourceWithDeps>;
6467 };
6568
@@ -136,6 +139,7 @@ export const persistSource = ( · 70 unmodified lines
136139 source: Source,
137140 options: {
138141 actorScopeId?: ScopeId | null;
142+ lastError?: string | null;
139143 } = {},
140144 ): Effect.Effect<
141145 Source,
packages/platform/sdk/src/runtime/sources/source-store/lifecycle.ts+5−1
@@ -70,6 +70,7 @@ export const persistSourceWithDeps = ( · 69 unmodified lines
7070 source: Source,
7171 options: {
7272 actorScopeId?: ScopeId | null;
73+ lastError?: string | null;
7374 } = {},
7475 ): Effect.Effect<Source, Error, never> =>
7576 Effect.gen(function* () {
@@ -111,7 +112,10 @@ export const persistSourceWithDeps = ( · 35 unmodified lines
111112 ...localScope.scopeState.sources,
112113 [nextSource.id]: {
113114 status: nextSource.status,
114− lastError: existingSourceState?.lastError ?? null,
115+ lastError:
116+ options.lastError !== undefined
117+ ? options.lastError
118+ : existingSourceState?.lastError ?? null,
115119 sourceHash: existingSourceState?.sourceHash ?? null,
116120 createdAt: existingSourceState?.createdAt ?? nextSource.createdAt,
117121 updatedAt: nextSource.updatedAt,
packages/platform/sdk/src/sources/operations.test.ts+68−2
@@ -4,6 +4,7 @@ import { · 3 unmodified lines
44 import {
55 NodeFileSystem,
66 } from "@effect/platform-node";
7+import { resolve } from "node:path";
78 import {
89 describe,
910 expect,
@@ -31,8 +32,11 @@ type BrokenStoredSource = { · 21 unmodified lines
3132 readonly name: string;
3233 };
3334
34−const makeBrokenSourcePlugin = () => {
35+const makeBrokenSourcePlugin = (input: {
36+ syncErrorMessage?: string;
37+} = {}) => {
3538 const storage = new Map<string, BrokenStoredSource>();
39+ const syncErrorMessage = input.syncErrorMessage ?? "sync boom";
3640
3741 return defineExecutorSourcePlugin<
3842 "broken",
@@ -98,7 +102,7 @@ const makeBrokenSourcePlugin = () => { · 59 unmodified lines
98102 kind: "imported",
99103 sync: () =>
100104 Effect.fail(
101− runtimeEffectError("sources/operations.test", "sync boom"),
105+ runtimeEffectError("sources/operations.test", syncErrorMessage),
102106 ),
103107 invoke: () =>
104108 Effect.fail(
@@ -154,4 +158,66 @@ describe("source operations", () => { · 49 unmodified lines
154158 expect(persisted.name).toBe("Broken Source");
155159 }).pipe(Effect.provide(NodeFileSystem.layer)),
156160 );
161+
162+ it.scoped("marks credential failures as auth_required and records the last sync error", () =>
163+ Effect.gen(function* () {
164+ const fs = yield* FileSystem.FileSystem;
165+ const workspaceRoot = yield* fs.makeTempDirectory({
166+ prefix: "executor-source-ops-auth-",
167+ });
168+
169+ const executor = yield* Effect.acquireRelease(
170+ createLocalExecutorEffect({
171+ localDataDir: ":memory:",
172+ workspaceRoot,
173+ plugins: [
174+ makeBrokenSourcePlugin({
175+ syncErrorMessage:
176+ "GraphQL introspection requires credentials (status 403)",
177+ }),
178+ ] as const,
179+ }),
180+ (executor) =>
181+ Effect.promise(() => executor.close()).pipe(
182+ Effect.orDie,
183+ Effect.zipRight(
184+ fs.remove(workspaceRoot, {
185+ recursive: true,
186+ force: true,
187+ }),
188+ ),
189+ ),
190+ );
191+
192+ const created = yield* executor.broken.createSource({
193+ name: "Protected Source",
194+ });
195+
196+ expect(created.status).toBe("auth_required");
197+
198+ const persisted = yield* executor.broken.getSource(created.id);
199+
200+ expect(persisted.status).toBe("auth_required");
201+ expect(persisted.name).toBe("Protected Source");
202+
203+ const inspection = yield* executor.sources.inspection.get(created.id);
204+
205+ expect(inspection.source.status).toBe("auth_required");
206+ expect(inspection.toolCount).toBe(0);
207+ expect(inspection.tools).toEqual([]);
208+
209+ const workspaceState = JSON.parse(
210+ yield* fs.readFileString(
211+ resolve(workspaceRoot, ".executor", "state", "workspace-state.json"),
212+ "utf8",
213+ ),
214+ ) as {
215+ sources?: Record<string, { lastError?: string | null }>;
216+ };
217+
218+ expect(workspaceState.sources?.[created.id]?.lastError).toBe(
219+ "GraphQL introspection requires credentials (status 403)",
220+ );
221+ }).pipe(Effect.provide(NodeFileSystem.layer)),
222+ );
157223 });
packages/platform/sdk/src/sources/operations.ts+17−4
@@ -36,6 +36,16 @@ const sourceOps = { · 35 unmodified lines
3636 remove: operationErrors("sources.remove"),
3737 } as const;
3838
39+const syncFailureDetails = (cause: unknown): string =>
40+ cause instanceof Error ? cause.message : String(cause);
41+
42+const syncFailureStatus = (cause: unknown): Source["status"] =>
43+ /(?:status\s*(?:401|403)|unauthorized|forbidden|requires credentials|requires authentication|authentication required|authorization required|missing credential|missing credentials|oauth)/i.test(
44+ syncFailureDetails(cause),
45+ )
46+ ? "auth_required"
47+ : "error";
48+
3949 const syncArtifactsForSource = (input: {
4050 store: ExecutorStateStoreShape;
4151 sourceStore: Effect.Effect.Success<typeof RuntimeSourceStoreService>;
@@ -58,10 +68,12 @@ const syncArtifactsForSource = (input: { · 16 unmodified lines
5868 onLeft: (error) =>
5969 Effect.gen(function* () {
6070 if (input.source.enabled && input.source.status === "connected") {
61− const erroredSource = yield* normalizeSourceForSave({
71+ const failedStatus = syncFailureStatus(error);
72+ const lastError = syncFailureDetails(error);
73+ const failedSource = yield* normalizeSourceForSave({
6274 source: {
6375 ...input.source,
64− status: "error",
76+ status: failedStatus,
6577 },
6678 now: Date.now(),
6779 }).pipe(
@@ -75,12 +87,13 @@ const syncArtifactsForSource = (input: { · 7 unmodified lines
7587
7688 yield* mapPersistenceError(
7789 input.operation.child("source_error"),
78− input.sourceStore.persistSource(erroredSource, {
90+ input.sourceStore.persistSource(failedSource, {
7991 actorScopeId: input.actorScopeId,
92+ lastError,
8093 }),
8194 );
8295
83− return erroredSource;
96+ return failedSource;
8497 }
8598
8699 return yield* input.operation.unknownStorage(
packages/platform/server/src/index.test.ts+12−3
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 import { NodeFileSystem } from "@effect/platform-node";
1414 import { describe, expect, it } from "@effect/vitest";
1515 import { assertTrue } from "@effect/vitest/utils";
16+import * as Data from "effect/Data";
1617 import * as Effect from "effect/Effect";
1718 import * as Layer from "effect/Layer";
1819 import * as Schema from "effect/Schema";
@@ -65,6 +66,13 @@ import { · 46 unmodified lines
6566 } from "../../../sources/core/src/index";
6667 import { createMcpCatalogFragment } from "../../../../plugins/mcp/sdk/catalog";
6768
69+class MissingCredentialEnvVarError extends Data.TaggedError(
70+ "MissingCredentialEnvVarError",
71+)<{
72+ readonly message: string;
73+ readonly tokenEnvVar: string;
74+}> {}
75+
6876 const serverHttpPlugins = [
6977 graphqlHttpPlugin(),
7078 googleDiscoveryHttpPlugin(),
@@ -493,9 +501,10 @@ const seedGithubOpenApiSourceInWorkspace = (input: { · 422 unmodified lines
493501 const tokenEnvVar = input.credentialEnvVar ?? "GITHUB_TOKEN";
494502 const tokenValue = process.env[tokenEnvVar];
495503 if (!tokenValue) {
496− return yield* Effect.fail(
497− new Error(`Missing token value in environment variable ${tokenEnvVar}`),
498− );
504+ return yield* new MissingCredentialEnvVarError({
505+ message: `Missing token value in environment variable ${tokenEnvVar}`,
506+ tokenEnvVar,
507+ });
499508 }
500509
501510 const existingSecrets = yield* input.client.local.listSecrets({});