Skip to content

Commit 49200ca

Browse files
bchapuisclaude
andcommitted
Unify form node naming and add schema-based multi-field forms
- Rename hitl-form/hitl-wait nodes to create-form/wait-for-form - Rename HitlToken to FormToken (file, types, functions) - Rename HITL_SIGNING_KEY to FORM_SIGNING_KEY - Rename DO storage prefix and methods from hitl to form - Add schema input to Create Form node for multi-field forms - Schema stored in WorkflowAgent DO, fetched by form page - Form page renders dynamic fields based on schema field types - Wait for Form node outputs single JSON response - Add schema to allowed parameter types in types test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 21ee36d commit 49200ca

16 files changed

Lines changed: 482 additions & 328 deletions

File tree

apps/api/src/context.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export interface Bindings {
6767
R2_SECRET_ACCESS_KEY?: string;
6868
R2_BUCKET_NAME?: string;
6969
SECRET_MASTER_KEY: string;
70-
HITL_SIGNING_KEY: string;
70+
FORM_SIGNING_KEY: string;
7171
STRIPE_SECRET_KEY?: string;
7272
STRIPE_WEBHOOK_SECRET?: string;
7373
STRIPE_PRICE_ID_PRO?: string;

apps/api/src/durable-objects/workflow-agent.ts

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export class WorkflowAgent extends Agent<Bindings, WorkflowAgentState> {
9191
private static readonly PERSIST_DEBOUNCE_MS = 500;
9292
private static readonly STORAGE_KEY_DIRTY = "dirty:persist";
9393
private static readonly STORAGE_PREFIX_EXEC_BUFFER = "execbuf:";
94-
private static readonly STORAGE_PREFIX_HITL = "hitl:";
94+
private static readonly STORAGE_PREFIX_FORM = "form:";
9595

9696
initialState: WorkflowAgentState = {};
9797

@@ -512,6 +512,9 @@ export class WorkflowAgent extends Agent<Bindings, WorkflowAgentState> {
512512
private async routeExecutionUpdate(
513513
execution: WorkflowExecution
514514
): Promise<void> {
515+
// Extract and store form schemas from node outputs
516+
await this.extractFormSchemas(execution);
517+
515518
const conn = this.findConnectionByExecutionId(execution.id);
516519
if (conn) {
517520
this.sendExecutionUpdate(conn, execution);
@@ -525,6 +528,34 @@ export class WorkflowAgent extends Agent<Bindings, WorkflowAgentState> {
525528
);
526529
}
527530

531+
/**
532+
* Scan node outputs for form schema data (`schema` + `token`) and
533+
* store them in DO transactional storage. This is how form nodes
534+
* register their field definitions without touching the main DB.
535+
*/
536+
private async extractFormSchemas(
537+
execution: WorkflowExecution
538+
): Promise<void> {
539+
for (const nodeExec of execution.nodeExecutions) {
540+
if (
541+
nodeExec.status === "completed" &&
542+
nodeExec.outputs &&
543+
typeof nodeExec.outputs.schema === "string" &&
544+
typeof nodeExec.outputs.token === "string"
545+
) {
546+
const key =
547+
WorkflowAgent.STORAGE_PREFIX_FORM +
548+
nodeExec.outputs.token +
549+
":schema";
550+
// Only store if not already present (idempotent)
551+
const existing = await this.storage.get(key);
552+
if (!existing) {
553+
await this.storage.put(key, nodeExec.outputs.schema);
554+
}
555+
}
556+
}
557+
}
558+
528559
/**
529560
* Scan live connections for one subscribed to the given execution.
530561
* Connection state (`connection.setState`) survives DO hibernation via
@@ -570,29 +601,35 @@ export class WorkflowAgent extends Agent<Bindings, WorkflowAgentState> {
570601
}
571602
}
572603

573-
// ── HITL form state ───────────────────────────────────────────────────
604+
// ── Form state ────────────────────────────────────────────────────────
574605

575606
/**
576-
* Check if a HITL form has already been submitted.
607+
* Check if a form has already been submitted.
577608
* Returns `{ submitted: boolean }`.
578609
*/
579-
async getHitlFormStatus(token: string): Promise<{ submitted: boolean }> {
580-
const key = WorkflowAgent.STORAGE_PREFIX_HITL + token;
610+
async getFormStatus(
611+
token: string
612+
): Promise<{ submitted: boolean; schema?: string }> {
613+
const key = WorkflowAgent.STORAGE_PREFIX_FORM + token;
581614
const record = await this.storage.get<{ submitted: boolean }>(key);
582-
return { submitted: record?.submitted ?? false };
615+
const schema = await this.storage.get<string>(key + ":schema");
616+
return {
617+
submitted: record?.submitted ?? false,
618+
...(schema ? { schema } : {}),
619+
};
583620
}
584621

585622
/**
586-
* Atomically check-and-submit a HITL form response.
623+
* Atomically check-and-submit a form response.
587624
* Rejects duplicate submissions. On success, sends the event to the
588625
* EXECUTE workflow instance to resume the paused node.
589626
*/
590-
async checkAndSubmitHitlForm(
627+
async checkAndSubmitForm(
591628
token: string,
592629
executionId: string,
593-
response: { text?: string; approved?: boolean; metadata?: Record<string, unknown> }
630+
response: Record<string, unknown>
594631
): Promise<{ success: boolean; error?: string }> {
595-
const key = WorkflowAgent.STORAGE_PREFIX_HITL + token;
632+
const key = WorkflowAgent.STORAGE_PREFIX_FORM + token;
596633
const existing = await this.storage.get<{ submitted: boolean }>(key);
597634

598635
if (existing?.submitted) {
@@ -606,19 +643,15 @@ export class WorkflowAgent extends Agent<Bindings, WorkflowAgentState> {
606643
try {
607644
const instance = await this.env.EXECUTE.get(executionId);
608645
await instance.sendEvent({
609-
type: `hitl-response-${token}`,
646+
type: `form-response-${token}`,
610647
payload: {
611-
outputs: {
612-
response: response.text ?? "",
613-
approved: response.approved ?? false,
614-
metadata: response.metadata ?? {},
615-
},
648+
outputs: { response },
616649
usage: 0,
617650
},
618651
});
619652
return { success: true };
620653
} catch (error) {
621-
console.error("Failed to send HITL event:", error);
654+
console.error("Failed to send form event:", error);
622655
return {
623656
success: false,
624657
error: "Failed to resume workflow. The execution may have expired.",

apps/api/src/mocks/node-registry.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import {
66
import { NumberInputNode } from "@dafthunk/runtime/nodes/input/number-input-node";
77
import { ConditionalForkNode } from "@dafthunk/runtime/nodes/logic/conditional-fork-node";
88
import { ConditionalJoinNode } from "@dafthunk/runtime/nodes/logic/conditional-join-node";
9-
import { HitlFormNode } from "@dafthunk/runtime/nodes/logic/hitl-form-node";
10-
import { HitlWaitNode } from "@dafthunk/runtime/nodes/logic/hitl-wait-node";
9+
import { CreateFormNode } from "@dafthunk/runtime/nodes/logic/create-form-node";
10+
import { WaitForFormNode } from "@dafthunk/runtime/nodes/logic/wait-for-form-node";
1111
import { AdditionNode } from "@dafthunk/runtime/nodes/math/addition-node";
1212
import { AvgNode } from "@dafthunk/runtime/nodes/math/avg-node";
1313
import { DivisionNode } from "@dafthunk/runtime/nodes/math/division-node";
@@ -31,7 +31,7 @@ import type { Bindings } from "../context";
3131
* - Addition, Subtraction, Multiplication, Division
3232
* - Number Input
3333
* - Sum, Max, Min, Avg, Median
34-
* - Conditional Fork, Conditional Join, HITL Form, HITL Wait
34+
* - Conditional Fork, Conditional Join, Create Form, Wait for Form
3535
* - Multi-Step Addition, Failing Multi-Step (test nodes)
3636
*/
3737
export class MockNodeRegistry extends BaseNodeRegistry<Bindings> {
@@ -48,8 +48,8 @@ export class MockNodeRegistry extends BaseNodeRegistry<Bindings> {
4848
this.registerImplementation(MedianNode);
4949
this.registerImplementation(ConditionalForkNode);
5050
this.registerImplementation(ConditionalJoinNode);
51-
this.registerImplementation(HitlFormNode);
52-
this.registerImplementation(HitlWaitNode);
51+
this.registerImplementation(CreateFormNode);
52+
this.registerImplementation(WaitForFormNode);
5353
this.registerImplementation(MultiStepAdditionNode);
5454
this.registerImplementation(FailingMultiStepNode);
5555
}

apps/api/src/routes/forms.ts

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
/**
2-
* Public HITL Form Routes
2+
* Public Form Routes
33
*
44
* These routes are unauthenticated — the signed token IS the authorization.
55
* They allow external users to view and submit human-in-the-loop forms
66
* via a shareable URL.
77
*/
88

9-
import { verifyHitlToken } from "@dafthunk/runtime";
9+
import { verifyFormToken } from "@dafthunk/runtime";
1010
import { Hono } from "hono";
1111

1212
import type { ApiContext } from "../context";
@@ -17,25 +17,38 @@ const formRoutes = new Hono<ApiContext>();
1717
/**
1818
* GET /forms/:signedToken
1919
*
20-
* Returns the form configuration (prompt, context, input type) and
20+
* Returns the form configuration (title, description, fields) and
2121
* submission status. No authentication required.
2222
*/
2323
formRoutes.get("/:signedToken", async (c) => {
2424
const signedToken = c.req.param("signedToken");
25-
const payload = await verifyHitlToken(signedToken, c.env.HITL_SIGNING_KEY);
25+
const payload = await verifyFormToken(signedToken, c.env.FORM_SIGNING_KEY);
2626

2727
if (!payload) {
2828
return c.json({ error: "Invalid or expired form link" }, 400);
2929
}
3030

3131
try {
3232
const agent = await getAgentByName(c.env.WORKFLOW_AGENT, payload.wid);
33-
const { submitted } = await agent.getHitlFormStatus(payload.tok);
33+
const { submitted, schema } = await agent.getFormStatus(payload.tok);
34+
35+
if (!schema) {
36+
return c.json(
37+
{ error: "Form schema not yet available. Please try again shortly." },
38+
404
39+
);
40+
}
41+
42+
const parsed = JSON.parse(schema) as {
43+
title: string;
44+
description?: string;
45+
fields: Array<{ name: string; type: string; required?: boolean }>;
46+
};
3447

3548
return c.json({
36-
prompt: payload.p,
37-
context: payload.c,
38-
inputType: payload.t,
49+
title: parsed.title,
50+
description: parsed.description,
51+
fields: parsed.fields,
3952
submitted,
4053
});
4154
} catch (error) {
@@ -52,28 +65,20 @@ formRoutes.get("/:signedToken", async (c) => {
5265
*/
5366
formRoutes.post("/:signedToken", async (c) => {
5467
const signedToken = c.req.param("signedToken");
55-
const payload = await verifyHitlToken(signedToken, c.env.HITL_SIGNING_KEY);
68+
const payload = await verifyFormToken(signedToken, c.env.FORM_SIGNING_KEY);
5669

5770
if (!payload) {
5871
return c.json({ error: "Invalid or expired form link" }, 400);
5972
}
6073

61-
const body = await c.req.json<{
62-
text?: string;
63-
approved?: boolean;
64-
metadata?: Record<string, unknown>;
65-
}>();
74+
const body = await c.req.json<Record<string, unknown>>();
6675

6776
try {
6877
const agent = await getAgentByName(c.env.WORKFLOW_AGENT, payload.wid);
69-
const result = await agent.checkAndSubmitHitlForm(
78+
const result = await agent.checkAndSubmitForm(
7079
payload.tok,
7180
payload.eid,
72-
{
73-
text: body.text,
74-
approved: body.approved,
75-
metadata: body.metadata,
76-
}
81+
body
7782
);
7883

7984
if (!result.success) {

apps/api/src/routes/types.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ describe("Types Route Tests", () => {
203203
"json",
204204
"document",
205205
"audio",
206+
"schema",
206207
"any",
207208
]).toContain(input.type);
208209
});

apps/api/src/runtime/cloudflare-node-registry.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -352,8 +352,8 @@ import { LikePostLinkedInNode } from "@dafthunk/runtime/nodes/linkedin/like-post
352352
import { SharePostLinkedInNode } from "@dafthunk/runtime/nodes/linkedin/share-post-linkedin-node";
353353
import { ConditionalForkNode } from "@dafthunk/runtime/nodes/logic/conditional-fork-node";
354354
import { ConditionalJoinNode } from "@dafthunk/runtime/nodes/logic/conditional-join-node";
355-
import { HitlFormNode } from "@dafthunk/runtime/nodes/logic/hitl-form-node";
356-
import { HitlWaitNode } from "@dafthunk/runtime/nodes/logic/hitl-wait-node";
355+
import { CreateFormNode } from "@dafthunk/runtime/nodes/logic/create-form-node";
356+
import { WaitForFormNode } from "@dafthunk/runtime/nodes/logic/wait-for-form-node";
357357
import { AbsoluteValueNode } from "@dafthunk/runtime/nodes/math/absolute-value-node";
358358
import { AdditionNode } from "@dafthunk/runtime/nodes/math/addition-node";
359359
import { AvgNode } from "@dafthunk/runtime/nodes/math/avg-node";
@@ -674,8 +674,8 @@ export class CloudflareNodeRegistry extends BaseNodeRegistry<Bindings> {
674674
this.registerImplementation(BooleanInputNode);
675675
this.registerImplementation(ConditionalForkNode);
676676
this.registerImplementation(ConditionalJoinNode);
677-
this.registerImplementation(HitlFormNode);
678-
this.registerImplementation(HitlWaitNode);
677+
this.registerImplementation(CreateFormNode);
678+
this.registerImplementation(WaitForFormNode);
679679

680680
// Image operations
681681
this.registerImplementation(PhotonAddNoiseNode);

apps/api/src/utils/encryption.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const createMockEnv = (masterKey?: string): Bindings => ({
3333
WEBSITE_URL: "",
3434
EMAIL_DOMAIN: "",
3535
JWT_SECRET: "",
36-
HITL_SIGNING_KEY: "test-hitl-signing-key",
36+
FORM_SIGNING_KEY: "test-form-signing-key",
3737
CLOUDFLARE_ENV: "",
3838
CLOUDFLARE_ACCOUNT_ID: "",
3939
CLOUDFLARE_API_TOKEN: "",

apps/app/src/components/workflow/use-workflow-execution-state.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,6 @@ export function useWorkflowExecutionState({
231231
outputs: nodeExecution.outputs || {},
232232
error: nodeExecution.error,
233233
});
234-
235234
});
236235

237236
if (execution.status === "exhausted") {

0 commit comments

Comments
 (0)