Skip to content
Closed
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
42 changes: 42 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@ env:
TURBO_TELEMETRY_DISABLED: "1"

jobs:
typecheck-budget:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun i
# Fails the PR if any package's type-instantiation count grows past its
# committed budget (scripts/typecheck-budget.json). Re-baseline intentional
# growth with: bun run typecheck:budget --update
- name: Typecheck budget guard
run: bun run typecheck:budget

build:
runs-on: ubuntu-latest
steps:
Expand Down Expand Up @@ -246,6 +263,13 @@ jobs:
with:
bun-version: latest
- run: bun i
- name: Cache Hugging Face model downloads
uses: actions/cache@v4
with:
path: models
key: hf-models-rag-${{ runner.os }}-${{ hashFiles('packages/test/src/samples/**') }}
restore-keys: |
hf-models-rag-${{ runner.os }}-
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
Expand Down Expand Up @@ -276,12 +300,21 @@ jobs:
with:
bun-version: latest
- run: bun i
- name: Cache Hugging Face model downloads
uses: actions/cache@v4
with:
path: models
key: hf-models-hft-${{ runner.os }}-${{ hashFiles('packages/test/src/samples/**') }}
restore-keys: |
hf-models-hft-${{ runner.os }}-
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
name: build-output
path: .
- name: Run HuggingFace Transformers provider tests via vitest
env:
WORKGLOW_SECRETS_PASSPHRASE: ${{ secrets.WORKGLOW_SECRETS_PASSPHRASE }}
run: bun run test:vitest:ai-provider-hft
- name: Copy Vitest coverage fragment
run: cp coverage/coverage-final.json vitest-ai-provider-hft-coverage.json
Expand All @@ -304,12 +337,21 @@ jobs:
with:
bun-version: latest
- run: bun i
- name: Cache Hugging Face / GGUF model downloads
uses: actions/cache@v4
with:
path: models
key: hf-models-nodellama-${{ runner.os }}-${{ hashFiles('packages/test/src/samples/**') }}
restore-keys: |
hf-models-nodellama-${{ runner.os }}-
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
name: build-output
path: .
- name: Run LlamaCpp provider tests via vitest
env:
WORKGLOW_SECRETS_PASSPHRASE: ${{ secrets.WORKGLOW_SECRETS_PASSPHRASE }}
run: bun run test:vitest:ai-provider-nodellama
- name: Copy Vitest coverage fragment
run: cp coverage/coverage-final.json vitest-ai-provider-nodellama-coverage.json
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"key":"hf-token","value":"{\"encrypted\":\"eZT55PHECPQsE5HycJH/V8SKUiEVuSzpUcjtuvDbVCK42eRvav0eZGMhfjv3vetebvIdYJcJzwpNYIc0ZOKuZbTBUStW\",\"iv\":\"df4IdKxc2JYoz5CV\",\"provider\":\"huggingface\",\"createdAt\":\"2026-06-10T14:33:08.480Z\",\"updatedAt\":\"2026-06-10T15:00:29.988Z\"}"}
146 changes: 146 additions & 0 deletions docs/technical/typecheck-performance-investigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Typecheck performance investigation

_Investigation of the gradual `tsc` typecheck slowdown across the monorepo, with
bisected culprit commits, root-cause analysis, and the guardrail added to catch
future regressions._

## TL;DR

