Skip to content

Commit df45613

Browse files
authored
feat: Expose jobs via React SDK (#637)
* Feat: Expose jobs via React SDK * feat: Support for job approval * chore: Update tests
1 parent 56a8f8d commit df45613

File tree

7 files changed

+112
-53
lines changed

7 files changed

+112
-53
lines changed

control-plane/src/modules/contract.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1176,6 +1176,8 @@ export const definition = {
11761176
authContext: z.any().nullable(),
11771177
feedbackComment: z.string().nullable(),
11781178
feedbackScore: z.number().nullable(),
1179+
result: anyObject.nullable().optional(),
1180+
tags: z.record(z.string()).nullable().optional(),
11791181
attachedFunctions: z.array(z.string()).nullable(),
11801182
name: z.string().nullable(),
11811183
}),

control-plane/src/modules/runs/queues.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { getRun } from "./";
66
import { processRun } from "./agent/run";
77
import { generateTitle } from "./summarization";
88

9-
import { and, eq } from "drizzle-orm";
9+
import { and, eq, } from "drizzle-orm";
1010
import { Consumer } from "sqs-consumer";
1111
import { z } from "zod";
1212
import { injectTraceContext } from "../observability/tracer";

control-plane/src/modules/timeline.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { getJobsForRun } from "./jobs/jobs";
22
import { getActivityForTimeline } from "./observability/events";
33
import { getRunMessagesForDisplay } from "./runs/messages";
4-
import { getRun } from "./runs";
4+
import { getRun, getRunDetails } from "./runs";
55

66
export const timeline = {
77
getRunTimeline: async ({
@@ -53,7 +53,7 @@ export const timeline = {
5353
clusterId,
5454
runId,
5555
}),
56-
getRun({
56+
getRunDetails({
5757
clusterId,
5858
runId,
5959
}),

sdk-react/src/contract.ts

+39-7
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,17 @@ export const definition = {
234234
}),
235235
},
236236
},
237+
createEphemeralSetup: {
238+
method: "POST",
239+
path: "/ephemeral-setup",
240+
responses: {
241+
200: z.object({
242+
clusterId: z.string(),
243+
apiKey: z.string(),
244+
}),
245+
},
246+
body: z.undefined(),
247+
},
237248
getContract: {
238249
method: "GET",
239250
path: "/contract",
@@ -292,6 +303,8 @@ export const definition = {
292303
resultType: z.string().nullable(),
293304
createdAt: z.date(),
294305
blobs: z.array(blobSchema),
306+
approved: z.boolean().nullable(),
307+
approvalRequested: z.boolean().nullable(),
295308
}),
296309
},
297310
},
@@ -725,15 +738,23 @@ export const definition = {
725738
authorization: z.string(),
726739
}),
727740
body: z.object({
728-
runId: z
741+
id: z
729742
.string()
730743
.optional()
731744
.describe(
732-
"The run ID. If not provided, a new run will be created. If provided, the run will be created with the given"
745+
"The run ID. If not provided, a new run will be created. If provided, the run will be created with the given. If the run already exists, it will be returned."
733746
)
734747
.refine(
735-
val => !val || /^[0-9A-Z]{26}$/.test(val),
736-
"Run ID must be a valid ULID (26 uppercase alphanumeric characters)"
748+
val => !val || /^[0-9A-Za-z-_]{16,128}$/.test(val),
749+
"Run ID must contain only alphanumeric characters, dashes, and underscores. Must be between 16 and 128 characters long."
750+
),
751+
runId: z
752+
.string()
753+
.optional()
754+
.describe("Deprecated. Use `id` instead.")
755+
.refine(
756+
val => !val || /^[0-9A-Za-z-_]{16,128}$/.test(val),
757+
"Run ID must contain only alphanumeric characters, dashes, and underscores. Must be between 16 and 128 characters long."
737758
),
738759
initialPrompt: z
739760
.string()
@@ -761,9 +782,18 @@ export const definition = {
761782
),
762783
onStatusChange: z
763784
.object({
764-
statuses: z.array(z.enum(["pending", "running", "paused", "done", "failed"])).describe(" A list of Run statuses which should trigger the handler").optional().default(["done", "failed"]),
765-
function: functionReference.describe("A function to call when the run status changes").optional(),
766-
webhook: z.string().describe("A webhook URL to call when the run status changes").optional(),
785+
statuses: z
786+
.array(z.enum(["pending", "running", "paused", "done", "failed"]))
787+
.describe(" A list of Run statuses which should trigger the handler")
788+
.optional()
789+
.default(["done", "failed"]),
790+
function: functionReference
791+
.describe("A function to call when the run status changes")
792+
.optional(),
793+
webhook: z
794+
.string()
795+
.describe("A webhook URL to call when the run status changes")
796+
.optional(),
767797
})
768798
.optional()
769799
.describe("Mechanism for receiving notifications when the run status changes"),
@@ -1146,6 +1176,8 @@ export const definition = {
11461176
authContext: z.any().nullable(),
11471177
feedbackComment: z.string().nullable(),
11481178
feedbackScore: z.number().nullable(),
1179+
result: anyObject.nullable().optional(),
1180+
tags: z.record(z.string()).nullable().optional(),
11491181
attachedFunctions: z.array(z.string()).nullable(),
11501182
name: z.string().nullable(),
11511183
}),

sdk-react/src/hooks/useMessages.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ListMessagesResponse } from "./useRun";
1+
import { RunTimelineMessages } from "./useRun";
22

