Skip to content

Commit 6f73be8

Browse files
DylanMerigaudclaude
andcommitted
Merge: HRIS onboarding + conditional approval workflow + chat-edit + lint alignment
The product became two surfaces: onboarding (agent derives the approval workflow from the HRIS, conversational edits with preview/approve/revert) and operations (invoices run through that conditional workflow DAG). Plus a full code-quality pass (typed env, ESLint aligned-and-stricter than the sibling repo, two evals). All green on the branch: lint, typecheck, knip, 149 tests, build, sanity + both evals. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 parents 878679a + 2e7d989 commit 6f73be8

104 files changed

Lines changed: 10545 additions & 1237 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88

99
jobs:
1010
check:
11-
name: format · typecheck · knip · test · build · eval
11+
name: format · typecheck · lint · knip · test · build · eval
1212
runs-on: ubuntu-latest
1313
steps:
1414
- uses: actions/checkout@v4
@@ -29,6 +29,9 @@ jobs:
2929
- name: Typecheck
3030
run: pnpm typecheck
3131

32+
- name: Lint (ESLint — cast/any hygiene)
33+
run: pnpm lint
34+
3235
- name: Dead-code check (knip)
3336
run: pnpm knip
3437

@@ -51,3 +54,9 @@ jobs:
5154
# scoring offline (a perfect score is expected). Run the real eval locally.
5255
- name: Investigator eval (dry-run, no API calls)
5356
run: pnpm eval --dry-run
57+
58+
# The conversational-edit eval scores whether the model maps an instruction
59+
# to the right WorkflowEditOp. `--dry-run` exercises the corpus + scoring
60+
# offline; the live run (real model, 8/8) is local.
61+
- name: Workflow-edit eval (dry-run, no API calls)
62+
run: pnpm eval:edit --dry-run

README.md

Lines changed: 82 additions & 59 deletions
Large diffs are not rendered by default.

app/api/onboarding/route.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { defaultHris } from "@/lib/hris";
2+
import { deriveWorkflow } from "@/lib/onboarding";
3+
import { anthropicProposalModel } from "@/lib/onboarding-model";
4+
import { checkRateLimit, clientIpFrom } from "@/lib/ratelimit";
5+
6+
/**
7+
* POST /api/onboarding — the discovery step a forward-deployed engineer runs once
8+
* per client. It reads the client's org from the HRIS (BambooHR, scoped to the
9+
* demo client's division — or the recorded sample org when no key is set), then
10+
* the onboarding agent derives a proposed approval workflow + flags the org's
11+
* data-quality issues for a human to resolve.
12+
*
13+
* Returns the normalised org, the derived conditional workflow (the DAG the
14+
* pipeline executes), and the issues — the payload the onboarding UI renders for
15+
* validation. The result is a PROPOSAL: a human approves it before it goes live.
16+
*
17+
* Runtime: NODE (the HRIS adapter and the model SDK need it). Rate-limited like
18+
* the run route — each call spends model tokens.
19+
*/
20+
export const runtime = "nodejs";
21+
export const dynamic = "force-dynamic";
22+
export const maxDuration = 60;
23+
24+
export const POST = async (request: Request): Promise<Response> => {
25+
const ip = clientIpFrom(request.headers);
26+
const verdict = await checkRateLimit(ip);
27+
if (!verdict.ok) {
28+
return Response.json(
29+
{
30+
error: `You've hit the demo limit. Try again in about ${Math.max(
31+
1,
32+
Math.ceil(verdict.retryAfterSeconds / 60),
33+
)} minute(s).`,
34+
},
35+
{
36+
status: 429,
37+
headers: { "retry-after": String(verdict.retryAfterSeconds) },
38+
},
39+
);
40+
}
41+
42+
// 1. Read the client's org from the HRIS (live scoped, or recorded sample).
43+
let org: Awaited<ReturnType<ReturnType<typeof defaultHris>["fetchOrg"]>>;
44+
try {
45+
org = await defaultHris().fetchOrg();
46+
} catch {
47+
return Response.json(
48+
{ error: "Could not read the org from the HRIS." },
49+
{ status: 502 },
50+
);
51+
}
52+
if (org.employees.length === 0) {
53+
return Response.json(
54+
{
55+
error:
56+
"The HRIS returned no employees for this client (is the org seeded?).",
57+
},
58+
{ status: 404 },
59+
);
60+
}
61+
62+
// 2. The onboarding agent derives the workflow + explains the issues.
63+
try {
64+
const { workflow, proposal, issues } = await deriveWorkflow(
65+
anthropicProposalModel,
66+
org,
67+
);
68+
return Response.json({
69+
source: org.source,
70+
employeeCount: org.employees.length,
71+
employees: org.employees,
72+
workflow,
73+
proposal,
74+
issues,
75+
});
76+
} catch {
77+
return Response.json(
78+
{ error: "The onboarding model failed to derive a workflow." },
79+
{ status: 502 },
80+
);
81+
}
82+
};

