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
26 changes: 26 additions & 0 deletions .github/workflows/nightly-typecheck.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions packages/ai/src/task/AiChatTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions packages/ai/src/task/ChunkRetrievalTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions packages/ai/src/task/ToolCallingTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
56 changes: 56 additions & 0 deletions packages/ai/src/task/__tests__/types.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* 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<AiChatTaskInput["prompt"]>().toEqualTypeOf<
FromSchema<typeof AiChatInputSchema>["prompt"]
>();
});

it("ToolCalling: prompt equals FromSchema resolution", () => {
expectTypeOf<ToolCallingTaskInput["prompt"]>().toEqualTypeOf<
FromSchema<typeof ToolCallingInputSchema>["prompt"]
>();
});

it("ChunkRetrieval: hand-written discriminated union is stricter than FromSchema", () => {
type Schema = FromSchema<typeof ChunkRetrievalInputSchema>;
type Runtime = ChunkRetrievalTaskInput;
expectTypeOf<Runtime>().not.toEqualTypeOf<Schema>();
expectTypeOf<Schema>().not.toEqualTypeOf<never>();
expectTypeOf<Runtime>().not.toEqualTypeOf<never>();
});
});
2 changes: 1 addition & 1 deletion packages/ai/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
79 changes: 46 additions & 33 deletions packages/task-graph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"]
)
),
});

Expand Down Expand Up @@ -866,6 +871,7 @@ import {
Workflow,
CACHE_REGISTRY,
DefaultCacheRegistry,
tabularTaskOutputStorage,
TaskOutputPrimaryKeyNames,
TaskOutputSchema,
TaskOutputTabularRepository,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -977,6 +983,7 @@ Wire `TaskOutputTabularRepository` / `TaskGraphTabularRepository` from `@workglo

```typescript
import {
tabularTaskOutputStorage,
TaskGraphPrimaryKeyNames,
TaskGraphSchema,
TaskGraphTabularRepository,
Expand All @@ -993,20 +1000,22 @@ 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),
});

// 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({
Expand All @@ -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({
Expand All @@ -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({
Expand Down
15 changes: 9 additions & 6 deletions packages/task-graph/src/storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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" });
Expand Down
Loading