- Full-graph typecheck (pinned `tsc`, each commit's own deps) grew from **~11s
(mid-Feb 2026) to ~40s today (~3.6×)**.
- Two regressions were isolated by `git bisect`:
1. **`16a94b54`** — MCP task schemas switched to spreading `…properties` +
`allOf`, exploding `packages/tasks` instantiations **289k → 6.77M (23×)**.
_Already fixed_ (tasks is back to ~207k).
2. **`888da0e1`** ("image generation pipeline with ImageValue boundary") — a
task-runner refactor that **doubled `packages/ai` isolated check time
(0.9s → 3.4s)**. _Still in main._
- The `888da0e1` cost is **diffuse** in the core task/`Workflow` generics, not in
any single construct. Four candidate fixes were prototyped and **measured to be
ineffective or net-negative** (see below). There is no surgical fix.
- A **CI guard** (`scripts/typecheck-budget.ts`) now gates per-package
instantiation counts so future spikes fail at PR time.

## Method

To compare fairly across time, the **compiler was held constant** (a pinned
standalone `tsc`) while **each commit's own dependencies** were installed, so the
only variables were source + `tsconfig`. Two metrics were used:

- **End-to-end wall clock** of a full build — what a developer feels.
- **Isolated per-package check** — warm-build all packages to emit dependency
`.d.ts`, then `tsc -p <pkg> --extendedDiagnostics` with the package's
`tsbuildinfo` removed to force a full re-check. **Instantiations** is the
headline number: it is deterministic and machine-independent, unlike wall time.

> Caveat: these measurements use `tsc`. The repo's `build-types` runs **`tsgo`**
> per package, which is much faster and masks code regressions in day-to-day
> builds. `tsc` cost still matters for editor / `tsserver` responsiveness, and
> the relative trends hold regardless of compiler.

## Timeline

| Date | Full-graph wall | `packages/test` | `packages/ai` | `packages/tasks` | # packages |
| --- | --- | --- | --- | --- | --- |
| Feb 12 | ~11s | 4.4s | 0.6s | 0.9s | 11 |
| Mar 13 | ~24s | **15.0s** | 0.8s | 1.4s _(6.7M inst)_ | 12 |
| Apr 1 | ~22s | 6.3s | 0.8s | 1.5s | 10 |
| Apr 15 | ~30s | 7.9s | 0.9s | 2.5s | 10 |
| May 1 | ~27s | 8.6s | **3.4s** | 3.1s | 10 |
| May 15 | ~36s | 11.1s | 3.3s | 1.9s | 28 |
| May 28 | ~42s | 11.3s | 3.4s | 1.9s | 34 |
| Jun 7 (main) | ~40s | 12.4s | 3.4s | 2.0s | 34 |

Notes:
- The **Mar 13 spike** in `test`/`tasks` is `16a94b54` (fixed by Apr 1).
- The **`ai` step from 0.9s → 3.4s** between Apr 15 and May 1 is `888da0e1`, and
it persists to today.
- The package-count jump (10 → 28 → 34) in mid-May is the **provider split**
(`packages/ai-provider` broken into individual `providers/*`). This is
legitimate structural growth, not a bug, but it is the largest single
contributor to the recent wall-clock increase.

## Culprit 1 — `16a94b54` (MCP `allOf`), already fixed

The change spread `mcpServerConfigSchema.properties` and `allOf:
mcpServerConfigSchema.allOf` into four MCP task schemas, each declared
`as const satisfies DataPortSchema`. That forced four deep instantiations of a
heavily reworked `McpAuthTypes` union, taking `packages/tasks` from ~289k to
**6.77M** instantiations. It was resolved between Mar 13 and Apr 1.

**Lesson:** spreading large composed schemas (`allOf` / `…properties`) into
multiple `as const satisfies` sites multiplies instantiation cost. The CI guard
below would have caught this at PR time.

## Culprit 2 — `888da0e1` (durable `ai` regression)

`888da0e1` is a large task-runner refactor (`ITask.ts`, `Task.ts`,
`TaskRunner.ts` +169, `TaskGraphRunner.ts` +132, plus the ImageValue work). Its
edits to `ToolCallingTask.ts` itself were trivial (an import reorder and a
category-string rename), so the regression is **not** in AI task code — it is in
the **base generics every task instantiates**.

A `--generateTrace` of `packages/ai` attributes the time to
`ToolCallingTask.ts` (~1.3–2.6s) and `registerAiTasks.ts` (~1.0s), surfacing as
`structuredTypeRelatedTo` / `getVariancesWorker` over `Workflow<I,O>` and
`CreateWorkflow<I,O,C>`. But that attribution is **misleading**: variance is
computed once and cached, so the cost lands on whichever expression touches
`Workflow` first. Aggregated by event name, `ai`'s ~3.4s is dominated by deeply
nested `structuredTypeRelatedTo` (the structural comparison of large `Workflow`
instantiations), with `getVariancesWorker` nested inside it.

### Why it is hard to fix

Each `Workflow.prototype.<name> = CreateWorkflow(<Task>)` assignment relates two
**distinct** `Workflow<I,O>` instantiations (the inferred `I` from the task class
is a fresh type-parameter, different from the `I` written in the `declare module`
augmentation), so there is no reference-identity short-circuit and the full
structural relation runs over the heavily augmented `Workflow` interface (one
builder method per registered task — ~124 across the repo). The cost scales
super-linearly with the number of augmentations, which `888da0e1` increased by
adding `imageGenerate` / `imageEdit`.

### Fixes prototyped and measured (all rejected)

Measured on the real metric (dependencies pre-built, isolated `tsc -p ai`,
0 errors), baseline `ai` = 3.4s:

| Attempt | Result |
| --- | --- |
| Replace `FromSchema<…>` input type in `ToolCallingTask` with a hand-written interface | 3.4s → 3.4s (no effect) |
| Replace `WithImageValuePorts<…>` image-task types with explicit interfaces | image/vision tasks are already ~13ms each — no meaningful effect |
| Restore the `tsBuildInfoFile` line `888da0e1` deleted from root `tsconfig.json` | 3.38s vs 3.58s — **no real effect**; it only changes `tsc -b` orchestration and actually breaks solution builds (`TS6377` buildinfo collision). The "fast" number some measurements showed was an artifact of the build bailing out and leaving deps unbuilt. |
| Declare correct variance `Workflow<in out Input, out Output>` (+ narrow the 5 bare-`Workflow` `return this` methods) | task-graph compiles clean and emits the annotated `.d.ts`, but `ai` is unchanged (3.4s → 3.58s). Declaring variance removes `getVariancesWorker` but the **structural relation still runs**, and variance is also computed for `TaskConfig` / `CreateWorkflow` / `DataPorts` which one annotation does not touch. |

### Remaining options for the durable `ai` cost

There is no surgical fix. Real remediation is either:

- **Architectural** — rethink how `Workflow` is augmented (124 `CreateWorkflow<…>`
member instantiations in a single mega-interface) and/or how AI task input
types are derived, so the prototype assignments don't each relate a large
`Workflow<I,O>`. This is a multi-day design effort and must be validated under
**both** `tsc` and `tsgo` before investing.
- **Operational** — confirm whether the user-felt slowness is `tsserver`
(still `tsc`) vs `build-types` (already `tsgo`); the latter largely sidesteps
this. The biggest absolute cost, `packages/test` (~12s), is mostly legitimate
file-count growth exercising the same machinery ×N and is not addressed by any
of the above.

## Guardrail added: `scripts/typecheck-budget.ts`

A CI guard that type-checks each composite package in isolation and compares its
**instantiation count** against committed per-package budgets
(`scripts/typecheck-budget.json`, default tolerance +15%, packages under 50k
instantiations are not gated). Instantiations are deterministic and
machine-independent, so the gate is stable across runners while catching exactly
the class of regression documented here.

```sh
bun run typecheck:budget # check; exit 1 on regression
bun run typecheck:budget --update # re-baseline intentional growth
bun run typecheck:budget --json # emit raw measurements
```

Wired into `.github/workflows/test.yml` as the `typecheck-budget` job. Both
culprit commits above would have failed it (`tasks` +23×, `ai` ~2×).
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"build:examples": "turbo run build-example --concurrency=15",
"build:js": "turbo run build-js --concurrency=15",
"build:types": "turbo run build-types --concurrency=15",
"typecheck:budget": "bun scripts/typecheck-budget.ts",
"clean": "rm -rf node_modules packages/*/node_modules packages/*/*tsbuildinfo packages/*/dist packages/*/src/**/*\\.d\\.ts packages/*/src/**/*\\.map integrations/*/node_modules integrations/*/dist integrations/*/src/**/*\\.d\\.ts integrations/*/src/**/*\\.map examples/*/node_modules examples/*/dist examples/*/src/**/*\\.d\\.ts examples/*/src/**/*\\.map .turbo */*/.turbo",
"dev": "turbo run dev --concurrency=15",
"docs": "typedoc",
Expand Down
33 changes: 30 additions & 3 deletions packages/ai/src/model/ModelSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { DataPortSchemaObject, FromSchema } from "@workglow/util/worker";
import { DataPortSchemaObject } from "@workglow/util/worker";

/**
* A model configuration suitable for task/job inputs.
Expand Down Expand Up @@ -65,6 +65,33 @@ export const ModelRecordSchema = {
additionalProperties: false,
} as const satisfies DataPortSchemaObject;

export type ModelConfig = FromSchema<typeof ModelConfigSchema>;
export type ModelRecord = FromSchema<typeof ModelRecordSchema>;
export type ModelConfig = {
[x: string]: unknown;
title?: string | undefined;
description?: string | undefined;
model_id?: string | undefined;
capabilities?: string[] | undefined;
metadata?: { [x: string]: unknown } | undefined;
provider: string;
provider_config: {
[x: string]: unknown;
credential_key?: string | undefined;
native_dimensions?: number | undefined;
mrl?: boolean | undefined;
};
};
export type ModelRecord = {
title: string;
description: string;
model_id: string;
capabilities: string[];
provider: string;
provider_config: {
[x: string]: unknown;
credential_key?: string | undefined;
native_dimensions?: number | undefined;
mrl?: boolean | undefined;
};
metadata: { [x: string]: unknown };
};
export const ModelPrimaryKeyNames = ["model_id"] as const;
36 changes: 31 additions & 5 deletions packages/ai/src/task/AiChatTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { CachePolicy, IExecuteContext, StreamEvent } from "@workglow/task-g
import { TaskConfigSchema } from "@workglow/task-graph";
import type { IHumanRequest } from "@workglow/util";
import { resolveHumanConnector } from "@workglow/util";
import type { DataPortSchema, FromSchema } from "@workglow/util/schema";
import type { DataPortSchema } from "@workglow/util/schema";
import type { AiEmit } from "../capability/AiEmit";
import type { Capability } from "../capability/Capabilities";
import type { AiJobInput } from "../job/AiJob";
Expand Down Expand Up @@ -150,11 +150,37 @@ export const AiChatOutputSchema = {
// Runtime types
// ========================================================================

export type AiChatTaskInput = Omit<FromSchema<typeof AiChatInputSchema>, "messages"> & {
readonly messages?: ReadonlyArray<ChatMessage>;
};
export type AiChatTaskInput = Omit<
{
systemPrompt?: string | undefined;
messages?: ChatMessage[] | undefined;
maxTokens?: number | undefined;
temperature?: number | undefined;
maxIterations?: number | undefined;
responseFormat?: "text" | "markdown" | undefined;
model: string | ModelConfig;
prompt:
| string
| (
| { type: "text"; text: string }
| { type: "image"; mimeType: string; data: string }
| { type: "tool_use"; id: string; name: string; input: { [x: string]: unknown } }
| {
is_error?: boolean | undefined;
type: "tool_result";
content: (
| { type: "text"; text: string }
| { type: "image"; mimeType: string; data: string }
| { type: "tool_use"; id: string; name: string; input: { [x: string]: unknown } }
)[];
tool_use_id: string;
}
)[];
},
"messages"
> & { readonly messages?: ReadonlyArray<ChatMessage> };

export type AiChatTaskOutput = FromSchema<typeof AiChatOutputSchema>;
export type AiChatTaskOutput = { text: string; messages: ChatMessage[]; iterations: number };

/** Provider-facing input: same structural type as AiChatTaskInput, named separately for intent. */
export type AiChatProviderInput = AiChatTaskInput;
Expand Down
Loading
Loading