feat: dispatch authenticated project issue requests
This commit is contained in:
@@ -14,10 +14,12 @@
|
||||
"dependencies": {
|
||||
"@code/backend": "workspace:*",
|
||||
"@code/env": "workspace:*",
|
||||
"@code/primitives": "workspace:*",
|
||||
"@flue/runtime": "latest",
|
||||
"@rivet-dev/agentos-core": "catalog:",
|
||||
"convex": "catalog:",
|
||||
"hono": "4.12.30",
|
||||
"effect": "catalog:",
|
||||
"hono": "4.12.31",
|
||||
"valibot": "^1.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { registerProvider } from "@flue/runtime";
|
||||
import { flue } from "@flue/runtime/routing";
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { projectRequestRoute } from "./project-request";
|
||||
|
||||
const agentEnv = parseAgentEnv(process.env);
|
||||
|
||||
registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
|
||||
@@ -18,6 +20,7 @@ registerProvider(agentEnv.AGENT_MODEL_PROVIDER, {
|
||||
});
|
||||
|
||||
const app = new Hono();
|
||||
app.post("/project-requests", projectRequestRoute);
|
||||
app.route("/", flue());
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -98,7 +98,9 @@ const { CONVEX_URL } = parseAgentEnv(process.env);
|
||||
* caller's JWT is set on this instance only and discarded when the request
|
||||
* ends. We never mutate auth on a shared/global Convex client.
|
||||
*/
|
||||
const createAuthenticatedClient = (accessToken: string): ConvexHttpClient => {
|
||||
export const createAuthenticatedClient = (
|
||||
accessToken: string
|
||||
): ConvexHttpClient => {
|
||||
const client = new ConvexHttpClient(CONVEX_URL);
|
||||
client.setAuth(accessToken);
|
||||
return client;
|
||||
@@ -120,7 +122,7 @@ interface HeaderSource {
|
||||
* Extract and validate the Bearer access token from the request. Returns null
|
||||
* when absent or malformed so the caller can produce a clean 401.
|
||||
*/
|
||||
const extractBearerToken = (request: HeaderSource): string | null => {
|
||||
export const extractBearerToken = (request: HeaderSource): string | null => {
|
||||
const header = request.headers.get("authorization");
|
||||
if (!header) {
|
||||
return null;
|
||||
|
||||
162
packages/agents/src/project-request.ts
Normal file
162
packages/agents/src/project-request.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
decodeProjectIssueRequest,
|
||||
ProjectIssueDispatchInput,
|
||||
ProjectIssueRequestError,
|
||||
ProjectIssueRequestResult,
|
||||
ProjectIssueValidationError,
|
||||
} from "@code/primitives/project-issue";
|
||||
import type { ProjectIssueRequest } from "@code/primitives/project-issue";
|
||||
import { dispatch } from "@flue/runtime";
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import { makeFunctionReference } from "convex/server";
|
||||
import { Effect, Schema } from "effect";
|
||||
import type { Context } from "hono";
|
||||
|
||||
import { createAuthenticatedClient, extractBearerToken } from "./auth";
|
||||
|
||||
interface ProjectIssueCreateArgs extends Record<string, unknown> {
|
||||
readonly body: string;
|
||||
readonly projectId: string;
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
const createIssue = makeFunctionReference<
|
||||
"mutation",
|
||||
ProjectIssueCreateArgs,
|
||||
string
|
||||
>("projectIssues:create");
|
||||
|
||||
const createIssueFromSignal = makeFunctionReference<
|
||||
"mutation",
|
||||
{ readonly signalId: string },
|
||||
{ readonly issueId: string; readonly projectId: string }
|
||||
>("projectIssues:createFromSignal");
|
||||
|
||||
const beginIssue = makeFunctionReference<
|
||||
"mutation",
|
||||
{ readonly issueId: string },
|
||||
"queued" | "working"
|
||||
>("projectIssues:begin");
|
||||
|
||||
const markDispatchFailed = makeFunctionReference<
|
||||
"mutation",
|
||||
{ readonly error: string; readonly issueId: string },
|
||||
null
|
||||
>("projectIssues:markDispatchFailed");
|
||||
|
||||
const invalidRequest = (message: string) =>
|
||||
Response.json({ error: message }, { status: 400 });
|
||||
|
||||
const knownAuthorizationFailure = (message: string): boolean =>
|
||||
/authentication required|membership required|project not found|signal not found|not project-scoped/iu.test(
|
||||
message
|
||||
);
|
||||
|
||||
const errorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
const decodeRequest = async (input: unknown): Promise<ProjectIssueRequest> => {
|
||||
try {
|
||||
return await Effect.runPromise(decodeProjectIssueRequest(input));
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ProjectIssueRequestError ||
|
||||
error instanceof ProjectIssueValidationError
|
||||
) {
|
||||
throw invalidRequest(
|
||||
error instanceof Error ? error.message : "Invalid project request"
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const createIssueForRequest = async (
|
||||
client: ConvexHttpClient,
|
||||
request: Awaited<ReturnType<typeof decodeRequest>>
|
||||
): Promise<{ readonly issueId: string; readonly projectId: string }> => {
|
||||
if (request.kind === "signal") {
|
||||
return client.mutation(createIssueFromSignal, {
|
||||
signalId: request.signalId,
|
||||
});
|
||||
}
|
||||
const issueId = await client.mutation(createIssue, {
|
||||
body: request.body,
|
||||
projectId: request.projectId,
|
||||
title: request.title,
|
||||
});
|
||||
return { issueId, projectId: request.projectId };
|
||||
};
|
||||
|
||||
export const projectRequestRoute = async (c: Context): Promise<Response> => {
|
||||
const accessToken = extractBearerToken(c.req.raw);
|
||||
if (!accessToken) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
let input: unknown;
|
||||
try {
|
||||
input = await c.req.json();
|
||||
} catch {
|
||||
return invalidRequest("Request body must be valid JSON");
|
||||
}
|
||||
|
||||
let request: Awaited<ReturnType<typeof decodeRequest>>;
|
||||
try {
|
||||
request = await decodeRequest(input);
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
return error;
|
||||
}
|
||||
return c.json({ error: "Invalid project request" }, 400);
|
||||
}
|
||||
|
||||
const client = createAuthenticatedClient(accessToken);
|
||||
let issue:
|
||||
| { readonly issueId: string; readonly projectId: string }
|
||||
| undefined;
|
||||
try {
|
||||
issue = await createIssueForRequest(client, request);
|
||||
const status = await client.mutation(beginIssue, {
|
||||
issueId: issue.issueId,
|
||||
});
|
||||
const dispatchInput = Schema.decodeUnknownSync(ProjectIssueDispatchInput)({
|
||||
issueId: issue.issueId,
|
||||
kind: "project.issue.started",
|
||||
projectId: issue.projectId,
|
||||
});
|
||||
const receipt = await dispatch({
|
||||
agent: "project-manager",
|
||||
id: issue.issueId,
|
||||
input: dispatchInput,
|
||||
});
|
||||
const result = Schema.decodeUnknownSync(ProjectIssueRequestResult)({
|
||||
acceptedAt: receipt.acceptedAt,
|
||||
dispatchId: receipt.dispatchId,
|
||||
issueId: issue.issueId,
|
||||
projectId: issue.projectId,
|
||||
status,
|
||||
});
|
||||
return c.json(result, 202);
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
if (issue) {
|
||||
try {
|
||||
await client.mutation(markDispatchFailed, {
|
||||
error: message,
|
||||
issueId: issue.issueId,
|
||||
});
|
||||
} catch {
|
||||
// Preserve the original request failure; the issue remains inspectable.
|
||||
}
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
error: knownAuthorizationFailure(message)
|
||||
? "Project request is not authorized"
|
||||
: "Project request could not be dispatched",
|
||||
},
|
||||
knownAuthorizationFailure(message) ? 403 : 502
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user