diff --git a/.github/workflows/nightly-typecheck.yml b/.github/workflows/nightly-typecheck.yml new file mode 100644 index 000000000..55731eace --- /dev/null +++ b/.github/workflows/nightly-typecheck.yml @@ -0,0 +1,26 @@ +name: Nightly type-drift guard +permissions: + contents: read +on: + schedule: + - cron: "17 7 * * *" + workflow_dispatch: +env: + CI: "true" + DO_NOT_TRACK: "1" + TURBO_TELEMETRY_DISABLED: "1" +jobs: + type-drift: + 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 + # `.test-d.ts` files only run in vitest's typecheck mode. `--typecheck.only` + # disables the runtime test run so this stays cheap. + - run: bunx vitest run --typecheck --typecheck.only packages/ai/src/task/__tests__/types.test-d.ts diff --git a/packages/ai/src/task/AiChatTask.ts b/packages/ai/src/task/AiChatTask.ts index a11cd3403..89bec9cb7 100644 --- a/packages/ai/src/task/AiChatTask.ts +++ b/packages/ai/src/task/AiChatTask.ts @@ -150,6 +150,11 @@ export const AiChatOutputSchema = { // Runtime types // ======================================================================== +// The `prompt` field is intentionally written as the printed `FromSchema` +// resolution of `ContentBlockSchema` (PR #555 perf work). The nightly drift +// guard in `__tests__/types.test-d.ts` asserts equality with `FromSchema` +// so a schema edit (e.g. an added `audio` variant) trips a test instead of +// silently passing invalid runtime values across the type boundary. export type AiChatTaskInput = Omit< { systemPrompt?: string | undefined; diff --git a/packages/ai/src/task/ChunkRetrievalTask.ts b/packages/ai/src/task/ChunkRetrievalTask.ts index 09e872146..e89cdcda2 100644 --- a/packages/ai/src/task/ChunkRetrievalTask.ts +++ b/packages/ai/src/task/ChunkRetrievalTask.ts @@ -14,7 +14,7 @@ import type { ModelConfig } from "../model/ModelSchema"; import { TypeModel } from "./base/AiTaskSchemas"; import { TextEmbeddingTask } from "./TextEmbeddingTask"; -const inputSchema = { +export const ChunkRetrievalInputSchema = { type: "object", properties: { knowledgeBase: TypeKnowledgeBase({ @@ -174,6 +174,12 @@ const outputSchema = { additionalProperties: false, } as const satisfies DataPortSchema; +/** + * Intentionally tighter than `FromSchema`'s resolution: encodes the schema's + * `if/then/else` (when `query: string`, `model` is required) which + * `json-schema-to-ts` ignores. The nightly drift type-test pins this + * divergence by asserting one-way assignability rather than equality. + */ export type ChunkRetrievalTaskInput = | { filter?: { [x: string]: unknown } | undefined; @@ -228,7 +234,7 @@ export class ChunkRetrievalTask extends Task< public static override cacheable = true; public static override inputSchema(): DataPortSchema { - return inputSchema as DataPortSchema; + return ChunkRetrievalInputSchema as DataPortSchema; } public static override outputSchema(): DataPortSchema { diff --git a/packages/ai/src/task/ToolCallingTask.ts b/packages/ai/src/task/ToolCallingTask.ts index f9bde2ffa..3073323cc 100644 --- a/packages/ai/src/task/ToolCallingTask.ts +++ b/packages/ai/src/task/ToolCallingTask.ts @@ -233,6 +233,11 @@ export const ToolCallingOutputSchema = { additionalProperties: false, } as const satisfies DataPortSchema; +// The `prompt` field is intentionally written as the printed `FromSchema` +// resolution of the schema's array-item `oneOf` (PR #555 perf work). The +// nightly drift guard in `__tests__/types.test-d.ts` asserts equality with +// `FromSchema` so a schema edit (e.g. an added item variant) trips a test +// instead of silently passing invalid runtime values across the type boundary. /** * Runtime input type for ToolCallingTask. * diff --git a/packages/ai/src/task/__tests__/types.test-d.ts b/packages/ai/src/task/__tests__/types.test-d.ts new file mode 100644 index 000000000..23b8eba04 --- /dev/null +++ b/packages/ai/src/task/__tests__/types.test-d.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { FromSchema } from "@workglow/util/schema"; +import { describe, expectTypeOf, it } from "vitest"; +import { AiChatInputSchema, type AiChatTaskInput } from "../AiChatTask"; +import { ChunkRetrievalInputSchema, type ChunkRetrievalTaskInput } from "../ChunkRetrievalTask"; +import { ToolCallingInputSchema, type ToolCallingTaskInput } from "../ToolCallingTask"; + +/** + * Drift guard between the JSON schemas in `packages/ai/src/task/*.ts` and + * the hand-written runtime input types alongside them. + * + * - **AiChat / ToolCalling** — PR #555 deliberately inlined the resolved + * `FromSchema` literal in each task's runtime input type to avoid the + * `json-schema-to-ts` instantiation cost on every consumer that imports + * the type. The cost shows up only here, in a `.test-d.ts` file excluded + * from `packages/ai/tsconfig.json` so the per-PR `typecheck:budget` + * gate never sees it. The nightly workflow runs vitest's `--typecheck` + * engine over this file. The moment a schema edit (e.g. adding an + * `audio` variant to `ContentBlockSchema`) ships without a matching + * literal update, the equality assertion fails and the nightly turns + * red. + * + * - **ChunkRetrieval** — `FromSchema` ignores `if/then/else`, so the + * discriminated union the runtime type encodes is strictly tighter + * than the schema resolution. The non-equality assertion pins this + * intentional divergence; the moment they become equal, `FromSchema` + * has started honouring `if/then/else` (or the runtime type was + * loosened by hand) and the runtime type can switch to a + * `FromSchema`-derived form. + */ +describe("schema-vs-type drift guard", () => { + it("AiChat: prompt equals FromSchema resolution", () => { + expectTypeOf().toEqualTypeOf< + FromSchema["prompt"] + >(); + }); + + it("ToolCalling: prompt equals FromSchema resolution", () => { + expectTypeOf().toEqualTypeOf< + FromSchema["prompt"] + >(); + }); + + it("ChunkRetrieval: hand-written discriminated union is stricter than FromSchema", () => { + type Schema = FromSchema; + type Runtime = ChunkRetrievalTaskInput; + expectTypeOf().not.toEqualTypeOf(); + expectTypeOf().not.toEqualTypeOf(); + expectTypeOf().not.toEqualTypeOf(); + }); +}); diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json index c3917c449..d9a5d6145 100644 --- a/packages/ai/tsconfig.json +++ b/packages/ai/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.json", "include": ["src/worker.ts", "src/common.ts", "src/provider-utils.ts", "src/*/**/*"], "files": ["./src/worker.ts", "./src/browser.ts", "./src/node.ts", "./src/bun.ts", "./src/provider-utils.ts"], - "exclude": ["dist", "node_modules"], + "exclude": ["dist", "node_modules", "src/**/*.test-d.ts"], "compilerOptions": { "composite": true, "outDir": "./dist", diff --git a/packages/task-graph/README.md b/packages/task-graph/README.md index d0da2061d..7ea829ce9 100644 --- a/packages/task-graph/README.md +++ b/packages/task-graph/README.md @@ -757,6 +757,7 @@ Both slots are optional. A missing slot is a silent no-op — the task still run import { CACHE_REGISTRY, DefaultCacheRegistry, + tabularTaskOutputStorage, TaskOutputPrimaryKeyNames, TaskOutputSchema, TaskOutputTabularRepository, @@ -767,22 +768,26 @@ import { Sqlite, SqliteTabularStorage } from "@workglow/sqlite/storage"; await Sqlite.init(); const deterministic = new TaskOutputTabularRepository({ - tabularRepository: new SqliteTabularStorage( - "./cache.sqlite", - "task_outputs_deterministic", - TaskOutputSchema, - TaskOutputPrimaryKeyNames, - ["createdAt"] + storage: tabularTaskOutputStorage( + new SqliteTabularStorage( + "./cache.sqlite", + "task_outputs_deterministic", + TaskOutputSchema, + TaskOutputPrimaryKeyNames, + ["createdAt"] + ) ), }); const privateBacking = new TaskOutputTabularRepository({ - tabularRepository: new SqliteTabularStorage( - "./cache.sqlite", - "task_outputs_private", - TaskOutputSchema, - TaskOutputPrimaryKeyNames, - ["createdAt"] + storage: tabularTaskOutputStorage( + new SqliteTabularStorage( + "./cache.sqlite", + "task_outputs_private", + TaskOutputSchema, + TaskOutputPrimaryKeyNames, + ["createdAt"] + ) ), }); @@ -866,6 +871,7 @@ import { Workflow, CACHE_REGISTRY, DefaultCacheRegistry, + tabularTaskOutputStorage, TaskOutputPrimaryKeyNames, TaskOutputSchema, TaskOutputTabularRepository, @@ -912,9 +918,9 @@ class ExpensiveTask extends Task<{ n: number }, { result: number }> { // Build a CacheRegistry with a deterministic slot. (Private slot omitted here — // ExpensiveTask is deterministic, so it never needs the private tier.) const deterministic = new TaskOutputTabularRepository({ - tabularRepository: new InMemoryTabularStorage(TaskOutputSchema, TaskOutputPrimaryKeyNames, [ - "createdAt", - ]), + storage: tabularTaskOutputStorage( + new InMemoryTabularStorage(TaskOutputSchema, TaskOutputPrimaryKeyNames, ["createdAt"]) + ), }); const registry = new ServiceRegistry(); @@ -977,6 +983,7 @@ Wire `TaskOutputTabularRepository` / `TaskGraphTabularRepository` from `@workglo ```typescript import { + tabularTaskOutputStorage, TaskGraphPrimaryKeyNames, TaskGraphSchema, TaskGraphTabularRepository, @@ -993,9 +1000,9 @@ import { Sqlite, SqliteTabularStorage } from "@workglow/sqlite/storage"; // In-memory (e.g. tests) const memoryOutput = new TaskOutputTabularRepository({ - tabularRepository: new InMemoryTabularStorage(TaskOutputSchema, TaskOutputPrimaryKeyNames, [ - "createdAt", - ]), + storage: tabularTaskOutputStorage( + new InMemoryTabularStorage(TaskOutputSchema, TaskOutputPrimaryKeyNames, ["createdAt"]) + ), }); const memoryGraph = new TaskGraphTabularRepository({ tabularRepository: new InMemoryTabularStorage(TaskGraphSchema, TaskGraphPrimaryKeyNames), @@ -1003,10 +1010,12 @@ const memoryGraph = new TaskGraphTabularRepository({ // File system const fsOutput = new TaskOutputTabularRepository({ - tabularRepository: new FsFolderTabularStorage( - "./task-output-cache", - TaskOutputSchema, - TaskOutputPrimaryKeyNames + storage: tabularTaskOutputStorage( + new FsFolderTabularStorage( + "./task-output-cache", + TaskOutputSchema, + TaskOutputPrimaryKeyNames + ) ), }); const fsGraph = new TaskGraphTabularRepository({ @@ -1020,12 +1029,14 @@ const fsGraph = new TaskGraphTabularRepository({ // SQLite (await Sqlite.init() once before using a path or new Sqlite.Database) await Sqlite.init(); const sqliteOutput = new TaskOutputTabularRepository({ - tabularRepository: new SqliteTabularStorage( - ":memory:", - "task_outputs", - TaskOutputSchema, - TaskOutputPrimaryKeyNames, - ["createdAt"] + storage: tabularTaskOutputStorage( + new SqliteTabularStorage( + ":memory:", + "task_outputs", + TaskOutputSchema, + TaskOutputPrimaryKeyNames, + ["createdAt"] + ) ), }); const sqliteGraph = new TaskGraphTabularRepository({ @@ -1039,11 +1050,13 @@ const sqliteGraph = new TaskGraphTabularRepository({ // IndexedDB (browser) — the `@workglow/web` example under `examples/web` includes small helpers const idbOutput = new TaskOutputTabularRepository({ - tabularRepository: new IndexedDbTabularStorage( - "task_outputs", - TaskOutputSchema, - TaskOutputPrimaryKeyNames, - ["createdAt"] + storage: tabularTaskOutputStorage( + new IndexedDbTabularStorage( + "task_outputs", + TaskOutputSchema, + TaskOutputPrimaryKeyNames, + ["createdAt"] + ) ), }); const idbGraph = new TaskGraphTabularRepository({ diff --git a/packages/task-graph/src/storage/README.md b/packages/task-graph/src/storage/README.md index 266b3438f..6498ebf69 100644 --- a/packages/task-graph/src/storage/README.md +++ b/packages/task-graph/src/storage/README.md @@ -15,6 +15,7 @@ TaskOutputRepository is a repository for task caching. If a task has the same in ```typescript // Example usage import { + tabularTaskOutputStorage, TaskOutputPrimaryKeyNames, TaskOutputSchema, TaskOutputTabularRepository, @@ -25,12 +26,14 @@ import { Sqlite } from "@workglow/storage/sqlite"; await Sqlite.init(); const outputRepo = new TaskOutputTabularRepository({ - tabularRepository: new SqliteTabularStorage( - ":memory:", - "task_outputs", - TaskOutputSchema, - TaskOutputPrimaryKeyNames, - ["createdAt"] + storage: tabularTaskOutputStorage( + new SqliteTabularStorage( + ":memory:", + "task_outputs", + TaskOutputSchema, + TaskOutputPrimaryKeyNames, + ["createdAt"] + ) ), }); await outputRepo.saveOutput("MyTaskType", { param: "value" }, { result: "data" });