Skip to content

Commit aa423aa

Browse files
committed
Serve Microsoft Graph selections from precomputed spec slices
1 parent 0007474 commit aa423aa

10 files changed

Lines changed: 714 additions & 3 deletions

File tree

.changeset/graph-spec-slices.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

.github/workflows/graph-slices.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

.oxlintrc.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"apps/desktop/src/main.ts",
6565
"scripts/**/*.{ts,js}",
6666
"apps/*/scripts/**/*.{ts,js}",
67+
"packages/*/*/scripts/**/*.{ts,js}",
6768
"packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}",
6869
],
6970
"rules": {
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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}`);

packages/plugins/openapi/src/providers/microsoft/graph.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from "../../sdk/split";
1616
import type { Authentication } from "../../sdk/types";
1717

18+
import { fetchMicrosoftGraphSlice, microsoftGraphSliceAssetForSelection } from "./slices";
1819
import {
1920
MICROSOFT_AUTHORIZATION_URL,
2021
MICROSOFT_AUTH_TEMPLATE_SLUG,
@@ -759,9 +760,28 @@ export const buildMicrosoftGraphOpenApiSpec = (
759760
): Effect.Effect<MicrosoftGraphSpecBuild, OpenApiParseError> =>
760761
Effect.gen(function* () {
761762
const selection = yield* validateSelectionUrls(normalizeSelection(input), urlPolicy);
762-
const sourceText = yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe(
763-
Effect.provide(httpClientLayer),
764-
);
763+
// Covered selections read a precomputed slice (sub-MB) instead of the 43MB
764+
// monolith: in production, the monolith fetch alone almost never survives
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+
);
765785

766786
// Structural split is the only entry point: parsing the whole 37MB tree
767787
// OOMs the 128MB Workers isolate (measured: HTTP 503). No fallback. A spec
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)