app/api/pdf/[id]/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ import { renderInvoicePdf } from "@/lib/invoice-pdf";
2121

2222
export const runtime = "nodejs";
2323

24-
export async function GET(
24+
export const GET = async (
2525
_request: Request,
2626
{ params }: { params: Promise<{ id: string }> },
27-
): Promise<Response> {
27+
): Promise<Response> => {
2828
const { id } = await params;
2929

3030
let invoice: Awaited<ReturnType<typeof loadInvoiceById>>;
@@ -50,4 +50,4 @@ export async function GET(
5050
"cache-control": "public, max-age=3600, immutable",
5151
},
5252
});
53-
}
53+
};

app/api/run/route.ts

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import { mastra } from "@/src/mastra";
21
import { loadRunBundle } from "@/db/client";
32
import { profileById } from "@/db/client-profiles";
4-
import { toTraceEvent, pipelineErrorEvent, type TraceEvent } from "@/lib/trace";
5-
import { ndjsonLine } from "@/lib/ndjson";
6-
import { checkRateLimit, clientIpFrom } from "@/lib/ratelimit";
73
import {
84
RunRequest,
95
STREAM_CONTENT_TYPE,
106
type StreamDone,
117
} from "@/lib/api-types";
8+
import { ndjsonLine } from "@/lib/ndjson";
9+
import { checkRateLimit, clientIpFrom } from "@/lib/ratelimit";
10+
import { toTraceEvent, pipelineErrorEvent, type TraceEvent } from "@/lib/trace";
11+
import { mastra } from "@/src/mastra";
1212

