Convex-only Slice 1: clients talk to Convex, Flue is a private worker (#20)

This commit was merged in pull request #20.
This commit is contained in:
2026-07-27 16:03:36 +00:00
parent cc47007fa9
commit cb7484912c
141 changed files with 1547 additions and 17812 deletions
-119
View File
@@ -1,119 +0,0 @@
import {
createBranch,
createIssue,
createPullRequest,
fetchTransport,
listIssues,
} from "@code/primitives/git-remote-runtime";
import type {
GitRemoteConfig,
GitRemoteError,
} from "@code/primitives/git-remote-runtime";
import { defineTool } from "@flue/runtime";
import { Effect } from "effect";
import * as v from "valibot";
/**
* Canonical repo for the bootstrap: one self-hosted Gitea repo hosts all work.
* Hard-coded owner/repo keeps the agent off credential-shaped free input.
*/
export const CANONICAL_REPO = { owner: "puter", repo: "zopu-code" } as const;
export const makeGitRemoteConfig = (runtimeEnv: {
readonly GITEA_TOKEN?: string;
readonly GITEA_URL: string;
}): GitRemoteConfig => {
if (!runtimeEnv.GITEA_TOKEN) {
throw new Error(
"GITEA_TOKEN is required to configure the git remote tools"
);
}
return {
baseUrl: runtimeEnv.GITEA_URL,
owner: CANONICAL_REPO.owner,
repo: CANONICAL_REPO.repo,
token: runtimeEnv.GITEA_TOKEN,
};
};
const run = <A, E>(eff: Effect.Effect<A, E>): Promise<A> =>
Effect.runPromise(eff);
const describeError = (error: GitRemoteError) => ({
error: error.message,
reason: error.reason,
});
// Tool returns are serialized to the model; round-trip through JSON so the
// Effect-branded/readonly value objects become plain mutable JsonValue.
// oxlint-disable-next-line unicorn/prefer-structured-clone -- structuredClone keeps readonly modifiers; JSON erases them for JsonValue
const json = (value: unknown) => JSON.parse(JSON.stringify(value));
/**
* Git-remote tools bound to the canonical repo. The agent creates issues,
* branches, and pull requests on puter/zopu-code via the Gitea API using the
* server-side token — it never accepts credentials or repo paths as input.
*/
export const createGitRemoteTools = (config: GitRemoteConfig) => [
defineTool({
description:
"Create an issue on the canonical zopu-code repository. Use this to turn an agreed-upon piece of work into a tracked issue. Returns the issue number and URL. Always call list_issues first to avoid duplicates.",
input: v.object({
body: v.string(),
title: v.string(),
}),
name: "create_issue",
async run({ input }) {
const result = await run(
createIssue(fetchTransport, config, input)
).catch(describeError);
return json(result);
},
}),
defineTool({
description:
"List open issues on the canonical zopu-code repository. Call this before creating an issue to check for duplicates and understand existing work.",
name: "list_issues",
async run() {
const result = await run(listIssues(fetchTransport, config)).catch(
describeError
);
return json(result);
},
}),
defineTool({
description:
"Create a remote branch on the canonical zopu-code repository. Usually you do not need this directly — start_workflow handles branching for a work run. Use only when explicitly setting up a branch by hand.",
input: v.object({
from: v.optional(v.string()),
name: v.string(),
}),
name: "create_branch",
async run({ input }) {
const result = await run(
createBranch(fetchTransport, config, input)
).catch(describeError);
return json(result);
},
}),
defineTool({
description:
"Open a pull request on the canonical zopu-code repository. Provide the work branch, the base branch (usually main), a title, and a body. Returns the PR number and URL.",
input: v.object({
baseBranch: v.string(),
body: v.string(),
branch: v.string(),
title: v.string(),
}),
name: "create_pull_request",
async run({ input }) {
const result = await run(
createPullRequest(fetchTransport, config, input)
).catch(describeError);
return json(result);
},
}),
];
-43
View File
@@ -1,43 +0,0 @@
import { spawn } from "node:child_process";
import { once } from "node:events";
import path from "node:path";
import { defineTool } from "@flue/runtime";
import * as v from "valibot";
const repositoryRoot = path.resolve(process.cwd(), "../..");
export const paseoCli = defineTool({
description:
"Run the locally installed Paseo CLI. Pass every argument exactly as it should appear after the paseo executable.",
input: v.object({
args: v.array(v.string()),
}),
name: "paseo_cli",
output: v.object({
exitCode: v.number(),
stderr: v.string(),
stdout: v.string(),
}),
async run({ input, signal }) {
const process = spawn("paseo", input.args, {
cwd: repositoryRoot,
signal,
stdio: ["ignore", "pipe", "pipe"],
});
const stderrChunks: Buffer[] = [];
const stdoutChunks: Buffer[] = [];
process.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
process.stdout.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
const [code] = await once(process, "close");
const exitCode = typeof code === "number" ? code : 1;
return {
exitCode,
stderr: Buffer.concat(stderrChunks).toString(),
stdout: Buffer.concat(stdoutChunks).toString(),
};
},
});
-70
View File
@@ -1,70 +0,0 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { ARTIFACT_PATHS } from "@code/backend/convex/artifactModel";
import { parseAgentEnv } from "@code/env/agent";
import { defineTool } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import * as v from "valibot";
export const createProjectTools = (
issueAgentId: string,
runtimeEnv: Record<string, string | undefined>
) => {
const env = parseAgentEnv(runtimeEnv);
const client = new ConvexHttpClient(env.CONVEX_URL);
// Flue receives this controlled id from the issue card that starts the run.
const issueId = issueAgentId as Id<"projectIssues">;
return [
defineTool({
description:
"Read the repository, issue, and canonical project artifacts bound to this agent.",
name: "lookup_issue_context",
async run() {
return await client.query(api.agentWorkspace.get, {
issueId,
token: env.FLUE_DB_TOKEN,
});
},
}),
defineTool({
description:
"Publish the complete current content of one canonical project markdown artifact after changing it in AgentOS.",
input: v.object({
content: v.pipe(v.string(), v.maxLength(200_000)),
path: v.picklist(ARTIFACT_PATHS),
}),
name: "publish_project_artifact",
async run({ input }) {
const revision = await client.mutation(
api.agentWorkspace.updateArtifact,
{
content: input.content,
issueId,
path: input.path,
token: env.FLUE_DB_TOKEN,
}
);
return { path: input.path, revision };
},
}),
defineTool({
description:
"Report the durable issue state. Use needs-input before asking a blocking question and completed only after verification.",
input: v.object({
status: v.picklist(["working", "needs-input", "completed", "failed"]),
summary: v.pipe(v.string(), v.maxLength(4000)),
}),
name: "report_work_status",
async run({ input }) {
await client.mutation(api.agentWorkspace.setStatus, {
issueId,
status: input.status,
summary: input.summary,
token: env.FLUE_DB_TOKEN,
});
return { recorded: true, status: input.status };
},
}),
];
};
-368
View File
@@ -1,368 +0,0 @@
import { parseAgentEnv } from "@code/env/agent";
import { ProjectIssueDispatchInput } from "@code/primitives/project-issue";
import { defineTool, dispatch } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server";
import { Schema } from "effect";
import * as v from "valibot";
// ---------------------------------------------------------------------------
// Agent-gated Convex function references for the routing loop.
// ---------------------------------------------------------------------------
const listEvidenceRef = makeFunctionReference<
"query",
{ readonly organizationId: string; readonly token: string },
{
messageId: string;
rawText: string;
createdAt: number;
submissionId: string | null;
}[]
>("signalRouting:listEvidence");
const createSignalRef = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly projectId?: string;
messageIds: string[];
readonly problemStatement: {
readonly title: string;
readonly summary: string;
readonly desiredOutcome: string;
constraints: string[];
};
readonly processedByAgentInstanceId: string;
readonly token: string;
},
{ signalId: string }
>("signalRouting:createSignal");
const listSignalsRef = makeFunctionReference<
"query",
{
readonly organizationId: string;
readonly projectId?: string;
readonly token: string;
},
{
_id: string;
createdAt: number;
problemStatement: {
title: string;
summary: string;
desiredOutcome: string;
constraints: string[];
};
projectId: string | null;
}[]
>("signalRouting:listSignals");
const listActiveIssuesRef = makeFunctionReference<
"query",
{
readonly organizationId: string;
readonly projectId: string;
readonly token: string;
},
{
_id: string;
number: number;
title: string;
body: string;
status: string;
projectId: string;
updatedAt: number;
}[]
>("signalRouting:listActiveIssues");
const attachSignalToIssueRef = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly signalId: string;
readonly issueId: string;
readonly token: string;
},
{ attachmentId: string; alreadyAttached: boolean }
>("signalRouting:attachSignalToIssue");
const createIssueFromSignalRef = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly signalId: string;
readonly token: string;
},
{ issueId: string; projectId: string }
>("signalRouting:createIssueFromSignal");
const beginIssueRef = makeFunctionReference<
"mutation",
{
readonly organizationId: string;
readonly issueId: string;
readonly token: string;
},
{
readonly dispatchRequired: boolean;
readonly projectId: string;
readonly status: "queued" | "working";
}
>("signalRouting:beginIssue");
const markDispatchFailedRef = makeFunctionReference<
"mutation",
{
readonly error: string;
readonly issueId: string;
readonly organizationId: string;
readonly token: string;
},
null
>("signalRouting:markDispatchFailed");
const getProjectContextRef = makeFunctionReference<
"query",
{
readonly organizationId: string;
readonly projectId: string;
readonly token: string;
},
{
project: {
_id: string;
name: string;
description: string | null;
organizationId: string;
};
contextDocuments: {
kind: string;
path: string;
content: string;
revision: number;
}[];
}
>("signalRouting:getProjectContext");
const listProjectsRef = makeFunctionReference<
"query",
{
readonly organizationId: string;
readonly token: string;
},
{
_id: string;
name: string;
description: string | null;
organizationId: string;
}[]
>("signalRouting:listProjects");
// ---------------------------------------------------------------------------
// Tool factory: creates all routing tools bound to one agent instance.
//
// The `instanceId` is the organization ID (established by the authenticated
// route middleware). The tool never accepts organization ID as free input;
// it always comes from the bound instance. This is the hard tenancy boundary.
// ---------------------------------------------------------------------------
export const createSignalRoutingTools = (
instanceId: string,
runtimeEnv: Record<string, string | undefined>
) => {
const env = parseAgentEnv(runtimeEnv);
const client = new ConvexHttpClient(env.CONVEX_URL);
const token = env.FLUE_DB_TOKEN;
// The instance id is the organization id; tools are scoped to it.
const organizationId = instanceId;
return [
defineTool({
description:
"List projects in the current organization. Call this first to identify the project context the user is working in.",
name: "list_projects",
async run() {
return await client.query(listProjectsRef, {
organizationId,
token,
});
},
}),
defineTool({
description:
"Read the project name, description, and canonical context documents (README, product, design, tech, agents). Use this to understand the project before routing work.",
input: v.object({
projectId: v.string(),
}),
name: "get_project_context",
async run({ input }) {
return await client.query(getProjectContextRef, {
organizationId,
projectId: input.projectId,
token,
});
},
}),
defineTool({
description:
"List admitted user messages in the current conversation that have not yet been consumed by a Signal. These are the candidate evidence messages. Raw text is exact — never modify it.",
name: "list_signal_evidence",
async run() {
return await client.query(listEvidenceRef, {
organizationId,
token,
});
},
}),
defineTool({
description:
"Create a Signal from one or more admitted user messages plus a structured problem statement. Use ONLY when the conversation contains an actionable problem, request, blocker, or decision. Do NOT create Signals for casual chat, greetings, or exploration without a concrete problem. The problemStatement must faithfully represent the user's own words — do not invent or rewrite their intent.",
input: v.object({
messageIds: v.array(v.string()),
problemStatement: v.object({
constraints: v.array(v.string()),
desiredOutcome: v.string(),
summary: v.string(),
title: v.string(),
}),
projectId: v.optional(v.string()),
}),
name: "create_signal",
async run({ input }) {
return await client.mutation(createSignalRef, {
messageIds: input.messageIds,
organizationId,
problemStatement: input.problemStatement,
...(input.projectId === undefined
? {}
: { projectId: input.projectId }),
processedByAgentInstanceId: organizationId,
token,
});
},
}),
defineTool({
description:
"List recent Signals for a project (or organization-wide if no projectId). Use this to see what has already been captured.",
input: v.object({
projectId: v.optional(v.string()),
}),
name: "list_recent_signals",
async run({ input }) {
return await client.query(listSignalsRef, {
organizationId,
...(input.projectId === undefined
? {}
: { projectId: input.projectId }),
token,
});
},
}),
defineTool({
description:
"List active (open, queued, working, needs-input) ProjectIssues for a project. Use this to find existing issues that a new Signal might relate to before deciding whether to attach or create a new one.",
input: v.object({
projectId: v.string(),
}),
name: "list_active_issues",
async run({ input }) {
return await client.query(listActiveIssuesRef, {
organizationId,
projectId: input.projectId,
token,
});
},
}),
defineTool({
description:
"Attach a Signal to an existing ProjectIssue. This links the signal's evidence to an open issue. Idempotent: repeating the same attachment is safe and returns the existing relation. Use when the signal's problem clearly relates to an existing active issue.",
input: v.object({
issueId: v.string(),
signalId: v.string(),
}),
name: "attach_signal_to_issue",
async run({ input }) {
return await client.mutation(attachSignalToIssueRef, {
issueId: input.issueId,
organizationId,
signalId: input.signalId,
token,
});
},
}),
defineTool({
description:
"Create a new ProjectIssue from a Signal. The signal must be project-scoped. This also auto-attaches the signal to the new issue. Use when the signal's problem does not match any existing active issue.",
input: v.object({
signalId: v.string(),
}),
name: "create_issue_from_signal",
async run({ input }) {
return await client.mutation(createIssueFromSignalRef, {
organizationId,
signalId: input.signalId,
token,
});
},
}),
defineTool({
description:
"Begin working on a ProjectIssue by transitioning it to queued. Use only after the user explicitly confirms they want to start the issue. Returns the new status.",
input: v.object({
issueId: v.string(),
}),
name: "begin_issue",
async run({ input }) {
const outcome = await client.mutation(beginIssueRef, {
issueId: input.issueId,
organizationId,
token,
});
const dispatchInput = Schema.decodeUnknownSync(
ProjectIssueDispatchInput
)({
issueId: input.issueId,
kind: "project.issue.started",
projectId: outcome.projectId,
});
if (!outcome.dispatchRequired) {
return {
acceptedAt: "",
dispatchId: "",
status: outcome.status,
};
}
try {
const receipt = await dispatch({
agent: "project-manager",
id: input.issueId,
input: dispatchInput,
});
return {
acceptedAt: receipt.acceptedAt,
dispatchId: receipt.dispatchId,
status: outcome.status,
};
} catch (error) {
await client.mutation(markDispatchFailedRef, {
error: error instanceof Error ? error.message : String(error),
issueId: input.issueId,
organizationId,
token,
});
throw error;
}
},
}),
];
};
-37
View File
@@ -1,37 +0,0 @@
import { defineTool } from "@flue/runtime";
import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from "convex/server";
import * as v from "valibot";
// The control-plane mutation that enqueues a work run for an issue. Implemented
// in the backend Convex workflow module; referenced by name so the agent never
// imports generated bindings.
const startIssueWorkRef = makeFunctionReference<
"mutation",
{ readonly issueNumber: number; readonly token: string },
{ readonly runId: string; readonly status: string }
>("workflows:startIssueWork");
export const createWorkflowTools = (clientOptions: {
readonly convexUrl: string;
readonly token: string;
}) => {
const client = new ConvexHttpClient(clientOptions.convexUrl);
return [
defineTool({
description:
"Start the autonomous work workflow for an existing zopu-code issue. This spawns a Codex agent in an isolated worktree of the canonical repo, gives it the issue, lets it implement and verify the change, then commits and opens a pull request. Use it after create_issue (or for an existing issue number) once the user confirms they want work to begin. Returns the run id and initial status.",
input: v.object({
issueNumber: v.number(),
}),
name: "start_workflow",
async run({ input }) {
return await client.mutation(startIssueWorkRef, {
issueNumber: input.issueNumber,
token: clientOptions.token,
});
},
}),
];
};