feat: ship agent onboarding runtime
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "@flue/runtime/config";
|
||||
|
||||
// Flue 2.0 project configuration.
|
||||
// - target: 'node' builds a long-running Node HTTP server.
|
||||
// - app: the route map (src/app.ts) — health + private internal routes + agent router.
|
||||
// - db: file-backed SQLite persistence (src/db.ts) for canonical conversation state.
|
||||
// - agents: scoped to src/agents/ for the 'use agent' scan.
|
||||
export default defineConfig({
|
||||
agents: "agents/**/*.ts",
|
||||
app: "src/app.ts",
|
||||
db: "src/db.ts",
|
||||
// Custom providers are registered at runtime via setProvider() in app.ts.
|
||||
// Empty list = register none from the built-in catalog; the Cheaptricks
|
||||
// provider is wired programmatically.
|
||||
providers: [],
|
||||
target: "node",
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@code/agents",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check-types": "tsc --noEmit",
|
||||
"start": "node dist/server.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentos-software/common": "catalog:",
|
||||
"@agentos-software/git": "catalog:",
|
||||
"@code/env": "workspace:*",
|
||||
"@earendil-works/pi-ai": "catalog:",
|
||||
"@flue/runtime": "catalog:",
|
||||
"@flue/sdk": "catalog:",
|
||||
"@hono/node-server": "catalog:",
|
||||
"@rivet-dev/agentos": "catalog:",
|
||||
"convex": "catalog:",
|
||||
"hono": "catalog:",
|
||||
"rivetkit": "catalog:",
|
||||
"valibot": "^1.0.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@code/config": "workspace:*",
|
||||
"@flue/cli": "catalog:",
|
||||
"@flue/vite": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { env } from "@code/env/agent";
|
||||
import type { CodeExecutionResult } from "@rivet-dev/agentos";
|
||||
import { createClient } from "@rivet-dev/agentos/client";
|
||||
|
||||
import type { registry } from "../runner";
|
||||
|
||||
// AgentOS adapter: resolves one project workspace actor keyed structurally
|
||||
// by projectId, and exposes only safe read-oriented operations for the
|
||||
// onboarding runtime endpoint and the conversation agent's tools.
|
||||
//
|
||||
// The adapter talks to the durable AgentOS RivetKit actor registered in this
|
||||
// same process (src/runner.ts, served in serverless mode via the Hono app).
|
||||
// It caches only the lightweight actor client, never VM handles — so it
|
||||
// survives engine restarts and process recycling. Each operation re-resolves
|
||||
// the actor handle via getOrCreate + resolve, which is stateless and safe
|
||||
// across lifecycle boundaries.
|
||||
//
|
||||
// It does NOT use Pi for onboarding — Pi capability is preserved only for
|
||||
// later GLM-5.2 work, and this module never invokes it.
|
||||
|
||||
/**
|
||||
* Repository path inside a project VM.
|
||||
* All repos clone to /workspace/repo inside the VM.
|
||||
*/
|
||||
export const REPO_PATH = "/workspace/repo";
|
||||
|
||||
export interface ProjectVm {
|
||||
vmId: string;
|
||||
workspacePath: string;
|
||||
repositoryPath: string;
|
||||
}
|
||||
|
||||
export interface DirListEntry {
|
||||
name: string;
|
||||
kind: "file" | "directory" | "symlink";
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface RepoDescription {
|
||||
commit: string;
|
||||
rootEntries: DirListEntry[];
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
path: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safe-command policy for execSafe.
|
||||
// Only static inspected command names are permitted; shell strings are
|
||||
// rejected. Arguments are passed as an array (no shell interpolation).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ALLOWED_COMMANDS: ReadonlyMap<string, readonly string[]> = new Map([
|
||||
// Git read-only.
|
||||
[
|
||||
"git",
|
||||
[
|
||||
"rev-parse",
|
||||
"log",
|
||||
"status",
|
||||
"ls-files",
|
||||
"remote",
|
||||
"branch",
|
||||
"describe",
|
||||
"show",
|
||||
],
|
||||
],
|
||||
// Text inspection.
|
||||
["grep", ["-r", "-rn", "-i", "--include", "-l"]],
|
||||
["find", ["-name", "-type", "-maxdepth", "-mindepth"]],
|
||||
["wc", ["-l", "-w", "-c"]],
|
||||
["head", ["-n", "-c"]],
|
||||
["tail", ["-n", "-c"]],
|
||||
["cat", []],
|
||||
["ls", ["-la", "-l", "-a"]],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate that a command is statically permitted and that its args are a
|
||||
* safe list (no shell metacharacters, no strings). This is the only gate
|
||||
* between the model and process execution.
|
||||
*/
|
||||
export const validateSafeCommand = (
|
||||
command: string,
|
||||
args: readonly string[]
|
||||
): void => {
|
||||
if (typeof command !== "string" || command.length === 0) {
|
||||
throw new Error("execSafe requires a non-empty command name");
|
||||
}
|
||||
// Reject anything that looks like a shell string (spaces, pipes, redirects).
|
||||
if (/\s|[;&|<>`$()]/u.test(command)) {
|
||||
throw new Error(`execSafe rejects shell expression: ${command}`);
|
||||
}
|
||||
// Resolve the command to its base name (no path traversal through /bin/...).
|
||||
const base = path.basename(command);
|
||||
const allowedFlags = ALLOWED_COMMANDS.get(base);
|
||||
if (!allowedFlags) {
|
||||
throw new Error(`execSafe command not permitted: ${base}`);
|
||||
}
|
||||
for (const arg of args) {
|
||||
if (typeof arg !== "string") {
|
||||
throw new TypeError("execSafe args must all be strings");
|
||||
}
|
||||
// Reject shell metacharacters in arguments.
|
||||
if (/[;&|<>`$()]/u.test(arg)) {
|
||||
throw new Error(`execSafe arg contains shell metacharacters: ${arg}`);
|
||||
}
|
||||
// Flags must be in the allowlist (starts with -). Non-flag args (paths,
|
||||
// patterns, numbers) pass through — they cannot invoke a shell.
|
||||
if (
|
||||
arg.startsWith("-") &&
|
||||
!allowedFlags.includes(arg) &&
|
||||
!arg.startsWith("--include=")
|
||||
) {
|
||||
throw new Error(`execSafe flag not permitted for ${base}: ${arg}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Prevent path traversal: resolve a path against the repository root and
|
||||
* ensure the result stays inside it. Rejects absolute paths escaping the
|
||||
* root and ../ sequences.
|
||||
*/
|
||||
export const safeRepoPath = (
|
||||
repoRoot: string,
|
||||
relativePath: string
|
||||
): string => {
|
||||
// Normalize and strip leading slashes so "/etc/passwd" can't escape.
|
||||
const cleaned = relativePath.replace(/^\/+/u, "");
|
||||
const resolved = path.resolve(repoRoot, cleaned);
|
||||
const normalizedRoot = path.resolve(repoRoot);
|
||||
if (
|
||||
resolved !== normalizedRoot &&
|
||||
!resolved.startsWith(normalizedRoot + path.sep)
|
||||
) {
|
||||
throw new Error(
|
||||
`Path traversal rejected: ${relativePath} escapes repository root`
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actor client.
|
||||
//
|
||||
// The client is the only durable handle the adapter caches. Actor (VM)
|
||||
// handles are resolved per-operation through getOrCreate + resolve, so the
|
||||
// adapter is stateless with respect to VM lifecycle and survives process
|
||||
// recycling.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The public actor client surface used by this adapter. The actor handle is
|
||||
* stateless: every action resolves it against the engine as needed.
|
||||
*/
|
||||
interface ProjectWorkspaceClient {
|
||||
projectWorkspace: {
|
||||
getOrCreate: (
|
||||
key: readonly [projectId: string],
|
||||
options: { params: { token: string } }
|
||||
) => {
|
||||
resolve: () => Promise<string>;
|
||||
process: {
|
||||
execFile: (
|
||||
command: string,
|
||||
args?: readonly string[]
|
||||
) => Promise<CodeExecutionResult>;
|
||||
};
|
||||
filesystem: {
|
||||
exists: (target: string) => Promise<boolean>;
|
||||
readdirEntries: (target: string) => Promise<readonly DirStatEntry[]>;
|
||||
readFile: (target: string) => Promise<Uint8Array>;
|
||||
mkdir: (
|
||||
target: string,
|
||||
options?: { recursive?: boolean }
|
||||
) => Promise<void>;
|
||||
writeFile: (
|
||||
target: string,
|
||||
content: string | Uint8Array
|
||||
) => Promise<void>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Entry returned by filesystem.readdirEntries. */
|
||||
interface DirStatEntry {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
isSymbolicLink: boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily-created actor client. Cached for the process lifetime; the client
|
||||
* object is safe to reuse, unlike VM handles which are lifecycle-bound.
|
||||
*/
|
||||
let clientCache: ProjectWorkspaceClient | undefined;
|
||||
|
||||
const getClient = (): ProjectWorkspaceClient => {
|
||||
if (!clientCache) {
|
||||
clientCache = createClient<typeof registry>({
|
||||
disableMetadataLookup: true,
|
||||
encoding: "cbor",
|
||||
endpoint: env.RIVET_ENDPOINT,
|
||||
}) as unknown as ProjectWorkspaceClient;
|
||||
}
|
||||
return clientCache;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the durable actor handle for a project. The actor is keyed
|
||||
* structurally by [projectId] (project ids are globally unique). The handle
|
||||
* is stateless — it re-resolves across lifecycle boundaries — so it is never
|
||||
* cached.
|
||||
*/
|
||||
const getProjectHandle = (projectId: string) =>
|
||||
getClient().projectWorkspace.getOrCreate([projectId], {
|
||||
params: { token: env.RIVET_WORKSPACE_TOKEN },
|
||||
});
|
||||
|
||||
/**
|
||||
* Throw a descriptive Error when an execFile result did not succeed.
|
||||
* Surfaces the operation, outcome, and available stdout/stderr/error.
|
||||
*/
|
||||
const requireExecSuccess = (
|
||||
result: CodeExecutionResult,
|
||||
operation: string
|
||||
): void => {
|
||||
if (result.outcome !== "succeeded") {
|
||||
const detail = [result.stderr, result.stdout, result.error.message]
|
||||
.filter(
|
||||
(part): part is string => typeof part === "string" && part.length > 0
|
||||
)
|
||||
.join(" | ");
|
||||
throw new Error(
|
||||
`${operation} failed (outcome=${result.outcome})${detail ? `: ${detail}` : ""}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* AgentOS owns the actor filesystem. The repository has no host workspace
|
||||
* projection, so persist its actual in-VM path rather than an invented path.
|
||||
*/
|
||||
const workspacePath = (): string => REPO_PATH;
|
||||
|
||||
/**
|
||||
* Get or create a project workspace VM. Idempotent by projectId — the actor
|
||||
* is keyed structurally by [projectId] and resolved on demand.
|
||||
*
|
||||
* The vmId/runtimeId is the actor's resolved id, which remains stable across
|
||||
* re-resolution.
|
||||
*/
|
||||
export const getOrCreateProjectVm = async (
|
||||
projectId: string
|
||||
): Promise<ProjectVm> => {
|
||||
const handle = getProjectHandle(projectId);
|
||||
const vmId = await handle.resolve();
|
||||
return {
|
||||
repositoryPath: REPO_PATH,
|
||||
vmId,
|
||||
workspacePath: workspacePath(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the current HEAD commit. Used for readability verification and
|
||||
* ProjectRuntime.repositoryCommit.
|
||||
*/
|
||||
export const readRepositoryHead = async (
|
||||
projectId: string
|
||||
): Promise<{ commit: string }> => {
|
||||
const handle = getProjectHandle(projectId);
|
||||
const result = await handle.process.execFile("git", [
|
||||
"-C",
|
||||
REPO_PATH,
|
||||
"rev-parse",
|
||||
"HEAD",
|
||||
]);
|
||||
requireExecSuccess(result, "git rev-parse HEAD");
|
||||
return { commit: (result.stdout ?? "").trim() };
|
||||
};
|
||||
|
||||
/**
|
||||
* Depth-1 branch clone into the project VM. Idempotent: if the repository
|
||||
* directory already exists, the clone is skipped (assuming a prior successful
|
||||
* clone). Credentials are never exposed to the model.
|
||||
*/
|
||||
export const cloneRepository = async (input: {
|
||||
projectId: string;
|
||||
repositoryUrl: string;
|
||||
branch: string;
|
||||
}): Promise<{ commit: string }> => {
|
||||
const handle = getProjectHandle(input.projectId);
|
||||
|
||||
// Idempotent: skip if repo dir already exists.
|
||||
const exists = await handle.filesystem.exists(REPO_PATH).catch(() => false);
|
||||
if (!exists) {
|
||||
const result = await handle.process.execFile("git", [
|
||||
"clone",
|
||||
"--depth=1",
|
||||
"--single-branch",
|
||||
"--branch",
|
||||
input.branch,
|
||||
input.repositoryUrl,
|
||||
REPO_PATH,
|
||||
]);
|
||||
requireExecSuccess(result, "git clone");
|
||||
}
|
||||
|
||||
return readRepositoryHead(input.projectId);
|
||||
};
|
||||
|
||||
/**
|
||||
* List files in a directory relative to the repository root.
|
||||
* Path traversal is prevented by safeRepoPath().
|
||||
*/
|
||||
export const listFiles = async (
|
||||
projectId: string,
|
||||
relativePath: string
|
||||
): Promise<DirListEntry[]> => {
|
||||
const handle = getProjectHandle(projectId);
|
||||
const target = safeRepoPath(REPO_PATH, relativePath);
|
||||
const entries = await handle.filesystem.readdirEntries(target);
|
||||
return entries.map((e) => {
|
||||
let kind: DirListEntry["kind"] = "file";
|
||||
if (e.isDirectory) {
|
||||
kind = "directory";
|
||||
} else if (e.isSymbolicLink) {
|
||||
kind = "symlink";
|
||||
}
|
||||
return { kind, name: e.name, size: e.size };
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify repository readability: HEAD commit + root listing + read one file.
|
||||
*/
|
||||
export const describeRepository = async (
|
||||
projectId: string
|
||||
): Promise<RepoDescription> => {
|
||||
const { commit } = await readRepositoryHead(projectId);
|
||||
const entries = await listFiles(projectId, "");
|
||||
return { commit, rootEntries: entries };
|
||||
};
|
||||
|
||||
/**
|
||||
* Read a file relative to the repository root. Path traversal prevented.
|
||||
*/
|
||||
export const readFile = async (
|
||||
projectId: string,
|
||||
relativePath: string
|
||||
): Promise<{ content: string }> => {
|
||||
const handle = getProjectHandle(projectId);
|
||||
const target = safeRepoPath(REPO_PATH, relativePath);
|
||||
const bytes = await handle.filesystem.readFile(target);
|
||||
return { content: Buffer.from(bytes).toString("utf-8") };
|
||||
};
|
||||
|
||||
/**
|
||||
* Search repository files using grep. Returns path + snippet matches.
|
||||
* Zero matches return an empty array rather than throwing.
|
||||
*/
|
||||
export const searchRepository = async (
|
||||
projectId: string,
|
||||
query: string
|
||||
): Promise<{ matches: SearchResult[] }> => {
|
||||
if (typeof query !== "string" || query.length === 0) {
|
||||
throw new Error("searchRepository requires a non-empty query string");
|
||||
}
|
||||
// Validate exactly the args we execute.
|
||||
const args = ["-rn", "--include=*", query, REPO_PATH];
|
||||
validateSafeCommand("grep", args);
|
||||
|
||||
const handle = getProjectHandle(projectId);
|
||||
const result = await handle.process.execFile("grep", args);
|
||||
if (result.outcome !== "succeeded") {
|
||||
if (result.exitCode === 1) {
|
||||
return { matches: [] };
|
||||
}
|
||||
requireExecSuccess(result, "grep repository");
|
||||
}
|
||||
const lines = (result.stdout ?? "").split("\n").filter(Boolean).slice(0, 50);
|
||||
return {
|
||||
matches: lines.map((line) => {
|
||||
const colonIdx = line.indexOf(":");
|
||||
const secondColon = line.indexOf(":", colonIdx + 1);
|
||||
return {
|
||||
path: line.slice(0, colonIdx),
|
||||
snippet: line.slice(secondColon + 1, secondColon + 1 + 200),
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute a validated safe command in the project VM. Only statically
|
||||
* inspected commands / safe argument lists are permitted.
|
||||
*/
|
||||
export const execSafe = async (
|
||||
projectId: string,
|
||||
command: string,
|
||||
args: readonly string[]
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
validateSafeCommand(command, args);
|
||||
const handle = getProjectHandle(projectId);
|
||||
const result = await handle.process.execFile(command, [...args]);
|
||||
requireExecSuccess(result, `execSafe ${command}`);
|
||||
return { stderr: result.stderr ?? "", stdout: result.stdout ?? "" };
|
||||
};
|
||||
|
||||
/**
|
||||
* Scan root .env.example for variable names only (values are never stored
|
||||
* or displayed). Non-blocking: returns empty array if absent.
|
||||
*/
|
||||
export const scanEnvExample = async (
|
||||
projectId: string
|
||||
): Promise<{ names: string[] }> => {
|
||||
const handle = getProjectHandle(projectId);
|
||||
const envPath = safeRepoPath(REPO_PATH, ".env.example");
|
||||
const exists = await handle.filesystem.exists(envPath).catch(() => false);
|
||||
if (!exists) {
|
||||
return { names: [] };
|
||||
}
|
||||
const bytes = await handle.filesystem
|
||||
.readFile(envPath)
|
||||
.catch(() => new Uint8Array());
|
||||
const content = Buffer.from(bytes).toString("utf-8");
|
||||
const names: string[] = [];
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
const [rawName] = trimmed.split("=");
|
||||
const name = rawName?.trim() ?? "";
|
||||
if (name && /^[A-Z_][A-Z0-9_]*$/iu.test(name)) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
return { names };
|
||||
};
|
||||
|
||||
/**
|
||||
* Write a generated file under .zopu/artifacts/. This is the only write path
|
||||
* exposed — it is constrained to the .zopu output directory and never touches
|
||||
* repository source files.
|
||||
*/
|
||||
export const writeArtifactFile = async (
|
||||
projectId: string,
|
||||
buildId: string,
|
||||
filename: string,
|
||||
content: string
|
||||
): Promise<{ path: string }> => {
|
||||
const handle = getProjectHandle(projectId);
|
||||
const artifactPath = `/workspace/.zopu/artifacts/${buildId}/${filename}`;
|
||||
// Ensure parent directory exists.
|
||||
await handle.filesystem.mkdir(`/workspace/.zopu/artifacts/${buildId}`, {
|
||||
recursive: true,
|
||||
});
|
||||
await handle.filesystem.writeFile(artifactPath, content);
|
||||
return { path: artifactPath };
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
// Static HTML artifact renderer.
|
||||
// Takes a typed ArtifactManifest and produces a safe, static HTML page.
|
||||
// No user-supplied HTML is passed through — all content is escaped and
|
||||
// rendered through the kit's component templates.
|
||||
|
||||
export interface ArtifactManifestSection {
|
||||
title: string;
|
||||
body: string;
|
||||
items?: string[];
|
||||
}
|
||||
|
||||
export interface ArtifactManifest {
|
||||
kind: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
sections: ArtifactManifestSection[];
|
||||
generatedAt: string;
|
||||
projectName?: string;
|
||||
repositoryCommit?: string;
|
||||
}
|
||||
|
||||
/** Escape HTML special characters to prevent injection. */
|
||||
const escapeHtml = (text: string): string =>
|
||||
text
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
const renderSection = (section: ArtifactManifestSection): string => {
|
||||
const items = section.items
|
||||
? `<ul class="items">${section.items.map((i) => `<li>${escapeHtml(i)}</li>`).join("")}</ul>`
|
||||
: "";
|
||||
return ` <section>
|
||||
<h2>${escapeHtml(section.title)}</h2>
|
||||
<p>${escapeHtml(section.body)}</p>
|
||||
${items}
|
||||
</section>`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a typed manifest to a polished, static HTML page.
|
||||
* All content is escaped — the renderer supplies visual quality and safety.
|
||||
*/
|
||||
export const renderStaticHtml = (manifest: ArtifactManifest): string => {
|
||||
const sections = manifest.sections.map(renderSection).join("\n");
|
||||
const subtitle = manifest.subtitle
|
||||
? `<p class="subtitle">${escapeHtml(manifest.subtitle)}</p>`
|
||||
: "";
|
||||
const meta: string[] = [];
|
||||
if (manifest.projectName) {
|
||||
meta.push(`<span>Project: ${escapeHtml(manifest.projectName)}</span>`);
|
||||
}
|
||||
if (manifest.repositoryCommit) {
|
||||
meta.push(
|
||||
`<span>Commit: <code>${escapeHtml(manifest.repositoryCommit)}</code></span>`
|
||||
);
|
||||
}
|
||||
const metaHtml = meta.length
|
||||
? `<div class="meta">${meta.join(" ")}</div>`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(manifest.title)}</title>
|
||||
<style>
|
||||
:root { --bg: #0d1117; --card: #161b22; --border: #30363d; --text: #e6edf3; --muted: #8b949e; --accent: #2f81f7; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; padding: 2rem 1rem; }
|
||||
.container { max-width: 720px; margin: 0 auto; }
|
||||
header { margin-bottom: 2rem; }
|
||||
h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 0.5rem; }
|
||||
h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--accent); }
|
||||
.subtitle { color: var(--muted); font-size: 1rem; }
|
||||
.meta { display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 0.75rem; font-size: 0.875rem; color: var(--muted); }
|
||||
.meta code { background: var(--card); padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.8rem; }
|
||||
section { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; }
|
||||
section p { color: var(--text); margin-bottom: 0.75rem; }
|
||||
.items { list-style: none; }
|
||||
.items li { padding: 0.25rem 0 0.25rem 1.25rem; position: relative; color: var(--muted); }
|
||||
.items li::before { content: "\\2022"; position: absolute; left: 0; color: var(--accent); }
|
||||
footer { text-align: center; color: var(--muted); font-size: 0.75rem; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>${escapeHtml(manifest.title)}</h1>
|
||||
${subtitle}
|
||||
${metaHtml}
|
||||
</header>
|
||||
<main>
|
||||
${sections}
|
||||
</main>
|
||||
<footer>Generated by Zopu at ${escapeHtml(manifest.generatedAt)}</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
import { env } from "@code/env/agent";
|
||||
|
||||
/* eslint-disable max-classes-per-file -- service client and its error type form one domain module. */
|
||||
|
||||
/**
|
||||
* Service-authenticated Convex client for runtime → Convex callbacks.
|
||||
*
|
||||
* The agent's timeline/context tools call through this client to persist agent
|
||||
* messages, summaries, artifacts, and timeline cards. It POSTs to the
|
||||
* service-authenticated Convex Site HTTP actions exposed in
|
||||
* `packages/backend/convex/agentHttp.ts`:
|
||||
*
|
||||
* POST /api/agents/events/message
|
||||
* POST /api/agents/events/artifact
|
||||
* POST /api/agents/storage/generate-upload-url
|
||||
*
|
||||
* These actions carry the FLUE_DB_TOKEN bearer so Convex authenticates the call
|
||||
* as the intelligence runtime service (not a browser session) and enforce the
|
||||
* agent→project→organization boundary server-side. The runtime never holds
|
||||
* database credentials and never calls Convex mutations directly.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shared error type for non-OK responses from Convex service actions.
|
||||
* The Convex httpActions return `{ error }` with a status of 400/401 on
|
||||
* validation/auth failures.
|
||||
*/
|
||||
class ConvexServiceError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "ConvexServiceError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary context carried in every runtime → Convex callback. The Convex
|
||||
* `resolveBoundary` helper validates these against the conversationAgents row.
|
||||
*/
|
||||
interface BoundaryInput {
|
||||
agentId: string;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export class ConvexServiceClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly token: string;
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = env.CONVEX_SITE_URL.replace(/\/$/u, "");
|
||||
this.token = env.FLUE_DB_TOKEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST an agent-authored message to the global timeline.
|
||||
* Maps to the Convex `events:appendToolEvent` mutation via the
|
||||
* `/api/agents/events/message` Site action. Idempotent by idempotencyKey.
|
||||
*/
|
||||
async postAgentMessage(input: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
text: string;
|
||||
idempotencyKey: string;
|
||||
correlationId: string;
|
||||
type: string;
|
||||
visibility: string;
|
||||
replyToEventId?: string;
|
||||
workId?: string;
|
||||
threadId?: string;
|
||||
}): Promise<{ eventId: string }> {
|
||||
const result = await this.postJson("/api/agents/events/message", {
|
||||
agentId: input.agentId,
|
||||
correlationId: input.correlationId,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
organizationId: input.organizationId,
|
||||
projectId: input.projectId,
|
||||
replyToEventId: input.replyToEventId,
|
||||
text: input.text,
|
||||
threadId: input.threadId,
|
||||
type: input.type,
|
||||
visibility: input.visibility,
|
||||
workId: input.workId,
|
||||
});
|
||||
return { eventId: result.eventId as string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload rendered HTML (or other bytes) to Convex Storage and return the
|
||||
* storageId. The flow is:
|
||||
* 1. POST /api/agents/storage/generate-upload-url → one-time upload URL.
|
||||
* 2. PUT the bytes as a Blob to the upload URL.
|
||||
* 3. The Convex-generated storageId is the UUID in the upload URL path.
|
||||
*
|
||||
* Returns undefined if the upload could not be completed so the caller can
|
||||
* still publish an inline-only artifact.
|
||||
*/
|
||||
async uploadHtml(
|
||||
boundary: BoundaryInput,
|
||||
html: string
|
||||
): Promise<{ storageId: string } | undefined> {
|
||||
// 1. Obtain a one-time upload URL.
|
||||
const urlResult = await this.postJson(
|
||||
"/api/agents/storage/generate-upload-url",
|
||||
{
|
||||
agentId: boundary.agentId,
|
||||
organizationId: boundary.organizationId,
|
||||
projectId: boundary.projectId,
|
||||
}
|
||||
);
|
||||
const uploadUrl = urlResult.url as string | undefined;
|
||||
if (!uploadUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 2. PUT the bytes as a Blob to the upload URL.
|
||||
const blob = new Blob([html], { type: "text/html" });
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
body: blob,
|
||||
method: "POST",
|
||||
});
|
||||
if (!uploadResponse.ok) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 3. The Convex-generated storageId is in the upload URL's path
|
||||
// (/upload/<uuid>) and is echoed back in the JSON body of the PUT.
|
||||
const storageId = await uploadResponse
|
||||
.json()
|
||||
.then((body) => (body as { storageId?: string }).storageId)
|
||||
.catch(() => {
|
||||
/* malformed upload response body — fall through to URL fallback */
|
||||
});
|
||||
if (storageId) {
|
||||
return { storageId };
|
||||
}
|
||||
// Fallback: extract the UUID from the upload URL path.
|
||||
const match = /\/upload\/(?<id>[A-Za-z0-9_-]+)/u.exec(uploadUrl);
|
||||
return match?.groups?.id ? { storageId: match.groups.id } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a typed artifact revision (with an optional Convex Storage id for
|
||||
* rendered HTML) and emit the `artifact.published` timeline event. Maps to
|
||||
* the Convex `artifacts:publishRevision` mutation + `events:appendToolEvent`
|
||||
* via the `/api/agents/events/artifact` Site action.
|
||||
*
|
||||
* Note: the artifact revision requires a `createdByEventId` (the timeline
|
||||
* event that produced it). The onboarding slice publishes the message event
|
||||
* first, then uses its eventId here.
|
||||
*/
|
||||
async publishArtifact(input: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
createdByEventId: string;
|
||||
kind: string;
|
||||
logicalKey: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
status: string;
|
||||
card: unknown;
|
||||
content: unknown;
|
||||
storageId?: string;
|
||||
idempotencyKey: string;
|
||||
correlationId: string;
|
||||
workId?: string;
|
||||
threadId?: string;
|
||||
}): Promise<{ eventId: string; artifactId: string }> {
|
||||
const result = await this.postJson("/api/agents/events/artifact", {
|
||||
agentId: input.agentId,
|
||||
card: input.card,
|
||||
content: input.content,
|
||||
correlationId: input.correlationId,
|
||||
createdByEventId: input.createdByEventId,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
kind: input.kind,
|
||||
logicalKey: input.logicalKey,
|
||||
organizationId: input.organizationId,
|
||||
projectId: input.projectId,
|
||||
status: input.status,
|
||||
storageId: input.storageId,
|
||||
summary: input.summary,
|
||||
threadId: input.threadId,
|
||||
title: input.title,
|
||||
workId: input.workId,
|
||||
});
|
||||
return {
|
||||
artifactId: result.artifactId as string,
|
||||
eventId: result.eventId as string,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a project context document (e.g. the onboarding summary).
|
||||
* Published as an inline artifact revision of kind "summary" so it persists
|
||||
* durably and is recoverable across agent restarts.
|
||||
*/
|
||||
upsertProjectSummary(input: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
kind: string;
|
||||
content: string;
|
||||
correlationId: string;
|
||||
createdByEventId: string;
|
||||
idempotencyKey: string;
|
||||
}): Promise<{ eventId: string; artifactId: string }> {
|
||||
return this.publishArtifact({
|
||||
agentId: input.agentId,
|
||||
card: { kind: input.kind },
|
||||
content: { markdown: input.content },
|
||||
correlationId: input.correlationId,
|
||||
createdByEventId: input.createdByEventId,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
kind: "summary",
|
||||
logicalKey: `project:${input.projectId}:summary`,
|
||||
organizationId: input.organizationId,
|
||||
projectId: input.projectId,
|
||||
status: "ready",
|
||||
summary: input.content.slice(0, 160),
|
||||
title: "Project summary",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST JSON to a Convex Site action with the service bearer token.
|
||||
* Returns the parsed JSON body. Throws ConvexServiceError on non-2xx.
|
||||
*/
|
||||
private async postJson(
|
||||
path: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
authorization: `Bearer ${this.token}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.then((b) => (b as { error?: string }).error)
|
||||
.catch(() => {
|
||||
/* non-JSON error body — fall through to status fallback */
|
||||
});
|
||||
throw new ConvexServiceError(
|
||||
detail ?? `Convex action ${path} returned ${response.status}`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use agent";
|
||||
|
||||
import { useModel, useTool } from "@flue/runtime";
|
||||
|
||||
import { modelSpecifier } from "../model-config.ts";
|
||||
import { projectContextUpdate } from "../tools/project-context.ts";
|
||||
import {
|
||||
sandboxDescribe,
|
||||
sandboxListFiles,
|
||||
sandboxReadFile,
|
||||
sandboxSearch,
|
||||
} from "../tools/sandbox.ts";
|
||||
import {
|
||||
timelinePostMessage,
|
||||
timelinePublishArtifact,
|
||||
} from "../tools/timeline.ts";
|
||||
|
||||
/**
|
||||
* ProjectConversationAgent — one project-bound Flue 2.0 conversation agent.
|
||||
*
|
||||
* Identity is stable and structural: `conversation:<organizationId>:<projectId>:v1`.
|
||||
* The agent instance id passed to dispatch() encodes these facts:
|
||||
* conversation:<organizationId>:<projectId>
|
||||
*
|
||||
* On a `project.ready` signal it:
|
||||
* 1. Posts one idempotent short acknowledgement.
|
||||
* 2. Explores the repository via VM read-only tools.
|
||||
* 3. Stores a concise summary via projectContextUpdate.
|
||||
* 4. Renders a static setup artifact and publishes it to the timeline.
|
||||
*
|
||||
* The agent is read-oriented for the first slice — no autonomous code mutation.
|
||||
*/
|
||||
export const ProjectConversationAgent = () => {
|
||||
useModel(modelSpecifier());
|
||||
|
||||
// Timeline tools — constrained write surface to Convex (not arbitrary mutations).
|
||||
useTool(timelinePostMessage);
|
||||
useTool(timelinePublishArtifact);
|
||||
|
||||
// Project context tools — store durable project knowledge.
|
||||
useTool(projectContextUpdate);
|
||||
|
||||
// Sandbox tools — read-only repository access through the project VM.
|
||||
useTool(sandboxDescribe);
|
||||
useTool(sandboxListFiles);
|
||||
useTool(sandboxReadFile);
|
||||
useTool(sandboxSearch);
|
||||
|
||||
return `You are Zopu's project conversation agent — an effective coworker who already understands this project.
|
||||
|
||||
You are bound to exactly one project and its repository workspace VM. You have read-only access to the repository through your tools.
|
||||
|
||||
## Onboarding (project.ready)
|
||||
When you receive a \`project.ready\` signal:
|
||||
1. Acknowledge briefly (1-2 sentences) using timelinePostMessage with idempotencyKey "dispatch:<dispatchId>:message:acknowledge".
|
||||
2. Use sandboxDescribe to understand the repository structure.
|
||||
3. Use sandboxListFiles on the root to see the layout.
|
||||
4. Read key files: README.md, package.json, or equivalent entry documentation.
|
||||
5. Synthesize a concise summary and store it via projectContextUpdate (kind "summary").
|
||||
6. Render a polished setup artifact via timelinePublishArtifact with idempotencyKey "dispatch:<dispatchId>:message:onboarding-artifact".
|
||||
|
||||
## Style
|
||||
- Default to 1-3 short sentences per response.
|
||||
- Be specific and evidence-backed — cite file paths.
|
||||
- Do not claim code was changed, tested, or shipped when it was not.
|
||||
- For writing requests: inspect, scope, and explain — do not falsely claim completion.
|
||||
- Keep internal exploration invisible; post only meaningful results to the timeline.
|
||||
|
||||
## Constraints
|
||||
- You may NOT mutate repository source files. Output only goes under .zopu/.
|
||||
- You must NOT expose API keys, credentials, or secrets.
|
||||
- You must NOT access paths outside the repository root.`;
|
||||
};
|
||||
|
||||
// Durable identity — decoupled from the source-level function name so
|
||||
// minification or renames cannot corrupt conversation storage.
|
||||
ProjectConversationAgent.agentName = "project-conversation";
|
||||
@@ -0,0 +1,253 @@
|
||||
import { env } from "@code/env/agent";
|
||||
import { dispatch } from "@flue/runtime";
|
||||
import { createAgentRouter } from "@flue/runtime/routing";
|
||||
import { Hono } from "hono";
|
||||
|
||||
import {
|
||||
cloneRepository,
|
||||
getOrCreateProjectVm,
|
||||
describeRepository,
|
||||
scanEnvExample,
|
||||
} from "./adapters/agentos.ts";
|
||||
import { ProjectConversationAgent } from "./agents/project-agent.ts";
|
||||
import { registerModelProvider } from "./model-config.ts";
|
||||
import { registry } from "./runner.ts";
|
||||
|
||||
// Register the Cheaptricks OpenAI-compatible provider before the server
|
||||
// starts handling requests. The API key is resolved per-request inside the
|
||||
// provider's auth resolver and is never exposed to model tools.
|
||||
registerModelProvider();
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// --- Health (unauthenticated liveness) --------------------------------------
|
||||
app.get("/health", (c) => c.json({ service: "zopu-agents", status: "ok" }));
|
||||
|
||||
// --- Auth -------------------------------------------------------------------
|
||||
|
||||
/** Validate the bearer token against FLUE_DB_TOKEN. */
|
||||
const requireServiceAuth = (authHeader: string | undefined): boolean => {
|
||||
if (!authHeader) {
|
||||
return false;
|
||||
}
|
||||
const match = /^Bearer\s+(?<token>.+)$/u.exec(authHeader);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
return match.groups?.token === env.FLUE_DB_TOKEN;
|
||||
};
|
||||
|
||||
// --- Internal project-setup endpoint ----------------------------------------
|
||||
//
|
||||
// Called by the Convex `projectSetup.runSetup` coordinator to perform the
|
||||
// AgentOS VM get-or-create, shallow clone, readability verification, and root
|
||||
// `.env.example` name-only scan. Returns exactly the coordinator contract.
|
||||
//
|
||||
// Convex request body (callSetupEndpoint in projectSetup.ts):
|
||||
// { branch, projectId, repositoryUrl }
|
||||
//
|
||||
// Coordinator-expected response (SetupRuntimeResult):
|
||||
// { runtimeId?, vmId?, workspacePath?, repositoryPath?,
|
||||
// repositoryCommit?, environmentVariableNames? }
|
||||
interface ProjectSetupRequest {
|
||||
branch: string;
|
||||
projectId: string;
|
||||
repositoryUrl: string;
|
||||
}
|
||||
|
||||
export interface ProjectSetupResult {
|
||||
runtimeId?: string;
|
||||
vmId?: string;
|
||||
workspacePath?: string;
|
||||
repositoryPath?: string;
|
||||
repositoryCommit?: string;
|
||||
environmentVariableNames?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /internal/project-setup
|
||||
*
|
||||
* Single-call setup coordinator target. Performs:
|
||||
* 1. Get-or-create the project workspace VM (idempotent by projectId).
|
||||
* 2. Shallow single-branch clone (idempotent — skips if repo dir exists).
|
||||
* 3. Verify readability: HEAD commit + root listing + read one file.
|
||||
* 4. Scan root `.env.example` for variable names only (values never stored).
|
||||
*
|
||||
* Never mutates repository source files. The returned object matches the
|
||||
* Convex `SetupRuntimeResult` wire shape exactly.
|
||||
*/
|
||||
app.post("/internal/project-setup", async (c) => {
|
||||
if (!requireServiceAuth(c.req.header("authorization"))) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
|
||||
let body: ProjectSetupRequest;
|
||||
try {
|
||||
body = (await c.req.json()) as ProjectSetupRequest;
|
||||
} catch {
|
||||
return c.json({ error: "invalid JSON body" }, 400);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof body.projectId !== "string" ||
|
||||
body.projectId.length === 0 ||
|
||||
typeof body.repositoryUrl !== "string" ||
|
||||
body.repositoryUrl.length === 0 ||
|
||||
typeof body.branch !== "string" ||
|
||||
body.branch.length === 0
|
||||
) {
|
||||
return c.json({ error: "invalid project-setup request" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const vm = await getOrCreateProjectVm(body.projectId);
|
||||
|
||||
// Shallow clone (idempotent). Credentials are never exposed.
|
||||
const { commit } = await cloneRepository({
|
||||
branch: body.branch,
|
||||
projectId: body.projectId,
|
||||
repositoryUrl: body.repositoryUrl,
|
||||
});
|
||||
|
||||
// Verify readability: HEAD commit + root listing (throws on failure).
|
||||
await describeRepository(body.projectId);
|
||||
|
||||
// Scan root .env.example names-only.
|
||||
const { names } = await scanEnvExample(body.projectId);
|
||||
|
||||
const result: ProjectSetupResult = {
|
||||
environmentVariableNames: names,
|
||||
repositoryCommit: commit,
|
||||
repositoryPath: vm.repositoryPath,
|
||||
runtimeId: vm.vmId,
|
||||
vmId: vm.vmId,
|
||||
workspacePath: vm.workspacePath,
|
||||
};
|
||||
return c.json(result, 200);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return c.json({ detail: message, error: "project-setup failed" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Private dispatch endpoint -----------------------------------------------
|
||||
//
|
||||
// Convex calls this with a bearer FLUE_DB_TOKEN to deliver an event to a
|
||||
// project-bound conversation agent. The endpoint validates the envelope,
|
||||
// calls Flue dispatch(), and returns 202 Accepted.
|
||||
//
|
||||
// Idempotency: the dispatchId is encoded in the signal's idempotencyKey so
|
||||
// a retried Convex workflow step converges on the original submission.
|
||||
|
||||
export interface DispatchEnvelope {
|
||||
dispatchId: string;
|
||||
// The triggering event. Convex sends sourceEventId separately for
|
||||
// correlation; sourceEvent carries the event type + payload the agent acts on.
|
||||
sourceEvent: {
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
// The Convex events row id of the source event (e.g. the project.ready id).
|
||||
// Used for reply correlation; optional because some dispatches may be
|
||||
// ad-hoc.
|
||||
sourceEventId?: string;
|
||||
agentId: string;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
// Correlation id tying all setup/dispatch events together.
|
||||
correlationId?: string;
|
||||
workId?: string;
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
app.post("/internal/agents/:agentId/events", async (c) => {
|
||||
// Bearer auth.
|
||||
if (!requireServiceAuth(c.req.header("authorization"))) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const routeAgentId = c.req.param("agentId");
|
||||
let body: DispatchEnvelope;
|
||||
try {
|
||||
body = (await c.req.json()) as DispatchEnvelope;
|
||||
} catch {
|
||||
return c.json({ error: "invalid JSON body" }, 400);
|
||||
}
|
||||
|
||||
// Validate the dispatch envelope.
|
||||
if (
|
||||
typeof body.dispatchId !== "string" ||
|
||||
typeof body.agentId !== "string" ||
|
||||
typeof body.organizationId !== "string" ||
|
||||
typeof body.projectId !== "string" ||
|
||||
!body.sourceEvent ||
|
||||
typeof body.sourceEvent.type !== "string"
|
||||
) {
|
||||
return c.json({ error: "invalid dispatch envelope" }, 400);
|
||||
}
|
||||
|
||||
// The route agentId must match the envelope agentId.
|
||||
if (routeAgentId !== body.agentId) {
|
||||
return c.json({ error: "agentId mismatch" }, 400);
|
||||
}
|
||||
|
||||
// Build the stable conversation instance id. Must match
|
||||
// conversationAgents.register: `conversation:<org>:<project>:v1`.
|
||||
// The envelope agentId is already in that form.
|
||||
const conversationId = body.agentId;
|
||||
|
||||
// Dispatch into Flue as a signal carrying the source event.
|
||||
// The signal's attributes carry dispatch metadata so the agent's tools
|
||||
// can resolve project/org/dispatch context during the turn.
|
||||
try {
|
||||
await dispatch(ProjectConversationAgent, {
|
||||
id: conversationId,
|
||||
idempotencyKey: `event:${body.dispatchId}:agent:${body.agentId}:handler:v1`,
|
||||
message: {
|
||||
attributes: {
|
||||
agentId: body.agentId,
|
||||
correlationId: body.correlationId ?? "",
|
||||
dispatchId: body.dispatchId,
|
||||
organizationId: body.organizationId,
|
||||
projectId: body.projectId,
|
||||
sourceEventId: body.sourceEventId ?? "",
|
||||
threadId: body.threadId ?? "",
|
||||
workId: body.workId ?? "",
|
||||
},
|
||||
body: JSON.stringify(body.sourceEvent),
|
||||
kind: "signal",
|
||||
type: body.sourceEvent.type,
|
||||
},
|
||||
});
|
||||
return c.json(
|
||||
{ accepted: true, conversationId, dispatchId: body.dispatchId },
|
||||
202
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return c.json({ detail: message, error: "dispatch failed" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Public agent router (protected, optional) -------------------------------
|
||||
// Mounted for direct conversation access. Protected by the same bearer auth.
|
||||
// The dispatch-only path above is the primary entry for Convex → agent.
|
||||
const agentSubApp = createAgentRouter(ProjectConversationAgent);
|
||||
const protectedAgent = new Hono();
|
||||
protectedAgent.use("*", async (c, next) => {
|
||||
if (!requireServiceAuth(c.req.header("authorization"))) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
return await next();
|
||||
});
|
||||
protectedAgent.route("/", agentSubApp as unknown as Hono);
|
||||
app.route("/agents/project-conversation", protectedAgent);
|
||||
|
||||
// --- In-process RivetKit registry (serverless handler) -----------------------
|
||||
//
|
||||
// The AgentOS registry runs in serverless mode inside this same process.
|
||||
// Mounted on a private path so only internal callers (and the Engine via
|
||||
// configurePool.url) reach it. registry.handler drives the per-request
|
||||
// serverless runtime; importing the registry object performs no start.
|
||||
app.all("/internal/rivet/*", (c) => registry.handler(c.req.raw));
|
||||
export default app;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { sqlite } from "@flue/runtime/node";
|
||||
|
||||
// Flue 2.0 file-backed SQLite persistence adapter.
|
||||
// Stores canonical conversation state and accepted submissions for the
|
||||
// project-bound conversation agent. Application product truth remains Convex.
|
||||
//
|
||||
// Default path: /srv/zopu/data/flue/flue.db (overridable via FLUE_DB_PATH).
|
||||
export default sqlite(
|
||||
process.env.FLUE_DB_PATH ?? "/srv/zopu/data/flue/flue.db"
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
// @code/agents — Zopu Flue 2.0 + Hono + AgentOS intelligence runtime.
|
||||
//
|
||||
// The Flue 2.0 Node target generates dist/server.mjs via @flue/vite. That
|
||||
// generated entry calls startFlueNodeServer(), which loads src/app.ts (the
|
||||
// Hono route map) and src/db.ts (SQLite persistence) internally.
|
||||
//
|
||||
// This package's public exports are the reusable pieces: the conversation
|
||||
// agent definition and the model configuration utilities. The server entry
|
||||
// is dist/server.mjs (generated, not hand-authored).
|
||||
export { ProjectConversationAgent } from "./agents/project-agent.ts";
|
||||
export { registerModelProvider, modelSpecifier } from "./model-config.ts";
|
||||
export type {
|
||||
ArtifactManifest,
|
||||
ArtifactManifestSection,
|
||||
} from "./adapters/artifact-renderer.ts";
|
||||
export type { DispatchEnvelope, ProjectSetupResult } from "./app.ts";
|
||||
@@ -0,0 +1,58 @@
|
||||
import { env } from "@code/env/agent";
|
||||
import { createProvider } from "@earendil-works/pi-ai";
|
||||
import type { Provider } from "@earendil-works/pi-ai";
|
||||
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
||||
import { setProvider } from "@flue/runtime";
|
||||
|
||||
/**
|
||||
* Register the Cheaptricks OpenAI-compatible provider with the Flue runtime.
|
||||
*
|
||||
* The provider is keyed by env.AGENT_MODEL_PROVIDER (default "cheaptricks"),
|
||||
* exposing the model named by env.AGENT_MODEL_NAME (default "mimo-v2.5") over
|
||||
* the OpenAI Completions API against env.AGENT_MODEL_BASE_URL.
|
||||
*
|
||||
* The API key is resolved at request time from env.AGENT_MODEL_API_KEY and is
|
||||
* never exposed to model-facing tools — it lives only in the provider's auth
|
||||
* resolver, not in agent instructions, tool inputs, or conversation state.
|
||||
*/
|
||||
export const registerModelProvider = (): Provider<"openai-completions"> => {
|
||||
const provider = createProvider({
|
||||
api: openAICompletionsApi(),
|
||||
auth: {
|
||||
apiKey: {
|
||||
name: "Cheaptricks API key",
|
||||
resolve: () =>
|
||||
Promise.resolve({
|
||||
auth: {
|
||||
apiKey: env.AGENT_MODEL_API_KEY,
|
||||
baseUrl: env.AGENT_MODEL_BASE_URL,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
baseUrl: env.AGENT_MODEL_BASE_URL,
|
||||
id: env.AGENT_MODEL_PROVIDER,
|
||||
models: [
|
||||
{
|
||||
api: "openai-completions",
|
||||
baseUrl: env.AGENT_MODEL_BASE_URL,
|
||||
contextWindow: env.AGENT_MODEL_CONTEXT_WINDOW,
|
||||
cost: { cacheRead: 0, cacheWrite: 0, input: 0, output: 0 },
|
||||
id: env.AGENT_MODEL_NAME,
|
||||
input: ["text"],
|
||||
maxTokens: env.AGENT_MODEL_MAX_TOKENS,
|
||||
name: env.AGENT_MODEL_NAME,
|
||||
provider: env.AGENT_MODEL_PROVIDER,
|
||||
reasoning: false,
|
||||
},
|
||||
],
|
||||
name: "Cheaptricks",
|
||||
});
|
||||
|
||||
setProvider(provider);
|
||||
return provider;
|
||||
};
|
||||
|
||||
/** Full model specifier string for useModel(): provider-id/model-id. */
|
||||
export const modelSpecifier = (): string =>
|
||||
`${env.AGENT_MODEL_PROVIDER}/${env.AGENT_MODEL_NAME}`;
|
||||
@@ -0,0 +1,65 @@
|
||||
import git from "@agentos-software/git";
|
||||
import { env } from "@code/env/agent";
|
||||
import { agentOS, setup } from "@rivet-dev/agentos";
|
||||
|
||||
/**
|
||||
* AgentOS registry — owns the durable project workspace actor.
|
||||
*
|
||||
* The actor is keyed structurally by [projectId] (project ids are globally
|
||||
* unique), so each project resolves to one dedicated VM across the process's
|
||||
* lifetime. The registry runs in serverless runtime mode and is served
|
||||
* in-process by the Hono app (see app.ts), which mounts
|
||||
* `registry.handler(c.req.raw)` at `/internal/rivet/*`. There is no
|
||||
* standalone runner process and no `startAndWait` entry point.
|
||||
*
|
||||
* `configurePool.url` is the distinct endpoint the Engine invokes to reach
|
||||
* this registry handler; it is NOT the Engine control-plane URL (that stays
|
||||
* `endpoint: env.RIVET_ENDPOINT`). The actor still authenticates every
|
||||
* connection via `onBeforeConnect` against RIVET_WORKSPACE_TOKEN, so only
|
||||
* this deployment's adapter can drive the actor.
|
||||
*
|
||||
* Software: the actor bundles Git (@agentos-software/git) alongside the
|
||||
* AgentOS default utilities. defaultSoftware alone is common utilities, not
|
||||
* Git — without the explicit git entry, clone/rev-parse/grep would fail.
|
||||
*
|
||||
* Import-safe: this module exports a pure registry object and performs no
|
||||
* top-level process start. Importing it (including `import type`) does not
|
||||
* start a registry.
|
||||
*/
|
||||
|
||||
// Project workspace actor — one VM per project, with Git + default utilities.
|
||||
const projectWorkspace = agentOS<undefined, { token: string }>({
|
||||
defaultSoftware: true,
|
||||
onBeforeConnect: (_context, params) => {
|
||||
if (params.token !== env.RIVET_WORKSPACE_TOKEN) {
|
||||
throw new Error("Unauthorized workspace connection");
|
||||
}
|
||||
},
|
||||
// Git is required for clone, rev-parse, and read-only repository inspection.
|
||||
// defaultSoftware provides sh/coreutils/common utilities only.
|
||||
software: [git],
|
||||
});
|
||||
|
||||
// Internal mount path shared by the Hono app route and the registry's
|
||||
// serverless base path. Both MUST agree — the handler uses this base path to
|
||||
// route actor/metadata requests, and the app must mount it at the same path.
|
||||
export const RIVET_SERVERLESS_BASE_PATH = "/internal/rivet";
|
||||
|
||||
// Registry: the single actor type served by this process, in serverless mode.
|
||||
// `serverless.basePath` matches the app route mount point; `configurePool.url`
|
||||
// is the address the Engine calls back into this handler. Importing this
|
||||
// module does not start the registry — the handler is driven per-request by
|
||||
// the Hono app.
|
||||
export const registry = setup({
|
||||
configurePool: {
|
||||
url: env.RIVET_SERVERLESS_ENDPOINT,
|
||||
},
|
||||
endpoint: env.RIVET_ENDPOINT,
|
||||
logging: { level: "info" },
|
||||
serverless: {
|
||||
basePath: RIVET_SERVERLESS_BASE_PATH,
|
||||
},
|
||||
use: {
|
||||
projectWorkspace,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { defineTool, useDelivery } from "@flue/runtime";
|
||||
import * as v from "valibot";
|
||||
|
||||
import { ConvexServiceClient } from "../adapters/convex-service-client.ts";
|
||||
|
||||
let client: ConvexServiceClient | null = null;
|
||||
const getClient = (): ConvexServiceClient => {
|
||||
if (!client) {
|
||||
client = new ConvexServiceClient();
|
||||
}
|
||||
return client;
|
||||
};
|
||||
|
||||
/**
|
||||
* projectContextUpdate — store durable project knowledge (summary documents).
|
||||
*
|
||||
* Published as an inline artifact revision of kind "summary" via the Convex
|
||||
* `/api/agents/events/artifact` Site action, so it persists durably in Convex
|
||||
* and is recoverable across agent restarts. Requires a creation event (the
|
||||
* current dispatch message) as the `createdByEventId`.
|
||||
*/
|
||||
export const projectContextUpdate = defineTool({
|
||||
description:
|
||||
"Store or update a project context document (e.g. the onboarding summary). The document persists in Convex and is recoverable across agent restarts.",
|
||||
input: v.object({
|
||||
content: v.pipe(
|
||||
v.string(),
|
||||
v.description("Markdown content of the document")
|
||||
),
|
||||
kind: v.pipe(v.string(), v.description("Document kind, e.g. 'summary'")),
|
||||
}),
|
||||
name: "projectContextUpdate",
|
||||
run: async (ctx) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- useDelivery is a Flue runtime hook, not a React hook; called inside the tool's run callback per Flue tool contract.
|
||||
const delivery = useDelivery();
|
||||
if (delivery.kind !== "signal") {
|
||||
throw new Error("projectContextUpdate requires a signal delivery");
|
||||
}
|
||||
const attrs = delivery.attributes ?? {};
|
||||
const { organizationId, projectId, agentId, correlationId, sourceEventId } =
|
||||
attrs;
|
||||
if (
|
||||
!organizationId ||
|
||||
!projectId ||
|
||||
!agentId ||
|
||||
!correlationId ||
|
||||
!sourceEventId
|
||||
) {
|
||||
throw new Error("Delivery context missing source event metadata");
|
||||
}
|
||||
await getClient().upsertProjectSummary({
|
||||
agentId,
|
||||
content: ctx.data.content,
|
||||
correlationId,
|
||||
createdByEventId: sourceEventId,
|
||||
idempotencyKey: `project:${projectId}:summary:${ctx.data.kind}`,
|
||||
kind: ctx.data.kind,
|
||||
organizationId,
|
||||
projectId,
|
||||
});
|
||||
return { output: { stored: true } };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { defineTool, useDelivery } from "@flue/runtime";
|
||||
import * as v from "valibot";
|
||||
|
||||
import {
|
||||
describeRepository,
|
||||
listFiles,
|
||||
readFile,
|
||||
searchRepository,
|
||||
} from "../adapters/agentos.ts";
|
||||
|
||||
/**
|
||||
* Sandbox tools — read-only repository access through the project VM.
|
||||
* These are model-callable Flue tools that operate inside the agent's turn.
|
||||
*
|
||||
* Tool input schemas use Valibot (Flue 2.0's schema standard), not Typebox.
|
||||
*
|
||||
* The project context (projectId) is extracted from the delivery signal's
|
||||
* attributes, which carry the dispatch metadata.
|
||||
*/
|
||||
|
||||
/** Extract dispatch metadata from the current delivery signal. */
|
||||
const getDispatchMeta = (): {
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
agentId: string;
|
||||
dispatchId: string;
|
||||
correlationId: string;
|
||||
} => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- useDelivery is a Flue runtime hook, not a React hook; called inside a tool helper per Flue tool contract.
|
||||
const delivery = useDelivery();
|
||||
if (delivery.kind !== "signal") {
|
||||
throw new Error("Sandbox tools require a signal delivery");
|
||||
}
|
||||
const attrs = delivery.attributes ?? {};
|
||||
const { projectId } = attrs;
|
||||
const { organizationId } = attrs;
|
||||
const { agentId } = attrs;
|
||||
const { dispatchId } = attrs;
|
||||
const { correlationId } = attrs;
|
||||
if (
|
||||
!projectId ||
|
||||
!organizationId ||
|
||||
!agentId ||
|
||||
!dispatchId ||
|
||||
!correlationId
|
||||
) {
|
||||
throw new Error("No projectId in delivery context");
|
||||
}
|
||||
return { agentId, correlationId, dispatchId, organizationId, projectId };
|
||||
};
|
||||
|
||||
/** sandboxDescribe: report workspace/repo paths and current HEAD commit. */
|
||||
export const sandboxDescribe = defineTool({
|
||||
description:
|
||||
"Describe the project repository workspace: paths and current HEAD commit. Call this first when exploring a repository.",
|
||||
input: v.object({}),
|
||||
name: "sandboxDescribe",
|
||||
run: async () => {
|
||||
const { projectId } = getDispatchMeta();
|
||||
const desc = await describeRepository(projectId);
|
||||
const rootEntries = desc.rootEntries.map((e) => ({
|
||||
kind: e.kind,
|
||||
name: e.name,
|
||||
size: e.size,
|
||||
}));
|
||||
return {
|
||||
output: {
|
||||
commit: desc.commit,
|
||||
repositoryPath: "/workspace/repo",
|
||||
rootEntries,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/** sandboxListFiles: list directory entries relative to repo root. */
|
||||
export const sandboxListFiles = defineTool({
|
||||
description:
|
||||
"List files and directories at a path relative to the repository root.",
|
||||
input: v.object({
|
||||
path: v.pipe(
|
||||
v.string(),
|
||||
v.description(
|
||||
"Directory path relative to repository root (use '' for root)"
|
||||
)
|
||||
),
|
||||
}),
|
||||
name: "sandboxListFiles",
|
||||
run: async (ctx) => {
|
||||
const { projectId } = getDispatchMeta();
|
||||
const result = await listFiles(projectId, ctx.data.path);
|
||||
const entries = result.map((e) => ({
|
||||
kind: e.kind,
|
||||
name: e.name,
|
||||
size: e.size,
|
||||
}));
|
||||
return { output: { entries } };
|
||||
},
|
||||
});
|
||||
|
||||
/** sandboxReadFile: read a file's content relative to repo root. */
|
||||
export const sandboxReadFile = defineTool({
|
||||
description:
|
||||
"Read the text content of a file at a path relative to the repository root.",
|
||||
input: v.object({
|
||||
path: v.pipe(
|
||||
v.string(),
|
||||
v.description("File path relative to repository root")
|
||||
),
|
||||
}),
|
||||
name: "sandboxReadFile",
|
||||
run: async (ctx) => {
|
||||
const { projectId } = getDispatchMeta();
|
||||
const result = await readFile(projectId, ctx.data.path);
|
||||
// Truncate very large files to keep context bounded.
|
||||
const content =
|
||||
result.content.length > 50_000
|
||||
? `${result.content.slice(0, 50_000)}\n...[truncated]`
|
||||
: result.content;
|
||||
return { output: { content } };
|
||||
},
|
||||
});
|
||||
|
||||
/** sandboxSearch: search repository for a text query. */
|
||||
export const sandboxSearch = defineTool({
|
||||
description:
|
||||
"Search the repository for a text query. Returns matching file paths and snippets.",
|
||||
input: v.object({
|
||||
query: v.pipe(v.string(), v.description("Search query (text or pattern)")),
|
||||
}),
|
||||
name: "sandboxSearch",
|
||||
run: async (ctx) => {
|
||||
const { projectId } = getDispatchMeta();
|
||||
const result = await searchRepository(projectId, ctx.data.query);
|
||||
const matches = result.matches.map((m) => ({
|
||||
path: m.path,
|
||||
snippet: m.snippet,
|
||||
}));
|
||||
return { output: { matches } };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { defineTool, useDelivery } from "@flue/runtime";
|
||||
import * as v from "valibot";
|
||||
|
||||
import { renderStaticHtml } from "../adapters/artifact-renderer.ts";
|
||||
import { ConvexServiceClient } from "../adapters/convex-service-client.ts";
|
||||
|
||||
let client: ConvexServiceClient | null = null;
|
||||
const getClient = (): ConvexServiceClient => {
|
||||
if (!client) {
|
||||
client = new ConvexServiceClient();
|
||||
}
|
||||
return client;
|
||||
};
|
||||
|
||||
/** Extract dispatch/project context from the current delivery signal. */
|
||||
const getDispatchContext = (): {
|
||||
dispatchId: string;
|
||||
correlationId: string;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
sourceEventId: string;
|
||||
} => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- useDelivery is a Flue runtime hook, not a React hook; called inside a tool helper per Flue tool contract.
|
||||
const delivery = useDelivery();
|
||||
if (delivery.kind !== "signal") {
|
||||
throw new Error("Timeline tools require a signal delivery");
|
||||
}
|
||||
const attrs = delivery.attributes ?? {};
|
||||
const { dispatchId } = attrs;
|
||||
const { correlationId } = attrs;
|
||||
const { organizationId } = attrs;
|
||||
const { projectId } = attrs;
|
||||
const { agentId } = attrs;
|
||||
const { sourceEventId } = attrs;
|
||||
if (
|
||||
!dispatchId ||
|
||||
!correlationId ||
|
||||
!organizationId ||
|
||||
!projectId ||
|
||||
!agentId ||
|
||||
!sourceEventId
|
||||
) {
|
||||
throw new Error("Delivery context missing source event metadata");
|
||||
}
|
||||
return {
|
||||
agentId,
|
||||
correlationId,
|
||||
dispatchId,
|
||||
organizationId,
|
||||
projectId,
|
||||
sourceEventId,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* timelinePostMessage — post an agent-authored message to the global timeline.
|
||||
* The message is idempotent by the provided idempotencyKey; a retry with the
|
||||
* same key converges on the original event.
|
||||
*
|
||||
* Posts through the Convex `/api/agents/events/message` Site action, which
|
||||
* enforces the agent→project→organization boundary and exact-once semantics.
|
||||
*/
|
||||
export const timelinePostMessage = defineTool({
|
||||
description:
|
||||
"Post a short message to the project timeline. Use for acknowledgements and brief responses (1-3 sentences). Provide a unique idempotencyKey.",
|
||||
input: v.object({
|
||||
idempotencyKey: v.pipe(
|
||||
v.string(),
|
||||
v.description("Stable idempotency key for this message")
|
||||
),
|
||||
importance: v.optional(
|
||||
v.pipe(
|
||||
v.union([v.literal("normal"), v.literal("high")]),
|
||||
v.description("Message importance level (default: normal)")
|
||||
)
|
||||
),
|
||||
text: v.pipe(
|
||||
v.string(),
|
||||
v.description("The message text to post (1-3 sentences)")
|
||||
),
|
||||
}),
|
||||
name: "timelinePostMessage",
|
||||
run: async (ctx) => {
|
||||
const { correlationId, organizationId, projectId, agentId, sourceEventId } =
|
||||
getDispatchContext();
|
||||
const result = await getClient().postAgentMessage({
|
||||
agentId,
|
||||
correlationId,
|
||||
idempotencyKey: ctx.data.idempotencyKey,
|
||||
organizationId,
|
||||
projectId,
|
||||
replyToEventId: sourceEventId,
|
||||
text: ctx.data.text,
|
||||
type: "agent.message",
|
||||
visibility: "timeline",
|
||||
});
|
||||
return { output: { eventId: result.eventId } };
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* timelinePublishArtifact — render and publish a static setup artifact.
|
||||
*
|
||||
* Two-step flow matching the Convex artifact contract:
|
||||
* 1. Post an acknowledgement/creation message (the `createdByEventId`) so the
|
||||
* artifact revision is tied to a timeline event.
|
||||
* 2. Render safe static HTML, upload it to Convex Storage, and publish the
|
||||
* artifact revision referencing the uploaded storageId.
|
||||
*
|
||||
* The artifact is idempotent by logicalKey + version (server-side) and the
|
||||
* event is exact-once by idempotencyKey.
|
||||
*/
|
||||
export const timelinePublishArtifact = defineTool({
|
||||
description:
|
||||
"Render a project setup artifact from structured sections and publish it to the timeline. Provide title, subtitle, and content sections.",
|
||||
input: v.object({
|
||||
idempotencyKey: v.pipe(
|
||||
v.string(),
|
||||
v.description("Stable idempotency key for this artifact")
|
||||
),
|
||||
sections: v.array(
|
||||
v.object({
|
||||
body: v.string(),
|
||||
items: v.optional(v.array(v.string())),
|
||||
title: v.string(),
|
||||
})
|
||||
),
|
||||
subtitle: v.optional(
|
||||
v.pipe(v.string(), v.description("Artifact subtitle"))
|
||||
),
|
||||
title: v.pipe(v.string(), v.description("Artifact title")),
|
||||
}),
|
||||
name: "timelinePublishArtifact",
|
||||
run: async (ctx) => {
|
||||
const { correlationId, organizationId, projectId, agentId } =
|
||||
getDispatchContext();
|
||||
|
||||
// Step 1: create the timeline event that the artifact revision is tied to.
|
||||
const createdBy = await getClient().postAgentMessage({
|
||||
agentId,
|
||||
correlationId,
|
||||
idempotencyKey: `${ctx.data.idempotencyKey}:create`,
|
||||
organizationId,
|
||||
projectId,
|
||||
text: `Published artifact: ${ctx.data.title}`,
|
||||
type: "artifact.created",
|
||||
visibility: "compact",
|
||||
});
|
||||
|
||||
// Step 2: render + upload + publish the artifact revision.
|
||||
const manifest = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
kind: "project_setup",
|
||||
sections: ctx.data.sections,
|
||||
subtitle: ctx.data.subtitle,
|
||||
title: ctx.data.title,
|
||||
};
|
||||
const html = renderStaticHtml(manifest);
|
||||
const boundary = { agentId, organizationId, projectId };
|
||||
const uploaded = await getClient().uploadHtml(boundary, html);
|
||||
const result = await getClient().publishArtifact({
|
||||
agentId,
|
||||
card: {
|
||||
sections: ctx.data.sections,
|
||||
subtitle: ctx.data.subtitle,
|
||||
},
|
||||
content: { manifest },
|
||||
correlationId,
|
||||
createdByEventId: createdBy.eventId,
|
||||
idempotencyKey: ctx.data.idempotencyKey,
|
||||
kind: "project_setup",
|
||||
logicalKey: `project:${projectId}:setup`,
|
||||
organizationId,
|
||||
projectId,
|
||||
status: "ready",
|
||||
storageId: uploaded?.storageId,
|
||||
summary: ctx.data.subtitle ?? ctx.data.title,
|
||||
title: ctx.data.title,
|
||||
});
|
||||
return {
|
||||
output: { artifactId: result.artifactId, eventId: result.eventId },
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@code/config/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "*.d.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { flue } from "@flue/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// Flue 2.0 Node target. @flue/vite auto-discovers flue.config.ts, scans
|
||||
// 'use agent' modules, and builds dist/server.mjs. The built server listens
|
||||
// on 127.0.0.1:3000 (Caddy terminates TLS upstream at agents.zopu.puter.wtf).
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
...flue({
|
||||
providers: [],
|
||||
target: "node",
|
||||
}),
|
||||
],
|
||||
preview: {
|
||||
host: process.env.HOST ?? "127.0.0.1",
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
},
|
||||
server: {
|
||||
host: process.env.HOST ?? "127.0.0.1",
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user