1313
/**
1414
* POST /api/run — execute the procure-to-pay pipeline for one seeded invoice and
@@ -37,11 +37,11 @@ export const runtime = "nodejs";
3737
export const dynamic = "force-dynamic";
3838
export const maxDuration = 60;
3939

40-
function line(obj: unknown): Uint8Array {
40+
const line = (obj: unknown): Uint8Array => {
4141
return new TextEncoder().encode(ndjsonLine(obj));
42-
}
42+
};
4343

44-
export async function POST(request: Request): Promise<Response> {
44+
export const POST = async (request: Request): Promise<Response> => {
4545
// 0. Rate-limit by IP first — before any work or model calls. The demo is
4646
// public and each run spends Anthropic tokens, so this caps abuse. Fails
4747
// open if Redis isn't configured (see lib/ratelimit.ts).
@@ -64,27 +64,29 @@ export async function POST(request: Request): Promise<Response> {
6464

6565
// 1. Parse + validate the request body.
6666
let id: string;
67-
let decision: "approve" | "reject" | undefined;
67+
let decisions: Record<string, "approve" | "reject">;
6868
let profileId: string | undefined;
6969
try {
7070
const body: unknown = await request.json();
7171
const parsed = RunRequest.parse(body);
7272
id = parsed.id;
73-
decision = parsed.decision;
73+
decisions = parsed.decisions ?? {};
7474
profileId = parsed.profileId;
7575
} catch {
7676
return Response.json(
7777
{
78-
error: 'Body must be { id: string, decision?: "approve" | "reject" }.',
78+
error:
79+
'Body must be { id: string, decisions?: { [stepId]: "approve" | "reject" } }.',
7980
},
8081
{ status: 400 },
8182
);
8283
}
83-
// The reviewer's decision maps to the workflow's humanApproval input. No
84-
// decision → "pending" (an exception pauses for a human). "approve"/"reject"
85-
// are phase-2 resumes. Recomputing the cheap deterministic prefix instead of
86-
// restoring a persisted snapshot is what keeps the human-in-the-loop stateless.
87-
const humanApproval = decision ?? "pending";
84+
// The reviewer's per-step decisions drive the approval workflow. None → every
85+
// active gate pauses as pending (phase 1). A resume sends decisions by step id
86+
// (phase 2); the bill posts only once all active gates are approved. Recomputing
87+
// the deterministic prefix from the decisions (not a persisted snapshot) is what
88+
// keeps the human-in-the-loop stateless.
89+
const hasDecisions = Object.keys(decisions).length > 0;
8890

8991
// 2. Load the seeded document bundle (READ ONLY). Done before opening the
9092
// stream so a missing invoice / missing DB config is a clean HTTP error
@@ -129,12 +131,12 @@ export async function POST(request: Request): Promise<Response> {
129131
purchaseOrder: bundle.purchaseOrder,
130132
goodsReceipt: bundle.goodsReceipt,
131133
priorInvoiceNumbers: bundle.priorInvoiceNumbers,
132-
humanApproval,
133-
// On a phase-2 resume (approve/reject) the workflow re-runs from the
134+
decisions,
135+
// On a phase-2 resume (decisions present) the workflow re-runs from the
134136
// top, but the document was already read — skip the (costly) vision
135137
// call the second time. The reveal from phase 1 still stands.
136-
skipExtraction: decision !== undefined,
137-
// Run under the requested client profile (tolerances + approval tiers);
138+
skipExtraction: hasDecisions,
139+
// Run under the requested client profile (tolerances + approval workflow);
138140
// defaults to the standard profile.
139141
profile: profileById(profileId),
140142
},
@@ -176,4 +178,4 @@ export async function POST(request: Request): Promise<Response> {
176178
"x-accel-buffering": "no",
177179
},
178180
});
179-
}
181+
};

app/api/workflow/edit/route.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { z } from "zod";
2+
3+
import { ApprovalWorkflow } from "@/lib/approval-workflow";
4+
import { checkRateLimit, clientIpFrom } from "@/lib/ratelimit";
5+
import { proposeEdit } from "@/lib/workflow-edit";
6+
import { anthropicEditModel } from "@/lib/workflow-edit-model";
7+
8+
/**
9+
* POST /api/workflow/edit — propose a conversational edit to an approval workflow.
10+
*
11+
* Body: { workflow: ApprovalWorkflow, instruction: string }. Returns the PROPOSED
12+
* workflow plus the diff vs the current one. Nothing is persisted or applied — the
13+
* client shows the diff and the human approves (swaps it in) or reverts (discards).
14+
* The pipeline only ever runs the workflow the human has approved.
15+
*/
16+
export const runtime = "nodejs";
17+
export const dynamic = "force-dynamic";
18+
export const maxDuration = 60;
19+
20+
const EditRequest = z.object({
21+
workflow: ApprovalWorkflow,
22+
instruction: z.string().trim().min(1, "an instruction is required"),
23+
});
24+
25+
export const POST = async (request: Request): Promise<Response> => {
26+
const ip = clientIpFrom(request.headers);
27+
const verdict = await checkRateLimit(ip);
28+
if (!verdict.ok) {
29+
return Response.json(
30+
{ error: "Rate limit hit — try again shortly." },
31+
{
32+
status: 429,
33+
headers: { "retry-after": String(verdict.retryAfterSeconds) },
34+
},
35+
);
36+
}
37+
38+
let workflow: z.infer<typeof EditRequest>["workflow"];
39+
let instruction: string;
40+
try {
41+
const parsed = EditRequest.parse(await request.json());
42+
workflow = parsed.workflow;
43+
instruction = parsed.instruction;
44+
} catch {
45+
return Response.json(
46+
{ error: "Body must be { workflow, instruction }." },
47+
{ status: 400 },
48+
);
49+
}
50+
51+
try {
52+
const { proposed, op, changes } = await proposeEdit(
53+
anthropicEditModel,
54+
workflow,
55+
instruction,
56+
);
57+
// `op.reason` (only present on a `none`) explains why nothing changed.
58+
const reason = op.op === "none" ? op.reason : null;
59+
return Response.json({ proposed, changes, reason });
60+
} catch {
61+
// A validation failure (the model produced an invalid graph) or a model error
62+
// both land here — the edit is simply not offered, the current workflow stands.
63+
return Response.json(
64+
{
65+
error:
66+
"Could not produce a valid edit for that instruction. The current workflow is unchanged — try rephrasing.",
67+
},
68+
{ status: 422 },
69+
);
70+
}
71+
};

