Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/graph-spec-slices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/plugin-openapi": patch
---

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.
47 changes: 47 additions & 0 deletions .github/workflows/graph-slices.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Refresh the Microsoft Graph slice release assets.
#
# The Graph OpenAPI monolith (~43MB) cannot be processed inside a Workers
# isolate, so the runtime reads per-selection slices published on the
# `graph-slices` release tag (see packages/plugins/openapi/src/providers/
# microsoft/slices.ts). This workflow rebuilds the slices from the current
# upstream spec on a schedule and on demand.
name: Graph slices

on:
schedule:
# Weekly; Microsoft's msgraph-metadata automation lands upstream refreshes
# on a similar cadence. A failed run leaves the previous assets serving.
- cron: "17 6 * * 1"
workflow_dispatch: {}

permissions:
contents: write

jobs:
slices:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.11

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Generate slices
working-directory: packages/plugins/openapi
run: bun scripts/generate-graph-slices.ts --out "$RUNNER_TEMP/graph-slices"

- name: Publish to the graph-slices release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release view graph-slices --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \
gh release create graph-slices --repo "$GITHUB_REPOSITORY" \
--title "Microsoft Graph slices" --latest=false \
--notes "Generated per-preset Microsoft Graph OpenAPI slices. Data release consumed by the openapi plugin's Microsoft adapter; refreshed by the graph-slices workflow."
gh release upload graph-slices "$RUNNER_TEMP/graph-slices"/* \
--repo "$GITHUB_REPOSITORY" --clobber
1 change: 1 addition & 0 deletions .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"apps/desktop/src/main.ts",
"scripts/**/*.{ts,js}",
"apps/*/scripts/**/*.{ts,js}",
"packages/*/*/scripts/**/*.{ts,js}",
"packages/kernel/runtime-*/src/**/*.{ts,tsx,js,mjs}",
],
"rules": {
Expand Down
109 changes: 109 additions & 0 deletions packages/plugins/openapi/scripts/generate-graph-slices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Generate the Microsoft Graph slice release assets.
*
* bun scripts/generate-graph-slices.ts [--source <path-or-url>] [--out <dir>]
*
* Fetches (or reads) the Graph OpenAPI monolith, builds one slice per catalog
* preset plus the default bundle via `slice-build.ts`, validates every slice
* against the runtime's streamable profile, and writes `<asset>.yaml` files
* plus `manifest.json` to the output directory. The graph-slices workflow runs
* this and uploads the output to the `graph-slices` release tag; runtime
* resolution lives in `src/providers/microsoft/slices.ts`.
*
* Offline-only: this whole-parses the 43MB source, which only works where
* memory is free (CI runner / dev machine), never in a Workers isolate.
*/
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";

import { structuralSplit } from "../src/sdk/split";
import {
MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
MICROSOFT_GRAPH_OPENAPI_URL,
microsoftGraphScopePresets,
} from "../src/providers/microsoft/presets";
import { MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET } from "../src/providers/microsoft/slices";
import {
buildGraphSliceDocument,
parseGraphSourceDocument,
} from "../src/providers/microsoft/slice-build";

const argValue = (flag: string): string | undefined => {
const index = process.argv.indexOf(flag);
return index !== -1 ? process.argv[index + 1] : undefined;
};

const source = argValue("--source") ?? MICROSOFT_GRAPH_OPENAPI_URL;
const outDir = argValue("--out") ?? "graph-slices-out";

const readSource = async (): Promise<string> => {
if (source.startsWith("http://") || source.startsWith("https://")) {
const response = await fetch(source);
if (!response.ok) {
throw new Error(`Failed to fetch Graph source: HTTP ${response.status}`);
}
return response.text();
}
return readFile(source, "utf8");
};

const sourceText = await readSource();
const sourceSha256 = createHash("sha256").update(sourceText).digest("hex");
const doc = parseGraphSourceDocument(sourceText);
if (!doc) {
throw new Error("Microsoft Graph source did not parse to an object");
}

const selections: readonly { readonly asset: string; readonly presetIds: readonly string[] }[] = [
...microsoftGraphScopePresets.map((preset) => ({ asset: preset.id, presetIds: [preset.id] })),
{
asset: MICROSOFT_GRAPH_DEFAULT_SLICE_ASSET,
presetIds: MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
},
];

await mkdir(outDir, { recursive: true });

const manifestAssets: Record<
string,
{
readonly bytes: number;
readonly paths: number;
readonly operations: number;
readonly schemas: number;
}
> = {};

for (const { asset, presetIds } of selections) {
const slice = buildGraphSliceDocument(doc, presetIds);
if (slice.operationCount === 0) {
throw new Error(`Slice "${asset}" kept zero operations — preset filter or source drifted`);
}
const structure = structuralSplit(slice.specText);
if (!structure) {
throw new Error(`Slice "${asset}" is not in the streamable block-YAML profile`);
}
if (structure.pathItems.length !== slice.pathCount) {
throw new Error(
`Slice "${asset}" splitter sees ${structure.pathItems.length} path-items, expected ${slice.pathCount}`,
);
}
await writeFile(join(outDir, `${asset}.yaml`), slice.specText);
manifestAssets[asset] = {
bytes: Buffer.byteLength(slice.specText),
paths: slice.pathCount,
operations: slice.operationCount,
schemas: slice.schemaCount,
};
console.log(
`${asset}: ${(Buffer.byteLength(slice.specText) / 1024 / 1024).toFixed(2)}MB, ` +
`${slice.pathCount} paths, ${slice.operationCount} operations, ${slice.schemaCount} schemas`,
);
}

await writeFile(
join(outDir, "manifest.json"),
`${JSON.stringify({ source, sourceSha256, generatedAt: new Date().toISOString(), assets: manifestAssets }, null, 2)}\n`,
);
console.log(`wrote ${selections.length} slices + manifest.json to ${outDir}`);
26 changes: 23 additions & 3 deletions packages/plugins/openapi/src/providers/microsoft/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "../../sdk/split";
import type { Authentication } from "../../sdk/types";

import { fetchMicrosoftGraphSlice, microsoftGraphSliceAssetForSelection } from "./slices";
import {
MICROSOFT_AUTHORIZATION_URL,
MICROSOFT_AUTH_TEMPLATE_SLUG,
Expand Down Expand Up @@ -759,9 +760,28 @@ export const buildMicrosoftGraphOpenApiSpec = (
): Effect.Effect<MicrosoftGraphSpecBuild, OpenApiParseError> =>
Effect.gen(function* () {
const selection = yield* validateSelectionUrls(normalizeSelection(input), urlPolicy);
const sourceText = yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe(
Effect.provide(httpClientLayer),
);
// Covered selections read a precomputed slice (sub-MB) instead of the 43MB
// monolith: in production, the monolith fetch alone almost never survives
// the 128MB isolate (once in the 30 days before 2026-08-26). Slices apply
// only to the pinned Microsoft URL — an override (local Graph emulators)
// serves its own document. A missing/failed slice (asset not yet published,
// release unreachable) falls back to the monolith path, which is the prior
// behavior for the selections a slice would have covered.
const sliceAsset =
selection.specUrl === MICROSOFT_GRAPH_OPENAPI_URL
? microsoftGraphSliceAssetForSelection(selection)
: null;
const sourceText =
sliceAsset !== null
? yield* fetchMicrosoftGraphSlice(sliceAsset).pipe(
Effect.catchTag("OpenApiParseError", () =>
fetchMicrosoftGraphOpenApiSpec(selection.specUrl),
),
Effect.provide(httpClientLayer),
)
: yield* fetchMicrosoftGraphOpenApiSpec(selection.specUrl).pipe(
Effect.provide(httpClientLayer),
);

// Structural split is the only entry point: parsing the whole 37MB tree
// OOMs the 128MB Workers isolate (measured: HTTP 503). No fallback. A spec
Expand Down
104 changes: 104 additions & 0 deletions packages/plugins/openapi/src/providers/microsoft/slice-build.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, it } from "@effect/vitest";

import { structuralSplit } from "../../sdk/split";
import { buildGraphSliceDocument, parseGraphSourceDocument } from "./slice-build";

// Graph-shaped source: a mail path, an unrelated path, and a schema chain
// where only part is reachable from the mail selection.
const source = `openapi: 3.0.4
info:
title: Microsoft Graph Fixture
version: v1.0
servers:
- url: https://graph.microsoft.com/v1.0
paths:
/me/messages:
get:
operationId: me.ListMessages
security:
- azureAdDelegated:
- Mail.ReadWrite
parameters:
- $ref: '#/components/parameters/Top'
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/microsoft.graph.messageCollection'
/irrelevant:
get:
operationId: irrelevant.Get
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/microsoft.graph.unrelated'
components:
parameters:
Top:
name: $top
in: query
schema:
type: integer
securitySchemes:
azureAdDelegated:
type: oauth2
flows:
authorizationCode:
authorizationUrl: https://login.microsoftonline.com/common/oauth2/v2.0/authorize
tokenUrl: https://login.microsoftonline.com/common/oauth2/v2.0/token
scopes:
Mail.ReadWrite: Read and write mail
schemas:
microsoft.graph.messageCollection:
type: object
properties:
value:
type: array
items:
$ref: '#/components/schemas/microsoft.graph.message'
microsoft.graph.message:
type: object
properties:
id:
type: string
microsoft.graph.unrelated:
type: object
properties:
name:
type: string
`;

describe("buildGraphSliceDocument", () => {
it("keeps the selection's paths and prunes components to the reachable closure", () => {
const doc = parseGraphSourceDocument(source);
expect(doc).not.toBeNull();
const slice = buildGraphSliceDocument(doc!, ["mail"]);

expect(slice.pathCount).toBe(1);
expect(slice.operationCount).toBe(1);
expect(slice.specText).toContain("/me/messages");
expect(slice.specText).not.toContain("/irrelevant");
expect(slice.specText).toContain("microsoft.graph.messageCollection");
expect(slice.specText).toContain("microsoft.graph.message");
expect(slice.specText).not.toContain("microsoft.graph.unrelated");
// Referenced small components survive; securitySchemes always survive.
expect(slice.specText).toContain("$top");
expect(slice.specText).toContain("azureAdDelegated");
});

it("emits the streamable block-YAML profile the runtime splitter accepts", () => {
const doc = parseGraphSourceDocument(source);
expect(doc).not.toBeNull();
const slice = buildGraphSliceDocument(doc!, ["mail"]);

const structure = structuralSplit(slice.specText);
expect(structure).not.toBeNull();
expect(structure!.pathItems).toHaveLength(slice.pathCount);
expect(structure!.schemas).toHaveLength(slice.schemaCount);
});
});
Loading
Loading