Skip to content

Commit 0435452

Browse files
committed
Merge: ban as-casts + assertUnreachable + postJson + agent sequential-chain eval
2 parents 55a1a7d + d1f979a commit 0435452

21 files changed

Lines changed: 321 additions & 157 deletions

components/onboarding.tsx

Lines changed: 55 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { useEffect, useRef, useState } from "react";
4+
import { z } from "zod";
45

56
import { Badge } from "@/components/ui/badge";
67
import {
@@ -14,7 +15,8 @@ import { Card, CardHeader, CardTitle, Eyebrow } from "@/components/ui/card";
1415
import { WorkflowEditor } from "@/components/workflow-editor";
1516
import { WorkflowGraph } from "@/components/workflow-graph";
1617
import { API_ROUTES } from "@/lib/api-routes";
17-
import type { ApprovalWorkflow } from "@/lib/approval-workflow";
18+
import { ApprovalWorkflow } from "@/lib/approval-workflow";
19+
import { postJson, FetchJsonError } from "@/lib/fetch-json";
1820

1921
/**
2022
* The onboarding discovery screen — the forward-deployed-engineer step.
@@ -30,34 +32,46 @@ import type { ApprovalWorkflow } from "@/lib/approval-workflow";
3032
* issues before it goes live. (Conversational edits are the next layer.)
3133
*/
3234

33-
type RoleResolution = {
34-
role: string;
35-
title: string;
36-
employeeName: string | null;
37-
rationale: string;
38-
};
39-
type OrgEmployee = {
40-
id: string;
41-
name: string;
42-
title: string;
43-
department: string;
44-
division: string;
45-
managerId: string | null;
46-
};
47-
type OnboardingResponse = {
48-
source: string;
49-
employeeCount: number;
50-
employees: OrgEmployee[];
51-
workflow: ApprovalWorkflow;
52-
proposal: {
53-
directorThreshold: number;
54-
roles: RoleResolution[];
55-
summary: string;
56-
};
57-
issues: { employeeName: string; detail: string; note: string }[];
35+
// The /api/onboarding response, as a Zod schema so we VALIDATE it at the fetch
36+
// boundary (no `res.json() as T`). Types are derived from the schemas.
37+
const OrgEmployee = z.object({
38+
id: z.string(),
39+
name: z.string(),
40+
title: z.string(),
41+
department: z.string(),
42+
division: z.string(),
43+
managerId: z.string().nullable(),
44+
});
45+
type OrgEmployee = z.infer<typeof OrgEmployee>;
46+
47+
const RoleResolution = z.object({
48+
role: z.string(),
49+
title: z.string(),
50+
employeeName: z.string().nullable(),
51+
rationale: z.string(),
52+
});
53+
54+
const OnboardingResponse = z.object({
55+
source: z.string(),
56+
employeeCount: z.number(),
57+
employees: z.array(OrgEmployee),
58+
workflow: ApprovalWorkflow,
59+
proposal: z.object({
60+
directorThreshold: z.number(),
61+
roles: z.array(RoleResolution),
62+
summary: z.string(),
63+
}),
64+
issues: z.array(
65+
z.object({
66+
employeeName: z.string(),
67+
detail: z.string(),
68+
note: z.string(),
69+
}),
70+
),
5871
/** Up to three AI-generated next-edit suggestions for the derived workflow. */
59-
suggestions: string[];
60-
};
72+
suggestions: z.array(z.string()),
73+
});
74+
type OnboardingResponse = z.infer<typeof OnboardingResponse>;
6175

6276
type State =
6377
| { status: "idle" }
@@ -71,19 +85,14 @@ export const Onboarding = () => {
7185
const discover = async () => {
7286
setState({ status: "running" });
7387
try {
74-
const res = await fetch(API_ROUTES.onboarding, { method: "POST" });
75-
if (!res.ok) {
76-
const msg = await res
77-
.json()
78-
.then((j: { error?: string }) => j.error)
79-
.catch(() => null);
80-
setState({ status: "error", message: msg ?? "Discovery failed." });
81-
return;
82-
}
83-
const data = (await res.json()) as OnboardingResponse;
88+
const data = await postJson(API_ROUTES.onboarding, OnboardingResponse);
8489
setState({ status: "done", data });
85-
} catch {
86-
setState({ status: "error", message: "Could not reach the server." });
90+
} catch (err) {
91+
setState({
92+
status: "error",
93+
message:
94+
err instanceof FetchJsonError ? err.message : "Discovery failed.",
95+
});
8796
}
8897
};
8998

@@ -213,7 +222,12 @@ const WhatCanIChange = () => {
213222
useEffect(() => {
214223
if (!open) return;
215224
const onDoc = (e: MouseEvent) => {
216-
if (ref.current && !ref.current.contains(e.target as globalThis.Node))
225+
const target = e.target;
226+
if (
227+
target instanceof Node &&
228+
ref.current &&
229+
!ref.current.contains(target)
230+
)
217231
setOpen(false);
218232
};
219233
document.addEventListener("mousedown", onDoc);

components/trace-timeline.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"use client";
22

3+
import { z } from "zod";
4+
35
import { TraceDetail } from "@/components/trace-detail";
46
import { Badge } from "@/components/ui/badge";
57
import { Button } from "@/components/ui/button";
@@ -163,14 +165,23 @@ const TraceNode = ({
163165
);
164166
};
165167

168+
/** The few summary fields a stage may carry on `data`, for the chip. Validated with
169+
Zod (data is `unknown` on the trace) so we read TYPED fields, not `d["verdict"]`. */
170+
const ChipFields = z.object({
171+
verdict: z.string().optional(),
172+
tier: z.string().optional(),
173+
outcome: z.string().optional(), // posted / awaiting / rejected / blocked
174+
posted: z.boolean().optional(),
175+
});
176+
166177
/** Short chip text summarizing a step's outcome (verdict / tier / posted). */
167178
const verdictChip = (event: TraceEvent): string => {
168-
const d = event.data as Record<string, unknown> | undefined;
179+
const d = ChipFields.safeParse(event.data).data;
169180
if (d) {
170-
if (typeof d["verdict"] === "string") return humanize(d["verdict"]);
171-
if (typeof d["tier"] === "string") return humanize(d["tier"]);
172-
if (typeof d["outcome"] === "string") return humanize(d["outcome"]); // posted/awaiting/rejected/blocked
173-
if ("posted" in d) return d["posted"] ? "Posted" : "Not posted";
181+
if (d.verdict) return humanize(d.verdict);
182+
if (d.tier) return humanize(d.tier);
183+
if (d.outcome) return humanize(d.outcome);
184+
if (d.posted !== undefined) return d.posted ? "Posted" : "Not posted";
174185
}
175186
if (event.status === "running") return "Running";
176187
return stageLabel(event.stage);

components/workflow-editor.tsx

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"use client";
22

33
import { useMemo, useState } from "react";
4+
import { z } from "zod";
45

56
import { Button } from "@/components/ui/button";
67
import { WorkflowGraph } from "@/components/workflow-graph";
78
import { API_ROUTES } from "@/lib/api-routes";
8-
import type { ApprovalWorkflow, StepChange } from "@/lib/approval-workflow";
9+
import { ApprovalWorkflow, type StepChange } from "@/lib/approval-workflow";
10+
import { postJson, FetchJsonError } from "@/lib/fetch-json";
911
import {
1012
validateWorkflow,
1113
isActivatable,
@@ -32,6 +34,26 @@ type Proposal = {
3234
changes: StepChange[];
3335
};
3436

37+
// The /api/workflow/edit response, as a Zod schema so we validate it at the fetch
38+
// boundary instead of asserting `res.json() as …`. The `changes` union mirrors
39+
// `StepChange` exactly (only "changed" carries `fields`).
40+
const StepChangeSchema = z.discriminatedUnion("kind", [
41+
z.object({ kind: z.literal("added"), id: z.string(), label: z.string() }),
42+
z.object({ kind: z.literal("removed"), id: z.string(), label: z.string() }),
43+
z.object({
44+
kind: z.literal("changed"),
45+
id: z.string(),
46+
label: z.string(),
47+
fields: z.array(z.string()),
48+
}),
49+
z.object({ kind: z.literal("unchanged"), id: z.string(), label: z.string() }),
50+
]);
51+
const EditResponse = z.object({
52+
proposed: ApprovalWorkflow,
53+
changes: z.array(StepChangeSchema),
54+
reason: z.string().nullable().optional(),
55+
});
56+
3557
export const WorkflowEditor = ({
3658
initial,
3759
suggestions = [],
@@ -55,20 +77,10 @@ export const WorkflowEditor = ({
5577
setBusy(true);
5678
setError(null);
5779
try {
58-
const res = await fetch(API_ROUTES.workflowEdit, {
59-
method: "POST",
60-
headers: { "content-type": "application/json" },
61-
body: JSON.stringify({ workflow: current, instruction: value }),
80+
const data = await postJson(API_ROUTES.workflowEdit, EditResponse, {
81+
workflow: current,
82+
instruction: value,
6283
});
63-
if (!res.ok) {
64-
const msg = await res
65-
.json()
66-
.then((j: { error?: string }) => j.error)
67-
.catch(() => null);
68-
setError(msg ?? "Edit failed.");
69-
return;
70-
}
71-
const data = (await res.json()) as Proposal & { reason?: string | null };
7284
const realChanges = data.changes.filter((c) => c.kind !== "unchanged");
7385
if (realChanges.length === 0) {
7486
// The agent declined (redundant / off-topic) — say so, don't offer a no-op.
@@ -79,12 +91,12 @@ export const WorkflowEditor = ({
7991
);
8092
return;
8193
}
82-
setProposal(data);
94+
setProposal({ proposed: data.proposed, changes: data.changes });
8395
setInstruction("");
8496
// Drop the chip we just used (if this came from one).
8597
setChips((cs) => cs.filter((c) => c !== value));
86-
} catch {
87-
setError("Could not reach the server.");
98+
} catch (err) {
99+
setError(err instanceof FetchJsonError ? err.message : "Edit failed.");
88100
} finally {
89101
setBusy(false);
90102
}

eslint.config.mts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ export default tseslint.config(
8888
"@typescript-eslint/no-unnecessary-type-assertion": "error",
8989
"@typescript-eslint/no-explicit-any": "error",
9090
"@typescript-eslint/no-non-null-assertion": "error",
91+
// No `x as T` assertions — narrow with a guard, validate with a schema, or use
92+
// `satisfies`. (Const assertions `as const` are still allowed.)
93+
"@typescript-eslint/no-unsafe-type-assertion": "error",
9194
"@typescript-eslint/no-unnecessary-condition": "error",
9295

9396
// ── Type style ──────────────────────────────────────────────────────────

hooks/use-event-callback.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ export const useEventCallback = <
1717
ref.current = fn;
1818
});
1919

20-
return useRef<T>(
21-
((...args: Parameters<T>): ReturnType<T> =>
22-
ref.current(...args) as ReturnType<T>) as T,
23-
).current;
20+
// The wrapper has T's exact call signature but TS can't infer it's assignable to
21+
// the generic T itself — the one boundary assertion unavoidable in this utility.
22+
// The return is `any` only because T's return is `any` (same generic-callback
23+
// reason as the disable above); args/return are typed via Parameters/ReturnType.
24+
const stable = (...args: Parameters<T>): ReturnType<T> =>
25+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- T's return is `any` by the generic constraint above
26+
ref.current(...args);
27+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- generic wrapper → T: the call signature matches; T just can't be proven assignable
28+
return useRef<T>(stable as T).current;
2429
};

lib/api-types.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { z } from "zod";
22

3+
import { TraceEvent } from "@/lib/trace";
4+
35
/**
46
* The typed wire contract for the streaming run endpoint, imported by BOTH the
57
* route and the client so a shape change is a compile error on both sides (the
@@ -44,9 +46,21 @@ export type RunRequest = z.infer<typeof RunRequest>;
4446
*/
4547
export const STREAM_CONTENT_TYPE = "application/x-ndjson; charset=utf-8";
4648

47-
/** Terminal marker appended after the last trace event. */
48-
export type StreamDone = {
49-
done: true;
49+
/** Terminal marker appended after the last trace event. A Zod schema so the client
50+
validates it off the wire (no cast) the same way it parses each TraceEvent. */
51+
export const StreamDone = z.object({
52+
done: z.literal(true),
5053
/** Total wall-clock duration of the run, ms. */
51-
durationMs: number;
52-
};
54+
durationMs: z.number(),
55+
});
56+
export type StreamDone = z.infer<typeof StreamDone>;
57+
58+
/**
59+
* One line of the NDJSON stream: either a trace event or the terminal done marker.
60+
* One schema, parsed once; `isStreamDone` discriminates which arrived.
61+
*/
62+
export const StreamLine = z.union([TraceEvent, StreamDone]);
63+
export type StreamLine = z.infer<typeof StreamLine>;
64+
65+
export const isStreamDone = (line: StreamLine): line is StreamDone =>
66+
"done" in line;

lib/assert.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,20 @@ export const nonNull = <T>(value: T | null | undefined, why: string): T => {
1414
}
1515
return value;
1616
};
17+
18+
/**
19+
* Exhaustiveness guard for a discriminated union. Put it in the `default` of a
20+
* switch (or the else of an if-chain): the parameter is typed `never`, so if a new
21+
* variant is ever added without a branch, the TYPE CHECK fails — the bug is caught
22+
* at compile time. If somehow reached at runtime, it throws clearly.
23+
*/
24+
export const assertUnreachable = (value: never): never => {
25+
throw new Error(`Unreachable case: ${JSON.stringify(value)}`);
26+
};
27+
28+
/**
29+
* Type guard: a non-null object indexable by string. The honest narrowing of an
30+
* `unknown`/`object` to a record so we can read fields off it WITHOUT an `as` cast.
31+
*/
32+
export const isRecord = (v: unknown): v is Record<string, unknown> =>
33+
typeof v === "object" && v !== null;

lib/fetch-json.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { z } from "zod";
2+
3+
import { isRecord } from "@/lib/assert";
4+
5+
/**
6+
* Typed POST → JSON for our own API routes. The `fetch` body is `any`; instead of
7+
* asserting its shape (`res.json() as T`), we VALIDATE it against a Zod schema, so
8+
* the type is earned at runtime — a malformed response fails loudly here, not as a
9+
* mystery `undefined` three components deep.
10+
*
11+
* On a non-2xx response it reads `{ error }` (our routes' error shape) and throws
12+
* `FetchJsonError` with that message + status, so callers show the server's reason.
13+
*/
14+
15+
export class FetchJsonError extends Error {
16+
constructor(
17+
message: string,
18+
readonly status: number,
19+
) {
20+
super(message);
21+
this.name = "FetchJsonError";
22+
}
23+
}
24+
25+
const errorMessage = (body: unknown, fallback: string): string => {
26+
if (isRecord(body) && typeof body["error"] === "string") return body["error"];
27+
return fallback;
28+
};
29+
30+
/** POST `body` to `url`, parse the response with `schema`, return the typed value. */
31+
export const postJson = async <T>(
32+
url: string,
33+
schema: z.ZodType<T>,
34+
body?: unknown,
35+
): Promise<T> => {
36+
const res = await fetch(url, {
37+
method: "POST",
38+
headers:
39+
body === undefined ? undefined : { "content-type": "application/json" },
40+
body: body === undefined ? undefined : JSON.stringify(body),
41+
});
42+
const json: unknown = await res.json().catch(() => null);
43+
if (!res.ok) {
44+
throw new FetchJsonError(
45+
errorMessage(json, `Request failed (${res.status}).`),
46+
res.status,
47+
);
48+
}
49+
return schema.parse(json);
50+
};

0 commit comments

Comments
 (0)