app/layout.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import type { Metadata } from "next";
2-
import { GeistSans } from "geist/font/sans";
31
import { GeistMono } from "geist/font/mono";
4-
import "./globals.css";
2+
import { GeistSans } from "geist/font/sans";
3+
import type { Metadata } from "next";
4+
import "@/app/globals.css";
55

66
const SITE_URL = "https://ledgerloop-eta.vercel.app";
77
const TITLE = "ledgerloop — agentic procure-to-pay";

app/page.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Dashboard } from "@/components/dashboard";
1+
import { AppView } from "@/components/app-view";
22
import { SocialLinks } from "@/components/social-links";
33
import { listInvoiceQueue, type QueueItem } from "@/db/client";
44
import { PIPELINE_MODEL } from "@/src/mastra/model";
@@ -40,15 +40,15 @@ export default async function Page() {
4040
) : queue.length === 0 ? (
4141
<SetupNotice detail="The database is reachable but empty — run `pnpm db:seed` to load the demo invoices." />
4242
) : (
43-
<Dashboard queue={queue} />
43+
<AppView queue={queue} />
4444
)}
4545
</div>
4646
<Footer />
4747
</main>
4848
);
4949
}
5050

51-
function Header() {
51+
const Header = () => {
5252
return (
5353
<header className="mb-6">
5454
<div className="flex flex-wrap items-end justify-between gap-3">
@@ -77,9 +77,9 @@ function Header() {
7777
</div>
7878
</header>
7979
);
80-
}
80+
};
8181

82-
function FlowChip({ n, label }: { n: number; label: string }) {
82+
const FlowChip = ({ n, label }: { n: number; label: string }) => {
8383
return (
8484
<span className="inline-flex items-center gap-1.5 rounded-full bg-surface px-2.5 py-1 ring-1 ring-inset ring-line">
8585
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-accent-soft text-[9px] font-semibold text-accent">
@@ -88,17 +88,17 @@ function FlowChip({ n, label }: { n: number; label: string }) {
8888
{label}
8989
</span>
9090
);
91-
}
91+
};
9292

93-
function Arrow() {
93+
const Arrow = () => {
9494
return (
9595
<span aria-hidden className="text-line">
9696
9797
</span>
9898
);
99-
}
99+
};
100100

101-
function SetupNotice({ detail }: { detail: string }) {
101+
const SetupNotice = ({ detail }: { detail: string }) => {
102102
return (
103103
<div className="rounded-xl border border-warn-line bg-warn-soft/50 px-5 py-4 text-[13px] text-ink">
104104
<p className="font-medium">Almost there — the demo needs its database.</p>
@@ -120,9 +120,9 @@ function SetupNotice({ detail }: { detail: string }) {
120120
</p>
121121
</div>
122122
);
123-
}
123+
};
124124

125-
function Footer() {
125+
const Footer = () => {
126126
return (
127127
<footer className="mt-8 flex flex-wrap items-center justify-between gap-3 border-t border-line pt-4 text-[12px] text-muted">
128128
<p>
@@ -137,4 +137,4 @@ function Footer() {
137137
</div>
138138
</footer>
139139
);
140-
}
140+
};

0 commit comments

Comments
 (0)