33
/**
44
* Message types supported in the Inferable conversation system.
@@ -74,7 +74,7 @@ import { ListMessagesResponse } from "./useRun";
7474
* });
7575
* ```
7676
*/
77-
export const useMessages = (messages?: ListMessagesResponse) => {
77+
export const useMessages = (messages?: RunTimelineMessages) => {
7878
return {
7979
/**
8080
* Returns all messages sorted by their ID
@@ -91,7 +91,7 @@ export const useMessages = (messages?: ListMessagesResponse) => {
9191
* @param type - The message type to filter by ("human", "agent", or "invocation-result")
9292
* @returns Array of messages matching the specified type
9393
*/
94-
getOfType: (type: ListMessagesResponse[number]["type"]) =>
94+
getOfType: (type: RunTimelineMessages[number]["type"]) =>
9595
messages?.filter(message => message.type === type),
9696
};
9797
};

sdk-react/src/hooks/useRun.test.ts

+11-13
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ type ApiClient = ReturnType<typeof createApiClient>;
88

99
const createMockApiClient = () => ({
1010
createRun: jest.fn() as jest.Mock<any>,
11-
listMessages: jest.fn() as jest.Mock<any>,
12-
getRun: jest.fn() as jest.Mock<any>,
11+
getRunTimeline: jest.fn() as jest.Mock<any>,
1312
createMessage: jest.fn() as jest.Mock<any>,
1413
listRuns: jest.fn() as jest.Mock<any>,
1514
});
@@ -29,10 +28,6 @@ describe("useRun", () => {
2928
jest.clearAllMocks();
3029
});
3130

32-
const mockSchema = z.object({
33-
result: z.any(),
34-
});
35-
3631
const createMockInferable = (client: MockApiClient) => ({
3732
client: client as unknown as ApiClient,
3833
clusterId: "test-cluster",
@@ -48,11 +43,12 @@ describe("useRun", () => {
4843

4944
it("should use existing runId when provided", async () => {
5045
const existingRunId = "existing-run-123";
51-
mockApiClient.listMessages.mockResolvedValue({ status: 200, body: [], headers: new Headers() });
52-
mockApiClient.getRun.mockResolvedValue({
46+
mockApiClient.getRunTimeline.mockResolvedValue({
5347
status: 200,
54-
body: { id: existingRunId, status: "running" },
55-
headers: new Headers(),
48+
body: {
49+
messages: [],
50+
run: { id: existingRunId, status: "running" },
51+
}, headers: new Headers()
5652
});
5753

5854
const mockInferable = createMockInferable(mockApiClient);
@@ -72,10 +68,12 @@ describe("useRun", () => {
7268
const runId = "test-run-123";
7369
const messageText = "test message";
7470

75-
mockApiClient.listMessages.mockResolvedValue({ status: 200, body: [], headers: new Headers() });
76-
mockApiClient.getRun.mockResolvedValue({
71+
mockApiClient.getRunTimeline.mockResolvedValue({
7772
status: 200,
78-
body: { id: runId, status: "running" },
73+
body: {
74+
run: {id: runId, status: "running" },
75+
messages: [],
76+
},
7977
headers: new Headers(),
8078
});
8179
mockApiClient.createMessage.mockResolvedValueOnce({

sdk-react/src/hooks/useRun.ts

+54-27
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { z } from "zod";
44
import { contract } from "../contract";
55
import { useInferable } from "./useInferable";
66

7-
export type ListMessagesResponse = ClientInferResponseBody<(typeof contract)["listMessages"], 200>;
8-
type GetRunResponse = ClientInferResponseBody<(typeof contract)["getRun"], 200>;
7+
export type RunTimelineMessages = ClientInferResponseBody<(typeof contract)["listMessages"], 200>;
8+
type RunTimelineRun = ClientInferResponseBody<(typeof contract)["getRunTimeline"], 200>["run"];
9+
type RunTimelineJobs = ClientInferResponseBody<(typeof contract)["getRunTimeline"], 200>["jobs"];
910

1011
/**
1112
* Return type for the useRun hook containing all the necessary methods and data for managing a run session
@@ -20,14 +21,18 @@ interface UseRunReturn<T extends z.ZodObject<any>> {
2021
*/
2122
createMessage: (input: string) => Promise<void>;
2223
/** Array of messages in the current run, including human messages, agent responses, and invocation results */
23-
messages: ListMessagesResponse;
24+
messages: RunTimelineMessages;
2425
/** Current run details including status, metadata, and result if available */
25-
run?: GetRunResponse;
26+
run?: RunTimelineRun;
2627
/**
2728
* Typed result of the run based on the provided schema T.
2829
* Only available when the run is complete and successful.
2930
*/
3031
result?: z.infer<T>;
32+
/** Array of jobs in the current run */
33+
jobs: RunTimelineJobs;
34+
/** Function to approve or deny a job in the current run */
35+
submitApproval: (jobId: string, approved: boolean) => Promise<void>;
3136
/** Error object if any errors occurred during the session */
3237
error: Error | null;
3338
}
@@ -102,8 +107,9 @@ export function useRun<T extends z.ZodObject<any>>(
102107
options: UseRunOptions = {}
103108
): UseRunReturn<T> {
104109
const { persist = true } = options;
105-
const [messages, setMessages] = useState<ListMessagesResponse>([]);
106-
const [run, setRun] = useState<GetRunResponse>();
110+
const [messages, setMessages] = useState<RunTimelineMessages>([]);
111+
const [jobs, setJobs] = useState<RunTimelineJobs>([]);
112+
const [run, setRun] = useState<RunTimelineRun>();
107113
const [runId, setRunId] = useState<string | undefined>(() => {
108114
if (persist && typeof window !== "undefined") {
109115
return localStorage.getItem(STORAGE_KEY) || undefined;
@@ -120,6 +126,7 @@ export function useRun<T extends z.ZodObject<any>>(
120126
setRunId(newRunId);
121127
setMessages([]);
122128
setRun(undefined);
129+
setJobs([]);
123130
lastMessageId.current = null;
124131
},
125132
[persist]
@@ -136,29 +143,36 @@ export function useRun<T extends z.ZodObject<any>>(
136143
if (!runId) return;
137144

138145
try {
139-
const [messageResponse, runResponse] = await Promise.all([
140-
inferable.client.listMessages({
146+
const [timelineResponse] = await Promise.all([
147+
inferable.client.getRunTimeline({
141148
params: { clusterId: inferable.clusterId, runId: runId },
142149
query: {
143-
after: lastMessageId.current ?? "0",
144-
waitTime: 20,
150+
messagesAfter: lastMessageId.current ?? "0",
145151
},
146152
}),
147-
inferable.client.getRun({
148-
params: { clusterId: inferable.clusterId, runId: runId },
149-
}),
150153
]);
151154

155+
152156
if (!isMounted) return;
153157

154-
if (messageResponse.status === 200) {
158+
if (timelineResponse.status === 200) {
159+
160+
const runHasChanged = JSON.stringify(timelineResponse.body.run) !== JSON.stringify(run);
161+
162+
if (runHasChanged) {
163+
setRun(timelineResponse.body.run);
164+
}
165+
166+
const jobsHasChanged = JSON.stringify(timelineResponse.body.jobs) !== JSON.stringify(jobs);
167+
setJobs(timelineResponse.body.jobs);
168+
155169
lastMessageId.current =
156-
messageResponse.body.sort((a, b) => b.id.localeCompare(a.id))[0]?.id ??
170+
timelineResponse.body.messages.sort((a, b) => b.id.localeCompare(a.id))[0]?.id ??
157171
lastMessageId.current;
158172

159173
setMessages(existing =>
160174
existing.concat(
161-
messageResponse.body.filter(
175+
timelineResponse.body.messages.filter(
162176
message =>
163177
message.type === "agent" ||
164178
message.type === "human" ||
@@ -169,21 +183,11 @@ export function useRun<T extends z.ZodObject<any>>(
169183
} else {
170184
setError(
171185
new Error(
172-
`Could not list messages. Status: ${messageResponse.status} Body: ${JSON.stringify(messageResponse.body)}`
186+
`Could not list messages. Status: ${timelineResponse.status} Body: ${JSON.stringify(timelineResponse.body)}`
173187
)
174188
);
175189
}
176190

177-
if (runResponse.status === 200) {
178-
const runHasChanged = JSON.stringify(runResponse.body) !== JSON.stringify(run);
179-
180-
if (runHasChanged) {
181-
setRun(runResponse.body);
182-
}
183-
} else {
184-
setError(new Error(`Could not get run. Status: ${runResponse.status}`));
185-
}
186-
187191
// Schedule next poll
188192
timeoutId = setTimeout(pollMessages, 1000);
189193
} catch (error) {
@@ -226,12 +230,35 @@ export function useRun<T extends z.ZodObject<any>>(
226230
[inferable.client, runId]
227231
);
228232

233+
234+
const submitApproval = useMemo(
235+
() => async (jobId: string, approved: boolean) => {
236+
const response = await inferable.client.createJobApproval({
237+
body: { approved },
238+
params: { clusterId: inferable.clusterId, jobId },
239+
})
240+
241+
242+
if (response.status !== 204) {
243+
setError(
244+
new Error(
245+
`Could not submit approval. Status: ${response.status} Body: ${JSON.stringify(response.body)}`
246+
)
247+
);
248+
}
249+
250+
}, [inferable.client]
251+
);
252+
253+
229254
return {
230255
createMessage,
231256
messages,
257+
jobs,
232258
run,
233259
result: run?.result ? run.result : undefined,
234260
error,
235261
setRunId: setRunIdWithPersistence,
262+
submitApproval,
236263
};
237264
}

0 commit comments

Comments
 (0)