Unified · Split
| @@ -0,0 +1,5 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/plugin-openapi": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Serve Microsoft Graph preset selections from precomputed slice release assets instead of the 43MB upstream monolith. The monolith fetch almost never survives a 128MB Workers isolate (production traces show one completion in 30 days), so covered selections — every catalog preset, plus any combination within the default bundle — now read a 4–19MB filtered document built offline by the graph-slices workflow, with the monolith path kept only as a fallback and for full-graph/custom-scope selections. | ||
| @@ -0,0 +1,47 @@ | |||
| 1 | # Refresh the Microsoft Graph slice release assets. | ||
| 2 | # | ||
| 3 | # The Graph OpenAPI monolith (~43MB) cannot be processed inside a Workers | ||
| 4 | # isolate, so the runtime reads per-selection slices published on the | ||
| 5 | # `graph-slices` release tag (see packages/plugins/openapi/src/providers/ | ||
| 6 | # microsoft/slices.ts). This workflow rebuilds the slices from the current | ||
| 7 | # upstream spec on a schedule and on demand. | ||
| 8 | name: Graph slices | ||
| 9 | |||
| 10 | on: | ||
| 11 | schedule: | ||
| 12 | # Weekly; Microsoft's msgraph-metadata automation lands upstream refreshes | ||
| 13 | # on a similar cadence. A failed run leaves the previous assets serving. | ||
| 14 | - cron: "17 6 * * 1" | ||
| 15 | workflow_dispatch: {} | ||
| 16 | |||
| 17 | permissions: | ||
| 18 | contents: write | ||
| 19 | |||
| 20 | jobs: | ||
| 21 | slices: | ||
| 22 | runs-on: ubuntu-latest | ||
| 23 | timeout-minutes: 20 | ||
| 24 | steps: | ||
| 25 | - uses: actions/checkout@v4 | ||
| 26 | |||
| 27 | - uses: oven-sh/setup-bun@v2 | ||
| 28 | with: | ||
| 29 | bun-version: 1.3.11 | ||
| 30 | |||
| 31 | - name: Install dependencies | ||
| 32 | run: bun install --frozen-lockfile | ||
| 33 | |||
| 34 | - name: Generate slices | ||
| 35 | working-directory: packages/plugins/openapi | ||
| 36 | run: bun scripts/generate-graph-slices.ts --out "$RUNNER_TEMP/graph-slices" | ||
| 37 | |||
| 38 | - name: Publish to the graph-slices release | ||
| 39 | env: | ||
| 40 | GH_TOKEN: ${{ github.token }} | ||
| 41 | run: | | ||
| 42 | gh release view graph-slices --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \ | ||
| 43 | gh release create graph-slices --repo "$GITHUB_REPOSITORY" \ | ||
| 44 | --title "Microsoft Graph slices" --latest=false \ | ||
| 45 | --notes "Generated per-preset Microsoft Graph OpenAPI slices. Data release consumed by the openapi plugin's Microsoft adapter; refreshed by the graph-slices workflow." | ||
| 46 | gh release upload graph-slices "$RUNNER_TEMP/graph-slices"/* \ | ||
| 47 | --repo "$GITHUB_REPOSITORY" --clobber | ||
| @@ -64,6 +64,7 @@ · 63 unmodified lines | |||
| 64 | "apps/desktop/src/main.ts", | 64 | "apps/desktop/src/main.ts", |
| 65 | "scripts/**/*.{ts,js}", | 65 | "scripts/**/*.{ts,js}", |
| 66 | "apps/*/scripts/**/*.{ts,js}", | 66 | "apps/*/scripts/**/*.{ts,js}", |
| 67 | "packages/*/*/scripts/**/*.{ts,js}", | ||
| 67 | "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}", | 68 | "packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}", |
| 68 | ], | 69 | ], |
| 69 | "rules": { | 70 | "rules": { |
| @@ -0,0 +1,109 @@ | |||
| 1 | /** | ||
| 2 | * Generate the Microsoft Graph slice release assets. | ||
| 3 | * | ||
| 4 | * bun scripts/generate-graph-slices.ts [--source <path-or-url>] [--out <dir>] | ||
| 5 | * | ||
| 6 | * Fetches (or reads) the Graph OpenAPI monolith, builds one slice per catalog | ||
| 7 | * preset plus the default bundle via `slice-build.ts`, validates every slice | ||
| 8 | * against the runtime's streamable profile, and writes `<asset>.yaml` files | ||
| 9 | * plus `manifest.json` to the output directory. The graph-slices workflow runs | ||
| 10 | * this and uploads the output to the `graph-slices` release tag; runtime | ||
| 11 | * resolution lives in `src/providers/microsoft/slices.ts`. | ||
| 12 | * | ||
| 13 | * Offline-only: this whole-parses the 43MB source, which only works where | ||
| 14 | * memory is free (CI runner / dev machine), never in a Workers isolate. | ||
| 15 | */ | ||
| 16 | import { createHash } from "node:crypto"; | ||
| 17 | import { mkdir, readFile, writeFile } from "node:fs/promises"; | ||
| 18 | import { join } from "node:path"; | ||
| 19 | |||
| 20 | import { structuralSplit } from "../src/sdk/split"; | ||
| 21 | import { | ||
| 22 | MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, | ||
| 23 | MICROSOFT_GRAPH_OPENAPI_URL, | ||
| 24 | microsoftGraphScopePresets, | ||
| 25 | } from "../src/providers/microsoft/presets"; | ||
| 26 | import { MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET } from "../src/providers/microsoft/slices"; | ||
| 27 | import { | ||
| 28 | buildGraphSliceDocument, | ||
| 29 | parseGraphSourceDocument, | ||
| 30 | } from "../src/providers/microsoft/slice-build"; | ||
| 31 | |||
| 32 | const argValue = (flag: string): string | undefined => { | ||
| 33 | const index = process.argv.indexOf(flag); | ||
| 34 | return index !== -1 ? process.argv[index + 1] : undefined; | ||
| 35 | }; | ||
| 36 | |||
| 37 | const source = argValue("--source") ?? MICROSOFT_GRAPH_OPENAPI_URL; | ||
| 38 | const outDir = argValue("--out") ?? "graph-slices-out"; | ||
| 39 | |||
| 40 | const readSource = async (): Promise<string> => { | ||
| 41 | if (source.startsWith("http://") || source.startsWith("https://")) { | ||
| 42 | const response = await fetch(source); | ||
| 43 | if (!response.ok) { | ||
| 44 | throw new Error(`Failed to fetch Graph source: HTTP ${response.status}`); | ||
| 45 | } | ||
| 46 | return response.text(); | ||
| 47 | } | ||
| 48 | return readFile(source, "utf8"); | ||
| 49 | }; | ||
| 50 | |||
| 51 | const sourceText = await readSource(); | ||
| 52 | const sourceSha256 = createHash("sha256").update(sourceText).digest("hex"); | ||
| 53 | const doc = parseGraphSourceDocument(sourceText); | ||
| 54 | if (!doc) { | ||
| 55 | throw new Error("Microsoft Graph source did not parse to an object"); | ||
| 56 | } | ||
| 57 | |||
| 58 | const selections: readonly { readonly asset: string; readonly presetIds: readonly string[] }[] = [ | ||
| 59 | ...microsoftGraphScopePresets.map((preset) => ({ asset: preset.id, presetIds: [preset.id] })), | ||
| 60 | { | ||
| 61 | asset: MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET, | ||
| 62 | presetIds: MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, | ||
| 63 | }, | ||
| 64 | ]; | ||
| 65 | |||
| 66 | await mkdir(outDir, { recursive: true }); | ||
| 67 | |||
| 68 | const manifestAssets: Record< | ||
| 69 | string, | ||
| 70 | { | ||
| 71 | readonly bytes: number; | ||
| 72 | readonly paths: number; | ||
| 73 | readonly operations: number; | ||
| 74 | readonly schemas: number; | ||
| 75 | } | ||
| 76 | > = {}; | ||
| 77 | |||
| 78 | for (const { asset, presetIds } of selections) { | ||
| 79 | const slice = buildGraphSliceDocument(doc, presetIds); | ||
| 80 | if (slice.operationCount === 0) { | ||
| 81 | throw new Error(`Slice "${asset}" kept zero operations — preset filter or source drifted`); | ||
| 82 | } | ||
| 83 | const structure = structuralSplit(slice.specText); | ||
| 84 | if (!structure) { | ||
| 85 | throw new Error(`Slice "${asset}" is not in the streamable block-YAML profile`); | ||
| 86 | } | ||
| 87 | if (structure.pathItems.length !== slice.pathCount) { | ||
| 88 | throw new Error( | ||
| 89 | `Slice "${asset}" splitter sees ${structure.pathItems.length} path-items, expected ${slice.pathCount}`, | ||
| 90 | ); | ||
| 91 | } | ||
| 92 | await writeFile(join(outDir, `${asset}.yaml`), slice.specText); | ||
| 93 | manifestAssets[asset] = { | ||
| 94 | bytes: Buffer.byteLength(slice.specText), | ||
| 95 | paths: slice.pathCount, | ||
| 96 | operations: slice.operationCount, | ||
| 97 | schemas: slice.schemaCount, | ||
| 98 | }; | ||
| 99 | console.log( | ||
| 100 | `${asset}: ${(Buffer.byteLength(slice.specText) / 1024 / 1024).toFixed(2)}MB, ` + | ||
| 101 | `${slice.pathCount} paths, ${slice.operationCount} operations, ${slice.schemaCount} schemas`, | ||
| 102 | ); | ||
| 103 | } | ||
| 104 | |||
| 105 | await writeFile( | ||
| 106 | join(outDir, "manifest.json"), | ||
| 107 | `${JSON.stringify({ source, sourceSha256, generatedAt: new Date().toISOString(), assets: manifestAssets }, null, 2)}\n`, | ||
| 108 | ); | ||
| 109 | console.log(`wrote ${selections.length} slices + manifest.json to ${outDir}`); | ||
| @@ -15,6 +15,7 @@ import { · 14 unmodified lines | |||
| 15 | } from "../../sdk/split"; | 15 | } from "../../sdk/split"; |
| 16 | import type { Authentication } from "../../sdk/types"; | 16 | import type { Authentication } from "../../sdk/types"; |
| 17 | 17 | ||
| 18 | import { fetchMicrosoftGraphSlice, microsoftGraphSliceAssetForSelection } from "./slices"; | ||
| 18 | import { | 19 | import { |
| 19 | MICROSOFT_AUTHORIZATION_URL, | 20 | MICROSOFT_AUTHORIZATION_URL, |
| 20 | MICROSOFT_AUTH_TEMPLATE_SLUG, | 21 | MICROSOFT_AUTH_TEMPLATE_SLUG, |
| @@ -759,9 +760,28 @@ export const buildMicrosoftGraphOpenApiSpec = ( · 738 unmodified lines | |||
| 759 | ): Effect.Effect<MicrosoftGraphSpecBuild, OpenApiParseError> => | 760 | ): Effect.Effect<MicrosoftGraphSpecBuild, OpenApiParseError> => |
| 760 | Effect.gen(function* () { | 761 | Effect.gen(function* () { |
| 761 | const selection = yield* validateSelectionUrls(normalizeSelection(input), urlPolicy); | 762 | const selection = yield* validateSelectionUrls(normalizeSelection(input), urlPolicy); |
| 762 | const sourceText = yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe( | 763 | // Covered selections read a precomputed slice (sub-MB) instead of the 43MB |
| 763 | Effect.provide(httpClientLayer), | 764 | // monolith: in production, the monolith fetch alone almost never survives |
| 764 | ); | 765 | // the 128MB isolate (once in the 30 days before 2026-08-26). Slices apply |
| 766 | // only to the pinned Microsoft URL — an override (local Graph emulators) | ||
| 767 | // serves its own document. A missing/failed slice (asset not yet published, | ||
| 768 | // release unreachable) falls back to the monolith path, which is the prior | ||
| 769 | // behavior for the selections a slice would have covered. | ||
| 770 | const sliceAsset = | ||
| 771 | selection.specUrl === MICROSOFT_GRAPH_OPENAPI_URL | ||
| 772 | ? microsoftGraphSliceAssetForSelection(selection) | ||
| 773 | : null; | ||
| 774 | const sourceText = | ||
| 775 | sliceAsset !== null | ||
| 776 | ? yield* fetchMicrosoftGraphSlice(sliceAsset).pipe( | ||
| 777 | Effect.catchTag("OpenApiParseError", () => | ||
| 778 | fetchMicrosoftGraphOpenApiSpec(selection.specUrl), | ||
| 779 | ), | ||
| 780 | Effect.provide(httpClientLayer), | ||
| 781 | ) | ||
| 782 | : yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe( | ||
| 783 | Effect.provide(httpClientLayer), | ||
| 784 | ); | ||
| 765 | 785 | ||
| 766 | // Structural split is the only entry point: parsing the whole 37MB tree | 786 | // Structural split is the only entry point: parsing the whole 37MB tree |
| 767 | // OOMs the 128MB Workers isolate (measured: HTTP 503). No fallback. A spec | 787 | // OOMs the 128MB Workers isolate (measured: HTTP 503). No fallback. A spec |
| @@ -0,0 +1,104 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { structuralSplit } from "../../sdk/split"; | ||
| 4 | import { buildGraphSliceDocument, parseGraphSourceDocument } from "./slice-build"; | ||
| 5 | |||
| 6 | // Graph-shaped source: a mail path, an unrelated path, and a schema chain | ||
| 7 | // where only part is reachable from the mail selection. | ||
| 8 | const source = `openapi: 3.0.4 | ||
| 9 | info: | ||
| 10 | title: Microsoft Graph Fixture | ||
| 11 | version: v1.0 | ||
| 12 | servers: | ||
| 13 | - url: https://graph.microsoft.com/v1.0 | ||
| 14 | paths: | ||
| 15 | /me/messages: | ||
| 16 | get: | ||
| 17 | operationId: me.ListMessages | ||
| 18 | security: | ||
| 19 | - azureAdDelegated: | ||
| 20 | - Mail.ReadWrite | ||
| 21 | parameters: | ||
| 22 | - $ref: '#/components/parameters/Top' | ||
| 23 | responses: | ||
| 24 | "200": | ||
| 25 | description: OK | ||
| 26 | content: | ||
| 27 | application/json: | ||
| 28 | schema: | ||
| 29 | $ref: '#/components/schemas/microsoft.graph.messageCollection' | ||
| 30 | /irrelevant: | ||
| 31 | get: | ||
| 32 | operationId: irrelevant.Get | ||
| 33 | responses: | ||
| 34 | "200": | ||
| 35 | description: OK | ||
| 36 | content: | ||
| 37 | application/json: | ||
| 38 | schema: | ||
| 39 | $ref: '#/components/schemas/microsoft.graph.unrelated' | ||
| 40 | components: | ||
| 41 | parameters: | ||
| 42 | Top: | ||
| 43 | name: $top | ||
| 44 | in: query | ||
| 45 | schema: | ||
| 46 | type: integer | ||
| 47 | securitySchemes: | ||
| 48 | azureAdDelegated: | ||
| 49 | type: oauth2 | ||
| 50 | flows: | ||
| 51 | authorizationCode: | ||
| 52 | authorizationUrl: https://login.microsoftonline.com/common/oauth2/v2.0/authorize | ||
| 53 | tokenUrl: https://login.microsoftonline.com/common/oauth2/v2.0/token | ||
| 54 | scopes: | ||
| 55 | Mail.ReadWrite: Read and write mail | ||
| 56 | schemas: | ||
| 57 | microsoft.graph.messageCollection: | ||
| 58 | type: object | ||
| 59 | properties: | ||
| 60 | value: | ||
| 61 | type: array | ||
| 62 | items: | ||
| 63 | $ref: '#/components/schemas/microsoft.graph.message' | ||
| 64 | microsoft.graph.message: | ||
| 65 | type: object | ||
| 66 | properties: | ||
| 67 | id: | ||
| 68 | type: string | ||
| 69 | microsoft.graph.unrelated: | ||
| 70 | type: object | ||
| 71 | properties: | ||
| 72 | name: | ||
| 73 | type: string | ||
| 74 | `; | ||
| 75 | |||
| 76 | describe("buildGraphSliceDocument", () => { | ||
| 77 | it("keeps the selection's paths and prunes components to the reachable closure", () => { | ||
| 78 | const doc = parseGraphSourceDocument(source); | ||
| 79 | expect(doc).not.toBeNull(); | ||
| 80 | const slice = buildGraphSliceDocument(doc!, ["mail"]); | ||
| 81 | |||
| 82 | expect(slice.pathCount).toBe(1); | ||
| 83 | expect(slice.operationCount).toBe(1); | ||
| 84 | expect(slice.specText).toContain("/me/messages"); | ||
| 85 | expect(slice.specText).not.toContain("/irrelevant"); | ||
| 86 | expect(slice.specText).toContain("microsoft.graph.messageCollection"); | ||
| 87 | expect(slice.specText).toContain("microsoft.graph.message"); | ||
| 88 | expect(slice.specText).not.toContain("microsoft.graph.unrelated"); | ||
| 89 | // Referenced small components survive; securitySchemes always survive. | ||
| 90 | expect(slice.specText).toContain("$top"); | ||
| 91 | expect(slice.specText).toContain("azureAdDelegated"); | ||
| 92 | }); | ||
| 93 | |||
| 94 | it("emits the streamable block-YAML profile the runtime splitter accepts", () => { | ||
| 95 | const doc = parseGraphSourceDocument(source); | ||
| 96 | expect(doc).not.toBeNull(); | ||
| 97 | const slice = buildGraphSliceDocument(doc!, ["mail"]); | ||
| 98 | |||
| 99 | const structure = structuralSplit(slice.specText); | ||
| 100 | expect(structure).not.toBeNull(); | ||
| 101 | expect(structure!.pathItems).toHaveLength(slice.pathCount); | ||
| 102 | expect(structure!.schemas).toHaveLength(slice.schemaCount); | ||
| 103 | }); | ||
| 104 | }); | ||
| @@ -0,0 +1,193 @@ | |||
| 1 | import { JSON_SCHEMA, dump as dumpYamlDocument, load as parseYamlDocument } from "js-yaml"; | ||
| 2 | |||
| 3 | import { microsoftGraphKeepPathItem } from "./graph"; | ||
| 4 | import { | ||
| 5 | microsoftGraphExactPathsForPresetIds, | ||
| 6 | microsoftGraphPathPrefixesForPresetIds, | ||
| 7 | microsoftGraphTagPrefixesForPresetIds, | ||
| 8 | } from "./presets"; | ||
| 9 | |||
| 10 | /** | ||
| 11 | * Offline Microsoft Graph slice construction. NOT part of the runtime import | ||
| 12 | * graph: the 43MB Graph source cannot be held in a 128MB Workers isolate (its | ||
| 13 | * fetch alone has completed once in the last 30 days of production traces), so | ||
| 14 | * slices are built where memory is free — the graph-slices workflow / a dev | ||
| 15 | * machine — published as release assets, and fetched per selection at runtime | ||
| 16 | * by `slices.ts`. Import this module only from scripts and tests. | ||
| 17 | */ | ||
| 18 | |||
| 19 | /** `components` subkeys retained whole in a slice — mirrors the runtime | ||
| 20 | * splitter's `SMALL_COMPONENT_SECTIONS` (`sdk/split.ts`), which keeps these | ||
| 21 | * resident for `$ref` resolution. `schemas` is pruned to the transitive | ||
| 22 | * closure instead; `examples` is dropped. */ | ||
| 23 | const SMALL_COMPONENT_SECTIONS = [ | ||
| 24 | "parameters", | ||
| 25 | "requestBodies", | ||
| 26 | "responses", | ||
| 27 | "headers", | ||
| 28 | "links", | ||
| 29 | "securitySchemes", | ||
| 30 | ] as const; | ||
| 31 | |||
| 32 | const COMPONENT_REF_PREFIX = "#/components/"; | ||
| 33 | |||
| 34 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 35 | value !== null && typeof value === "object" && !Array.isArray(value); | ||
| 36 | |||
| 37 | const decodeRefSegment = (segment: string): string => | ||
| 38 | segment.replace(/~1/g, "/").replace(/~0/g, "~"); | ||
| 39 | |||
| 40 | interface ComponentRef { | ||
| 41 | readonly section: string; | ||
| 42 | readonly name: string; | ||
| 43 | } | ||
| 44 | |||
| 45 | const collectComponentRefs = (value: unknown, into: (ref: ComponentRef) => void): void => { | ||
| 46 | if (typeof value === "string") { | ||
| 47 | if (value.startsWith(COMPONENT_REF_PREFIX)) { | ||
| 48 | const rest = value.slice(COMPONENT_REF_PREFIX.length); | ||
| 49 | const slash = rest.indexOf("/"); | ||
| 50 | if (slash > 0) { | ||
| 51 | const section = rest.slice(0, slash); | ||
| 52 | const name = decodeRefSegment(rest.slice(slash + 1)); | ||
| 53 | if (name.length > 0) into({ section, name }); | ||
| 54 | } | ||
| 55 | } | ||
| 56 | return; | ||
| 57 | } | ||
| 58 | if (Array.isArray(value)) { | ||
| 59 | for (const item of value) collectComponentRefs(item, into); | ||
| 60 | return; | ||
| 61 | } | ||
| 62 | if (isRecord(value)) { | ||
| 63 | for (const item of Object.values(value)) collectComponentRefs(item, into); | ||
| 64 | } | ||
| 65 | }; | ||
| 66 | |||
| 67 | /** | ||
| 68 | * Transitive `#/components/<section>/<name>` closure over every component | ||
| 69 | * section, seeded from `roots` (the kept path-items). Only components actually | ||
| 70 | * reachable from a kept operation survive — Graph's ~8k schemas and its | ||
| 71 | * catch-all responses/parameters sections otherwise drag nearly the whole | ||
| 72 | * component graph into every slice. | ||
| 73 | */ | ||
| 74 | const componentClosure = ( | ||
| 75 | components: Record<string, unknown>, | ||
| 76 | roots: readonly unknown[], | ||
| 77 | ): Record<string, Record<string, unknown>> => { | ||
| 78 | const kept: Record<string, Record<string, unknown>> = {}; | ||
| 79 | const queue: ComponentRef[] = []; | ||
| 80 | const seen = new Set<string>(); | ||
| 81 | const enqueue = (ref: ComponentRef): void => { | ||
| 82 | const key = `${ref.section}/${ref.name}`; | ||
| 83 | if (seen.has(key)) return; | ||
| 84 | seen.add(key); | ||
| 85 | queue.push(ref); | ||
| 86 | }; | ||
| 87 | |||
| 88 | for (const root of roots) collectComponentRefs(root, enqueue); | ||
| 89 | for (let i = 0; i < queue.length; i += 1) { | ||
| 90 | const { section, name } = queue[i]!; | ||
| 91 | const sectionValues = components[section]; | ||
| 92 | if (!isRecord(sectionValues)) continue; | ||
| 93 | const component = sectionValues[name]; | ||
| 94 | if (component === undefined) continue; | ||
| 95 | (kept[section] ??= {})[name] = component; | ||
| 96 | collectComponentRefs(component, enqueue); | ||
| 97 | } | ||
| 98 | return kept; | ||
| 99 | }; | ||
| 100 | |||
| 101 | export interface GraphSliceBuild { | ||
| 102 | /** The slice as streamable block YAML (the same profile `structuralSplit` | ||
| 103 | * accepts, so the runtime pipeline treats a slice exactly like a source). */ | ||
| 104 | readonly specText: string; | ||
| 105 | readonly pathCount: number; | ||
| 106 | readonly operationCount: number; | ||
| 107 | readonly schemaCount: number; | ||
| 108 | } | ||
| 109 | |||
| 110 | const HTTP_METHODS = new Set(["delete", "get", "head", "options", "patch", "post", "put", "trace"]); | ||
| 111 | |||
| 112 | const countOperations = (paths: Record<string, unknown>): number => { | ||
| 113 | let count = 0; | ||
| 114 | for (const pathItem of Object.values(paths)) { | ||
| 115 | if (!isRecord(pathItem)) continue; | ||
| 116 | for (const key of Object.keys(pathItem)) { | ||
| 117 | if (HTTP_METHODS.has(key.toLowerCase())) count += 1; | ||
| 118 | } | ||
| 119 | } | ||
| 120 | return count; | ||
| 121 | }; | ||
| 122 | |||
| 123 | /** | ||
| 124 | * Build one preset selection's slice from the parsed Graph document: keep the | ||
| 125 | * selection's path-items (via the same `microsoftGraphKeepPathItem` filter the | ||
| 126 | * runtime applies), retain the small component sections whole, and prune | ||
| 127 | * `components.schemas` to the transitive `$ref` closure of everything kept. | ||
| 128 | */ | ||
| 129 | export const buildGraphSliceDocument = ( | ||
| 130 | doc: Record<string, unknown>, | ||
| 131 | presetIds: readonly string[], | ||
| 132 | ): GraphSliceBuild => { | ||
| 133 | const keepPathItem = microsoftGraphKeepPathItem({ | ||
| 134 | coversFullGraph: false, | ||
| 135 | presetIds, | ||
| 136 | customScopes: [], | ||
| 137 | exactPaths: microsoftGraphExactPathsForPresetIds(presetIds), | ||
| 138 | pathPrefixes: microsoftGraphPathPrefixesForPresetIds(presetIds), | ||
| 139 | tagPrefixes: microsoftGraphTagPrefixesForPresetIds(presetIds), | ||
| 140 | }); | ||
| 141 | |||
| 142 | const sourcePaths = isRecord(doc.paths) ? doc.paths : {}; | ||
| 143 | const paths: Record<string, unknown> = {}; | ||
| 144 | for (const [path, pathItem] of Object.entries(sourcePaths)) { | ||
| 145 | if (!isRecord(pathItem)) continue; | ||
| 146 | const kept = keepPathItem(path, pathItem); | ||
| 147 | if (kept) paths[path] = kept; | ||
| 148 | } | ||
| 149 | |||
| 150 | const sourceComponents = isRecord(doc.components) ? doc.components : {}; | ||
| 151 | const closure = componentClosure(sourceComponents, [paths]); | ||
| 152 | const components: Record<string, unknown> = {}; | ||
| 153 | for (const section of SMALL_COMPONENT_SECTIONS) { | ||
| 154 | if (closure[section]) components[section] = closure[section]; | ||
| 155 | } | ||
| 156 | // securitySchemes are never `$ref`'d from operations by name in Graph | ||
| 157 | // (operations reference them via `security` entries), so retain the section | ||
| 158 | // whole — it is tiny and the runtime reads OAuth endpoints from it. | ||
| 159 | if (isRecord(sourceComponents.securitySchemes)) { | ||
| 160 | components.securitySchemes = sourceComponents.securitySchemes; | ||
| 161 | } | ||
| 162 | const schemas = closure.schemas ?? {}; | ||
| 163 | components.schemas = schemas; | ||
| 164 | |||
| 165 | const slice: Record<string, unknown> = { | ||
| 166 | ...(doc.openapi !== undefined ? { openapi: doc.openapi } : {}), | ||
| 167 | ...(doc.info !== undefined ? { info: doc.info } : {}), | ||
| 168 | ...(doc.servers !== undefined ? { servers: doc.servers } : {}), | ||
| 169 | ...(doc.security !== undefined ? { security: doc.security } : {}), | ||
| 170 | paths, | ||
| 171 | components, | ||
| 172 | }; | ||
| 173 | |||
| 174 | // noRefs duplicates shared subtrees instead of emitting YAML anchors, which | ||
| 175 | // the streamable block profile forbids; lineWidth -1 keeps scalars on one | ||
| 176 | // line so no wrapped line can be mistaken for structure. | ||
| 177 | const specText = dumpYamlDocument(slice, { noRefs: true, lineWidth: -1, schema: JSON_SCHEMA }); | ||
| 178 | |||
| 179 | return { | ||
| 180 | specText, | ||
| 181 | pathCount: Object.keys(paths).length, | ||
| 182 | operationCount: countOperations(paths), | ||
| 183 | schemaCount: Object.keys(schemas).length, | ||
| 184 | }; | ||
| 185 | }; | ||
| 186 | |||
| 187 | /** Parse Graph source YAML for slice building, or null when the source does | ||
| 188 | * not parse to an object. Offline-only: this is the whole-document parse the | ||
| 189 | * runtime can never do. */ | ||
| 190 | export const parseGraphSourceDocument = (sourceText: string): Record<string, unknown> | null => { | ||
| 191 | const parsed = parseYamlDocument(sourceText, { json: true, schema: JSON_SCHEMA }); | ||
| 192 | return isRecord(parsed) ? parsed : null; | ||
| 193 | }; | ||
| @@ -0,0 +1,70 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { MICROSOFT_GRAPH_ALL_PRESET_IDS, MICROSOFT_GRAPH_DEFAULT_PRESET_IDS } from "./presets"; | ||
| 4 | import { | ||
| 5 | MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET, | ||
| 6 | microsoftGraphSliceAssetForSelection, | ||
| 7 | } from "./slices"; | ||
| 8 | |||
| 9 | describe("microsoftGraphSliceAssetForSelection", () => { | ||
| 10 | it("maps a single catalog preset to its asset", () => { | ||
| 11 | expect( | ||
| 12 | microsoftGraphSliceAssetForSelection({ | ||
| 13 | coversFullGraph: false, | ||
| 14 | presetIds: ["mail"], | ||
| 15 | customScopes: [], | ||
| 16 | }), | ||
| 17 | ).toBe("mail"); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("maps the default bundle in any order to the default asset", () => { | ||
| 21 | expect( | ||
| 22 | microsoftGraphSliceAssetForSelection({ | ||
| 23 | coversFullGraph: false, | ||
| 24 | presetIds: [...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS].reverse(), | ||
| 25 | customScopes: [], | ||
| 26 | }), | ||
| 27 | ).toBe(MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET); | ||
| 28 | }); | ||
| 29 | |||
| 30 | it("serves combinations within the default bundle from the default slice", () => { | ||
| 31 | expect( | ||
| 32 | microsoftGraphSliceAssetForSelection({ | ||
| 33 | coversFullGraph: false, | ||
| 34 | presetIds: ["mail", "calendar"], | ||
| 35 | customScopes: [], | ||
| 36 | }), | ||
| 37 | ).toBe(MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET); | ||
| 38 | }); | ||
| 39 | |||
| 40 | it("needs the monolith for full-graph, custom scopes, unknown presets, and combinations outside the default bundle", () => { | ||
| 41 | expect( | ||
| 42 | microsoftGraphSliceAssetForSelection({ | ||
| 43 | coversFullGraph: true, | ||
| 44 | presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS], | ||
| 45 | customScopes: [], | ||
| 46 | }), | ||
| 47 | ).toBeNull(); | ||
| 48 | expect( | ||
| 49 | microsoftGraphSliceAssetForSelection({ | ||
| 50 | coversFullGraph: false, | ||
| 51 | presetIds: ["mail"], | ||
| 52 | customScopes: ["Chat.Read"], | ||
| 53 | }), | ||
| 54 | ).toBeNull(); | ||
| 55 | expect( | ||
| 56 | microsoftGraphSliceAssetForSelection({ | ||
| 57 | coversFullGraph: false, | ||
| 58 | presetIds: ["not-a-preset"], | ||
| 59 | customScopes: [], | ||
| 60 | }), | ||
| 61 | ).toBeNull(); | ||
| 62 | expect( | ||
| 63 | microsoftGraphSliceAssetForSelection({ | ||
| 64 | coversFullGraph: false, | ||
| 65 | presetIds: ["mail", "users"], | ||
| 66 | customScopes: [], | ||
| 67 | }), | ||
| 68 | ).toBeNull(); | ||
| 69 | }); | ||
| 70 | }); | ||
| @@ -0,0 +1,87 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | ||
| 3 | |||
| 4 | import { OpenApiParseError } from "../../sdk/errors"; | ||
| 5 | |||
| 6 | import { MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, microsoftGraphPresetForId } from "./presets"; | ||
| 7 | |||
| 8 | /** | ||
| 9 | * Runtime access to precomputed Microsoft Graph slices. | ||
| 10 | * | ||
| 11 | * The 43MB Graph monolith cannot be processed in a 128MB Workers isolate — in | ||
| 12 | * production its fetch alone completed once in the 30 days before 2026-08-26; | ||
| 13 | * every other preview/add died mid-download with an empty 503. Slices are | ||
| 14 | * built offline (`slice-build.ts`, refreshed by the graph-slices workflow), | ||
| 15 | * published as release assets, and fetched here per selection: the isolate | ||
| 16 | * only ever holds the sub-megabyte filtered document for the selection. | ||
| 17 | */ | ||
| 18 | |||
| 19 | export const MICROSOFT_GRAPH_SLICE_RELEASE_TAG = "graph-slices"; | ||
| 20 | |||
| 21 | export const MICROSOFT_GRAPH_SLICE_BASE_URL = `https://github.com/UsefulSoftwareCo/executor/releases/download/${MICROSOFT_GRAPH_SLICE_RELEASE_TAG}`; | ||
| 22 | |||
| 23 | /** Asset name for the default catalog bundle (`MICROSOFT_GRAPH_DEFAULT_PRESET_IDS`). */ | ||
| 24 | export const MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET = "default"; | ||
| 25 | |||
| 26 | /** | ||
| 27 | * The published asset covering a selection, or null when the selection needs | ||
| 28 | * the monolith. A slice may be a superset of the selection: the runtime always | ||
| 29 | * applies the selection's `keepPathItem` filter to whatever source it reads, so | ||
| 30 | * any combination within the default bundle can be served from the default | ||
| 31 | * slice and narrowed in-band. The monolith remains necessary for full-graph | ||
| 32 | * coverage, custom scopes (scope matching walks operations outside any | ||
| 33 | * preset's paths), and combinations reaching outside the default bundle | ||
| 34 | * (precomputing every combination is combinatorial). | ||
| 35 | */ | ||
| 36 | export const microsoftGraphSliceAssetForSelection = (selection: { | ||
| 37 | readonly coversFullGraph: boolean; | ||
| 38 | readonly presetIds: readonly string[]; | ||
| 39 | readonly customScopes: readonly string[]; | ||
| 40 | }): string | null => { | ||
| 41 | if (selection.coversFullGraph) return null; | ||
| 42 | if (selection.customScopes.length > 0) return null; | ||
| 43 | if (selection.presetIds.length === 0) return null; | ||
| 44 | if (selection.presetIds.some((presetId) => !microsoftGraphPresetForId(presetId))) return null; | ||
| 45 | if (selection.presetIds.length === 1) return selection.presetIds[0]!; | ||
| 46 | const defaultIds = new Set(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS); | ||
| 47 | if (selection.presetIds.every((presetId) => defaultIds.has(presetId))) { | ||
| 48 | return MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET; | ||
| 49 | } | ||
| 50 | return null; | ||
| 51 | }; | ||
| 52 | |||
| 53 | export const microsoftGraphSliceUrl = (asset: string): string => | ||
| 54 | `${MICROSOFT_GRAPH_SLICE_BASE_URL}/${encodeURIComponent(asset)}.yaml`; | ||
| 55 | |||
| 56 | export const fetchMicrosoftGraphSlice = Effect.fn("Microsoft.fetchGraphSlice")(function* ( | ||
| 57 | asset: string, | ||
| 58 | ) { | ||
| 59 | const client = yield* HttpClient.HttpClient; | ||
| 60 | const response = yield* client | ||
| 61 | .execute( | ||
| 62 | HttpClientRequest.get(microsoftGraphSliceUrl(asset)).pipe( | ||
| 63 | HttpClientRequest.setHeader("Accept", "application/yaml, text/yaml, */*"), | ||
| 64 | ), | ||
| 65 | ) | ||
| 66 | .pipe( | ||
| 67 | Effect.mapError( | ||
| 68 | () => | ||
| 69 | new OpenApiParseError({ | ||
| 70 | message: `Failed to fetch Microsoft Graph slice: ${asset}`, | ||
| 71 | }), | ||
| 72 | ), | ||
| 73 | ); | ||
| 74 | if (response.status < 200 || response.status >= 300) { | ||
| 75 | return yield* new OpenApiParseError({ | ||
| 76 | message: `Failed to fetch Microsoft Graph slice ${asset}: HTTP ${response.status}`, | ||
| 77 | }); | ||
| 78 | } | ||
| 79 | return yield* response.text.pipe( | ||
| 80 | Effect.mapError( | ||
| 81 | () => | ||
| 82 | new OpenApiParseError({ | ||
| 83 | message: `Failed to read Microsoft Graph slice body: ${asset}`, | ||
| 84 | }), | ||
| 85 | ), | ||
| 86 | ); | ||
| 87 | }); | ||
| @@ -6,6 +6,7 @@ import { structuralSplit } from "@executor-js/plugin-openapi"; · 5 unmodified lines | |||
| 6 | import { previewSpecTextStreaming } from "../../sdk/preview"; | 6 | import { previewSpecTextStreaming } from "../../sdk/preview"; |
| 7 | import { microsoftGraphAdapter } from "./spec-format-adapter"; | 7 | import { microsoftGraphAdapter } from "./spec-format-adapter"; |
| 8 | import { MICROSOFT_GRAPH_OPENAPI_URL } from "./presets"; | 8 | import { MICROSOFT_GRAPH_OPENAPI_URL } from "./presets"; |
| 9 | import { microsoftGraphSliceUrl } from "./slices"; | ||
| 9 | 10 | ||
| 10 | const graphFixture = ` | 11 | const graphFixture = ` |
| 11 | openapi: 3.0.4 | 12 | openapi: 3.0.4 |
| @@ -92,6 +93,80 @@ it.effect("uses catalog URL fragments to select one Graph workload", () => · 80 unmodified lines | |||
| 92 | }), | 93 | }), |
| 93 | ); | 94 | ); |
| 94 | 95 | ||
| 96 | // Distinct content at the slice URL so tests can tell which source was read. | ||
| 97 | const sliceFixture = `openapi: 3.0.4 | ||
| 98 | info: | ||
| 99 | title: Microsoft Graph Slice Fixture | ||
| 100 | version: v1.0 | ||
| 101 | servers: | ||
| 102 | - url: https://graph.microsoft.com/v1.0 | ||
| 103 | paths: | ||
| 104 | /me: | ||
| 105 | get: | ||
| 106 | operationId: me.GetUser | ||
| 107 | security: | ||
| 108 | - azureAdDelegated: | ||
| 109 | - User.Read | ||
| 110 | responses: | ||
| 111 | "200": | ||
| 112 | description: OK | ||
| 113 | components: | ||
| 114 | securitySchemes: | ||
| 115 | azureAdDelegated: | ||
| 116 | type: oauth2 | ||
| 117 | flows: | ||
| 118 | authorizationCode: | ||
| 119 | authorizationUrl: https://login.microsoftonline.com/common/oauth2/v2.0/authorize | ||
| 120 | tokenUrl: https://login.microsoftonline.com/common/oauth2/v2.0/token | ||
| 121 | scopes: | ||
| 122 | User.Read: Read user profile | ||
| 123 | `; | ||
| 124 | |||
| 125 | const sliceAwareHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | ||
| 126 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { | ||
| 127 | const body = | ||
| 128 | request.url === microsoftGraphSliceUrl("profile") | ||
| 129 | ? sliceFixture | ||
| 130 | : request.url === MICROSOFT_GRAPH_OPENAPI_URL | ||
| 131 | ? graphFixture | ||
| 132 | : null; | ||
| 133 | return Effect.succeed( | ||
| 134 | HttpClientResponse.fromWeb( | ||
| 135 | request, | ||
| 136 | new Response(body ?? "not found", { status: body === null ? 404 : 200 }), | ||
| 137 | ), | ||
| 138 | ); | ||
| 139 | }), | ||
| 140 | ); | ||
| 141 | |||
| 142 | it.effect("reads the published slice for a covered selection", () => | ||
| 143 | Effect.gen(function* () { | ||
| 144 | const converted = yield* microsoftGraphAdapter.fetch({ | ||
| 145 | urls: [`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=profile`], | ||
| 146 | httpClientLayer: sliceAwareHttpClientLayer, | ||
| 147 | }); | ||
| 148 | |||
| 149 | expect(converted.specText).toBe(sliceFixture); | ||
| 150 | // The catalog URL (with fragment stripped) stays canonical so refresh | ||
| 151 | // re-resolves through the adapter, not the slice hosting. | ||
| 152 | expect(converted.specUrl).toBe(MICROSOFT_GRAPH_OPENAPI_URL); | ||
| 153 | }), | ||
| 154 | ); | ||
| 155 | |||
| 156 | it.effect("falls back to the monolith when the slice asset is unavailable", () => | ||
| 157 | Effect.gen(function* () { | ||
| 158 | // graphHttpClientLayer 404s everything except the monolith URL, including | ||
| 159 | // the slice URL — the existing selection tests above exercise this same | ||
| 160 | // fallback implicitly. | ||
| 161 | const converted = yield* microsoftGraphAdapter.fetch({ | ||
| 162 | urls: [`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=profile`], | ||
| 163 | httpClientLayer: graphHttpClientLayer, | ||
| 164 | }); | ||
| 165 | |||
| 166 | expect(converted.specText).toBe(graphFixture); | ||
| 167 | }), | ||
| 168 | ); | ||
| 169 | |||
| 95 | it.effect("stream-previews a Graph selection without a whole-document parse", () => | 170 | it.effect("stream-previews a Graph selection without a whole-document parse", () => |
| 96 | Effect.gen(function* () { | 171 | Effect.gen(function* () { |
| 97 | const converted = yield* microsoftGraphAdapter.fetch({ | 172 | const converted = yield* microsoftGraphAdapter.fetch({ |