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
6 changes: 2 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@
"ws": "^8.18.3",
"xxhash-wasm": "^1.1.0",
"yaml": "^2.8.2",
"zod": "^4.2.0",
"zod": "^4.6.5",
Comment thread
ThomasK33 marked this conversation as resolved.
Comment thread
ThomasK33 marked this conversation as resolved.
Comment thread
ThomasK33 marked this conversation as resolved.
"zod-to-json-schema": "^3.24.6"
},
"devDependencies": {
Expand Down
12 changes: 12 additions & 0 deletions src/common/orpc/schemas/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,18 @@ describe("workflow domain schemas", () => {
expect(record.evaluation).toEqual(admission);
expect(record.taskId).toBeUndefined();

// A minute-precision deadline (zod 4.4 accepted it) stays readable in persisted records.
const minuteDeadline = { ...admission, attemptDeadlineAt: "2026-05-29T00:01Z" };
expect(
WorkflowStepRecordSchema.safeParse({
stepId: "screen-issue",
inputHash: "sha256:screen-issue",
status: "started",
startedAt: "2026-05-29T00:00Z",
evaluation: minuteDeadline,
}).success
).toBe(true);

// Agent/patch records never carry the field and must keep parsing.
const agentRecord = WorkflowStepRecordSchema.parse({
stepId: "reserve-child",
Expand Down
10 changes: 9 additions & 1 deletion src/common/orpc/schemas/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,15 @@ export const WorkflowRunStatusSchema = z.enum([
"failed",
]);

const IsoDateTimeSchema = z.string().datetime({ offset: true });
// zod >= 4.5 rejects minute-precision datetimes with a zone ("2026-05-29T00:01Z"), which zod
// 4.4 accepted. Writers use toISOString (always with seconds), but persisted run.json and
// journal records from before or from other writers must stay readable: an unreadable run.json
// makes createRunIfAbsent treat the run as half-created and delete it. This is zod's
// documented union for accepting both precisions.
const IsoDateTimeSchema = z.union([
z.iso.datetime({ offset: true }),
z.iso.datetime({ offset: true, precision: -1 }),
]);
export const JsonValueSchema: z.ZodType<unknown> = z.lazy(() =>
z.union([
z.string(),
Expand Down
7 changes: 6 additions & 1 deletion src/common/types/evaluation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,12 @@ export const EvaluationAdmissionSchema = z.object({
configFingerprint: z.string(),
}),
timeoutMs: z.number().int().positive(),
attemptDeadlineAt: z.string().datetime({ offset: true }),
// Persisted in workflow step records, so it accepts the same minute-precision form as the
// other workflow timestamps (IsoDateTimeSchema in orpc/schemas/workflow.ts).
attemptDeadlineAt: z.union([
z.iso.datetime({ offset: true }),
z.iso.datetime({ offset: true, precision: -1 }),
]),
providerOptions: EvaluationProviderOptionsSchema.optional(),
stateSha256: z.string(),
stateBytes: z.number().int().nonnegative(),
Expand Down
36 changes: 36 additions & 0 deletions src/node/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2625,6 +2625,42 @@ describe("Config", () => {
review: { defaultBaseByProject: { "/repo/a": "main" } },
});
});

// zod >= 4.5 strips `__proto__` keys in object and record parsers. JSON.parse makes
// such a key an own property, so a hand-edited config.json can carry one. Lenient
// loading must still drop only the bad entries and must not adopt the key as a
// prototype (no inherited project defaults, no polluted Object.prototype).
it("drops __proto__ keys and invalid entries from persisted preference records", () => {
fs.writeFileSync(
path.join(tempDir, "config.json"),
`{
"projects": [],
"userPreferences": {
"__proto__": { "polluted": true },
"ai": {
"projectDefaults": {
"__proto__": { "agentId": "plan" },
"/repo": { "agentId": "exec" }
},
"autoCompactionThresholdByModel": { "__proto__": 70, "openai:gpt-4.1": 75, "bad": 101 }
}
}
}`
);

const preferences = config.loadConfigOrDefault().userPreferences;

expect(preferences).toEqual({
ai: {
projectDefaults: { "/repo": { agentId: "exec" } },
autoCompactionThresholdByModel: { "openai:gpt-4.1": 75 },
},
});
const projectDefaults = preferences?.ai?.projectDefaults;
expect(projectDefaults && Object.getPrototypeOf(projectDefaults)).toBe(Object.prototype);
expect(Object.keys(projectDefaults ?? {})).toEqual(["/repo"]);
expect(Object.prototype).not.toHaveProperty("polluted");
});
});

it.each([
Expand Down
37 changes: 36 additions & 1 deletion src/node/services/historyMessageEvidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { isReadableHistoryMessage } from "./historyScanner";
import { normalizeLegacyMuxMetadata } from "@/node/utils/messages/legacy";
import { scanHistoryRows } from "./historyRowScanner.testHarness";
import { createHistoryMessageEvidence } from "./historyMessageEvidence";
import { WorkflowScriptDescriptorSchema } from "@/common/orpc/schemas/workflow";

const DESCRIPTION_MAX = WorkflowScriptDescriptorSchema.shape.description.maxLength!;

const date = "2026-09-11T12:00:00Z";
const run = () => ({
Expand Down Expand Up @@ -261,14 +264,46 @@ describe("streamed history message evidence", () => {
expect(result.id?.matchesExpected).toBe(true);
expect(result.matchesNonce).toBe(true);
expect(result.id?.length).toBe(huge.length);
expect(result.id!.prefix.length).toBeLessThan(2048);
// The prefix stays bounded: one code point past the largest bound, in UTF-16 units.
expect(result.id!.prefix.length).toBeLessThanOrEqual(2 * (DESCRIPTION_MAX + 1));
await differential([
JSON.stringify(
message([tool({ ...run(), workflow: { ...run().workflow, description: huge } })])
),
]);
});

// zod >= 4.5 counts string bounds in code points, so an astral character counts once even
// though it takes two UTF-16 units. The streamed evidence must agree with the real parse at
// the bound for every mix of one-unit and two-unit characters.
it("matches the real parse at the description bound in code points", async () => {
const emoji = "\u{1F600}";
const withDescription = (description: string) =>
JSON.stringify(message([tool({ ...run(), workflow: { ...run().workflow, description } })]));
const descriptions = [
"x".repeat(DESCRIPTION_MAX),
"x".repeat(DESCRIPTION_MAX + 1),
emoji.repeat(DESCRIPTION_MAX),
emoji.repeat(DESCRIPTION_MAX + 1),
"x" + emoji.repeat(DESCRIPTION_MAX - 1),
"x" + emoji.repeat(DESCRIPTION_MAX),
emoji.repeat(DESCRIPTION_MAX - 2) + "xy",
emoji.repeat(DESCRIPTION_MAX - 1) + "xy",
];
const results = await inspect(descriptions.map(withDescription));
expect(results.map((result) => result.readable)).toEqual([
true,
false,
true,
false,
true,
false,
true,
false,
]);
await differential(descriptions.map(withDescription));
});

it("matches schema field mutations without retaining whole workflow records", async () => {
const rich = {
...run(),
Expand Down
9 changes: 6 additions & 3 deletions src/node/services/historyMessageEvidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import type { HistoryRowDescriptor, HistoryRowToken } from "./historyRowScanner"
import { createHistoryStringEvidence, createHistoryNumberEvidence } from "./historyScalarEvidence";

const INVALID = Symbol("invalid history projection");
const STRING_PREFIX = WorkflowScriptDescriptorSchema.shape.description.maxLength! + 1;
// zod >= 4.5 bounds string length in code points. A code point takes at most two UTF-16
// units, so this many units always hold one code point past the largest bound.
const STRING_PREFIX = 2 * (WorkflowScriptDescriptorSchema.shape.description.maxLength! + 1);
type StringFacts = ReturnType<ReturnType<typeof createHistoryStringEvidence>["finish"]>;
interface Value {
value: unknown;
Expand Down Expand Up @@ -187,8 +189,9 @@ export function createHistoryMessageEvidence(
const key = frame.key!.prefix;
if (shape && Object.hasOwn(shape, key) && key.length === frame.key!.length)
frame.fields.set(key, item);
else if (strict.has(frame.context) && key !== "__proto__")
frame.fields.set("", { value: null });
// zod >= 4.5 `.strict()` reports an own `__proto__` key (JSON.parse makes one) as
// unrecognized, so it fails the row like any other extra key.
else if (strict.has(frame.context)) frame.fields.set("", { value: null });
}
};
return {
Expand Down
6 changes: 4 additions & 2 deletions src/node/services/ptc/typeGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,10 @@ async function getResultTypeString(toolName: string): Promise<string | null> {
const schema = getToolResultSchema(toolName);
if (!schema) return null;

// Convert Zod → JSON Schema → TypeScript
const jsonSchema = z.toJSONSchema(schema);
// Convert Zod → JSON Schema → TypeScript. json-schema-to-typescript understands
// draft-7 tuples (`items: [...]`) but not draft-2020-12 `prefixItems`, which
// zod >= 4.5 emits by default; without draft-7, tuple results type as `never[]`.
const jsonSchema = z.toJSONSchema(schema, { target: "draft-7" });
const tsOutput = await compile(
jsonSchema as Parameters<typeof compile>[0],
`${pascalCase(toolName)}Result`,
Expand Down
33 changes: 33 additions & 0 deletions src/node/services/ptc/typeValidator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Tool } from "ai";
import { DisposableTempDir } from "@/node/services/tempDir";
import { findBundledTypeScriptLibDir, validateTypes } from "./typeValidator";
import { generateXumTypes } from "./typeGenerator";
import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions";

/**
* Create a mock tool with the given schema.
Expand Down Expand Up @@ -990,3 +991,35 @@ mux.file_read({ path: "wrong" });`,
expect(result.valid).toBe(true);
});
});

// zod >= 4.5 emits closed tuples as `prefixItems` + `items: false` in its default
// draft-2020-12 output. json-schema-to-typescript reads only draft-7 tuples, so the
// attach_file result `value` (a tuple union) became `never[]` and valid guest code
// that inspects the parts failed to typecheck.
describe("attach_file result types (real tool definition)", () => {
test("guest code can read the parts of a content result", async () => {
const attachFile = TOOL_DEFINITIONS.attach_file;
const types = await generateXumTypes({
attach_file: {
description: attachFile.description,
inputSchema: attachFile.schema,
execute: () => Promise.resolve({ success: true }),
} as unknown as Tool,
});
const result = validateTypes(
`
const r = xum.attach_file({ path: "/tmp/a.png" });
const texts = [];
if ("value" in r && r.type === "content") {
for (const part of r.value) {
if (part.type === "text") texts.push(part.text);
}
}
return texts;
`,
types
);
expect(result.errors.map((e) => e.message)).toEqual([]);
expect(result.valid).toBe(true);
});
});
80 changes: 80 additions & 0 deletions src/node/services/workflows/WorkflowRunStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,86 @@ describe("WorkflowRunStore", () => {
expect(completed?.result?.structuredOutput).toEqual({ ok: true });
});

// zod >= 4.5 rejects minute-precision datetimes with a zone ("...T00:01Z") unless the schema
// allows them, and zod 4.4 accepted them. Xum writes toISOString (always with seconds), but a
// persisted record from a hand edit or another writer must stay readable: an unreadable
// run.json also makes createRunIfAbsent treat the run as half-created and delete it.
test("keeps minute-precision journal lines readable", async () => {
using tmp = new DisposableTempDir("workflow-runs-minute-precision");
const store = await createStore(tmp.path);
const runDir = path.join(tmp.path, "workflows", "wfr_123");

await store.appendEvent("wfr_123", {
sequence: 1,
type: "phase",
at: new Date("2026-05-29T00:00:01Z").toISOString(),
name: "scope",
});
await fs.appendFile(
path.join(runDir, "events.jsonl"),
[
{ sequence: 2, type: "log", at: "2026-05-29T00:01Z", message: "minute precision" },
{ sequence: 3, type: "log", at: "2026-05-29T00:02+02:00", message: "offset" },
]
.map((event) => `${JSON.stringify(event)}\n`)
.join("")
);
await fs.appendFile(
path.join(runDir, "steps.jsonl"),
`${JSON.stringify({
stepId: "minute-step",
inputHash: "input:2",
status: "started",
startedAt: "2026-05-29T00:01Z",
})}\n`
);

const run = await store.getRun("wfr_123");

expect(run.events.map((event) => event.sequence)).toEqual([1, 2, 3]);
expect(run.steps.map((step) => step.startedAt)).toEqual(["2026-05-29T00:01Z"]);
expect(run.updatedAt).toBe("2026-05-29T00:02+02:00");
});

test("keeps a run.json with minute-precision timestamps readable and in place", async () => {
using tmp = new DisposableTempDir("workflow-runs-minute-precision-run");
const store = new WorkflowRunStore({ sessionDir: tmp.path });
const input = {
id: "wfr_child_minute",
workspaceId: "workspace-1",
workflow: definition,
source: source,
args: { topic: "nested" },
parentWorkflow: { runId: "wfr_parent", stepId: "child", inputHash: "hash:child", depth: 0 },
now: "2026-05-29T00:00:00.000Z",
};
await store.createRunIfAbsent(input);
await store.appendEvent("wfr_child_minute", {
sequence: 1,
type: "log",
at: "2026-05-29T00:00:01.000Z",
message: "kept",
});
const runFile = path.join(tmp.path, "workflows", "wfr_child_minute", "run.json");
const raw = JSON.parse(await fs.readFile(runFile, "utf-8")) as Record<string, unknown>;
await fs.writeFile(
runFile,
JSON.stringify({ ...raw, createdAt: "2026-05-29T00:00Z", updatedAt: "2026-05-29T00:01Z" })
);

await expect(store.getRun("wfr_child_minute")).resolves.toMatchObject({
createdAt: "2026-05-29T00:00Z",
});
await expect(store.getRunStatusSnapshot("wfr_child_minute")).resolves.toMatchObject({
id: "wfr_child_minute",
});
const again = await store.createRunIfAbsent(input);
expect(again.events.map((event) => event.type === "log" && event.message)).toEqual(["kept"]);
await expect(
fs.readFile(path.join(tmp.path, "workflows", "wfr_child_minute", "events.jsonl"), "utf-8")
).resolves.toContain('"kept"');
});

test("rejects duplicate or out-of-order event sequence numbers", async () => {
using tmp = new DisposableTempDir("workflow-runs");
const store = await createStore(tmp.path);
Expand Down
Loading