15 Commits

Author SHA1 Message Date
-Puter
a907539810 flue refactor and migrations 2026-07-29 00:45:46 +05:30
-Puter
601aca73c2 pnpm migrations 2026-07-29 00:45:33 +05:30
-Puter
ffecff3857 Run fixed Zopu worktrees with Pi 2026-07-28 22:20:42 +05:30
-Puter
0d7162544b fix: run AgentOS workspaces on remote runner 2026-07-28 20:08:46 +05:30
-Puter
092a9793ea fix: resolve repository hosts outside AgentOS 2026-07-28 18:30:08 +05:30
-Puter
5ee0a8d50e fix: allow AgentOS process environment 2026-07-28 18:27:16 +05:30
-Puter
420676f2d7 fix: allow AgentOS command execution 2026-07-28 18:21:31 +05:30
-Puter
24d82e2a06 fix: allow AgentOS workspace filesystem 2026-07-28 18:18:24 +05:30
-Puter
d47fa0e96a fix: allow slow AgentOS workspace boot 2026-07-28 18:14:32 +05:30
-Puter
1e7c893985 fix: apply AgentOS runtime permissions 2026-07-28 17:49:41 +05:30
-Puter
f9ebcb4a01 fix: align AgentOS runtime packages 2026-07-28 17:40:40 +05:30
-Puter
dceaa2b417 fix: allow AgentOS outbound networking 2026-07-28 17:29:47 +05:30
-Puter
a4f121e190 chore: preserve work attempt schema order 2026-07-28 17:12:54 +05:30
-Puter
4edd456b5b feat: wire real AgentOS execution 2026-07-28 16:52:49 +05:30
-Puter
4d9f6da41b fix: install AgentOS Codex session adapter 2026-07-28 16:09:34 +05:30
28 changed files with 22512 additions and 5156 deletions

View File

@@ -17,7 +17,9 @@ DAEMON_NAME=Local MacBook
DAEMON_VERSION=0.0.0 DAEMON_VERSION=0.0.0
DAEMON_HEARTBEAT_MS=15000 DAEMON_HEARTBEAT_MS=15000
DAEMON_COMMAND_LEASE_MS=60000 DAEMON_COMMAND_LEASE_MS=60000
# RIVET_ENDPOINT=http://localhost:6420 RIVET_ENDPOINT=http://localhost:6420
RIVET_PUBLIC_ENDPOINT=http://localhost:6420
RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token
# Flue persistence adapter # Flue persistence adapter
FLUE_DB_TOKEN=replace-with-a-long-random-token FLUE_DB_TOKEN=replace-with-a-long-random-token

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
verify-deps-before-run=false

View File

@@ -5,8 +5,10 @@ import {
} from "@code/ui/components/ai-elements/conversation"; } from "@code/ui/components/ai-elements/conversation";
import { Button } from "@code/ui/components/button"; import { Button } from "@code/ui/components/button";
import { import {
AlertTriangle,
ChevronRight, ChevronRight,
Check, Check,
FileCode2,
FolderGit2, FolderGit2,
Hammer, Hammer,
LoaderCircle, LoaderCircle,
@@ -15,6 +17,7 @@ import {
ImagePlus, ImagePlus,
Play, Play,
RotateCcw, RotateCcw,
ScrollText,
Send, Send,
Sparkles, Sparkles,
Settings, Settings,
@@ -36,6 +39,28 @@ import {
type SliceWork = NonNullable<ReturnType<typeof useSliceOne>["works"]>[number]; type SliceWork = NonNullable<ReturnType<typeof useSliceOne>["works"]>[number];
const EMPTY_WORKS: readonly SliceWork[] = []; const EMPTY_WORKS: readonly SliceWork[] = [];
type SliceArtifact = NonNullable<
NonNullable<SliceWork["runs"][number]["artifacts"]>[number]
>;
interface ArtifactMetadata {
readonly changedFiles?: readonly string[];
}
const parseArtifactMetadata = (artifact: SliceArtifact): ArtifactMetadata => {
if (!artifact.metadataJson) {
return {};
}
try {
return JSON.parse(artifact.metadataJson) as ArtifactMetadata;
} catch {
return {};
}
};
const changedFilesFor = (artifact: SliceArtifact): readonly string[] =>
parseArtifactMetadata(artifact).changedFiles ?? [];
interface WorkCardProps { interface WorkCardProps {
readonly onSourceSelect: (rawText: string) => void; readonly onSourceSelect: (rawText: string) => void;
readonly work: SliceWork; readonly work: SliceWork;
@@ -96,6 +121,7 @@ const starterDesign = (work: SliceWork) => ({
const WorkCard = ({ onSourceSelect, work, slice }: WorkCardProps) => { const WorkCard = ({ onSourceSelect, work, slice }: WorkCardProps) => {
const [sourcesOpen, setSourcesOpen] = useState(false); const [sourcesOpen, setSourcesOpen] = useState(false);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const sources = work.signals.flatMap((signal) => signal.sources); const sources = work.signals.flatMap((signal) => signal.sources);
const { definition } = work; const { definition } = work;
const { design } = work; const { design } = work;
@@ -258,6 +284,22 @@ const WorkCard = ({ onSourceSelect, work, slice }: WorkCardProps) => {
<p className="font-semibold uppercase tracking-wide text-[#65713a]"> <p className="font-semibold uppercase tracking-wide text-[#65713a]">
Build Build
</p> </p>
{slice.operationError ? (
<p className="mt-2 flex items-start gap-1.5 border border-red-300 bg-red-50 px-2 py-1.5 text-red-800">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 flex-1 leading-5">
{slice.operationError.message}
</span>
<button
aria-label="Dismiss error"
className="shrink-0"
onClick={() => slice.clearOperationError()}
type="button"
>
<X className="size-3.5" />
</button>
</p>
) : null}
{latestRun ? ( {latestRun ? (
<div className="mt-1 space-y-2 text-[#626057]"> <div className="mt-1 space-y-2 text-[#626057]">
<p className="leading-5"> <p className="leading-5">
@@ -269,31 +311,75 @@ const WorkCard = ({ onSourceSelect, work, slice }: WorkCardProps) => {
latestRun.terminalClassification ?? latestRun.terminalClassification ??
"activity is still arriving"} "activity is still arriving"}
</p> </p>
{latestRun.attemptEvents?.slice(-5).map((item) => (
<p
className="border-l-2 border-[#b8c760] pl-2"
key={item._id}
>
{item.message}
</p>
))}
{latestRun.baseRevision ? ( {latestRun.baseRevision ? (
<p className="font-mono text-[10px] text-[#747168]"> <p className="font-mono text-[10px] text-[#747168]">
{latestRun.baseRevision.slice(0, 8)} {" "} {latestRun.baseRevision.slice(0, 8)} {" "}
{latestRun.candidateRevision?.slice(0, 8) ?? "working"} {latestRun.candidateRevision?.slice(0, 8) ?? "working"}
</p> </p>
) : null} ) : null}
{latestRun.artifacts?.map((artifact) => ( {latestRun.artifacts?.map((artifact) => {
<a const changedFiles = changedFilesFor(artifact);
className="block font-medium underline" return (
href={artifact.uri} <div key={artifact._id} className="space-y-1">
key={artifact._id} {artifact.uri ? (
rel="noreferrer" <a
target="_blank" className="block font-medium underline"
> href={artifact.uri}
{artifact.title} rel="noreferrer"
</a> target="_blank"
))} >
{artifact.title}
</a>
) : (
<p className="font-medium">{artifact.title}</p>
)}
{changedFiles.length > 0 ? (
<ul className="space-y-0.5">
{changedFiles.map((file) => (
<li
className="flex items-start gap-1.5 font-mono text-[11px] leading-5 text-[#747168]"
key={file}
>
<FileCode2 className="mt-0.5 size-3 shrink-0 text-[#9a985f]" />
<span className="min-w-0 break-all">{file}</span>
</li>
))}
</ul>
) : null}
</div>
);
})}
{latestRun.attemptEvents &&
latestRun.attemptEvents.length > 0 ? (
<div className="space-y-1">
{(logsOpen
? latestRun.attemptEvents
: latestRun.attemptEvents.slice(-3)
).map((item) => (
<p
className="border-l-2 border-[#b8c760] pl-2 leading-5"
key={item._id}
>
{item.message}
</p>
))}
{latestRun.attemptEvents.length > 3 ? (
<button
className="flex items-center gap-1 font-medium text-[#65713a] hover:underline"
onClick={() => setLogsOpen((open) => !open)}
type="button"
>
<ScrollText className="size-3.5" />
{logsOpen
? "Show recent activity"
: `Show full activity log (${latestRun.attemptEvents.length})`}
<ChevronRight
className={`size-3.5 transition-transform ${logsOpen ? "rotate-90" : ""}`}
/>
</button>
) : null}
</div>
) : null}
</div> </div>
) : ( ) : (
<p className="mt-1 text-[#747168]">No implementation Run yet.</p> <p className="mt-1 text-[#747168]">No implementation Run yet.</p>
@@ -303,9 +389,7 @@ const WorkCard = ({ onSourceSelect, work, slice }: WorkCardProps) => {
<Button <Button
size="sm" size="sm"
disabled={!slice.projectGitConnection} disabled={!slice.projectGitConnection}
onClick={() => onClick={() => void slice.startExecution(work._id)}
void slice.startExecution(work._id, design?.slices?.[0]?.id)
}
> >
<Play className="size-3.5" /> Run <Play className="size-3.5" /> Run
</Button> </Button>
@@ -315,11 +399,7 @@ const WorkCard = ({ onSourceSelect, work, slice }: WorkCardProps) => {
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => onClick={() =>
void slice.startSimulation( void slice.startSimulation(work._id, "success")
work._id,
"success",
design?.slices?.[0]?.id
)
} }
> >
<Play className="size-3.5" /> Simulate <Play className="size-3.5" /> Simulate
@@ -338,7 +418,8 @@ const WorkCard = ({ onSourceSelect, work, slice }: WorkCardProps) => {
<X className="size-3.5" /> Cancel <X className="size-3.5" /> Cancel
</Button> </Button>
) : null} ) : null}
{latestRun?.status === "terminal" && {latestRun?.executionKind !== "real" &&
latestRun?.status === "terminal" &&
latestRun.terminalClassification === "RetryableFailure" ? ( latestRun.terminalClassification === "RetryableFailure" ? (
<Button <Button
size="sm" size="sm"
@@ -428,6 +509,7 @@ const ConnectProject = ({ slice }: { slice: SliceOneState }) => (
</main> </main>
); );
// oxlint-disable-next-line complexity -- this page coordinates the existing mobile shell without owning domain logic.
export const SliceOnePage = () => { export const SliceOnePage = () => {
const slice = useSliceOne(); const slice = useSliceOne();
const viewportStyle = useVisualViewportStyle(); const viewportStyle = useVisualViewportStyle();
@@ -553,6 +635,22 @@ export const SliceOnePage = () => {
? `${slice.projectGitConnection.provider} · ${slice.projectGitConnection.serverUrl}` ? `${slice.projectGitConnection.provider} · ${slice.projectGitConnection.serverUrl}`
: "No Git credentials attached"} : "No Git credentials attached"}
</p> </p>
{slice.operationError ? (
<p className="mt-2 flex items-start gap-1.5 border border-red-300 bg-red-50 px-2 py-1.5 text-xs text-red-800">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 flex-1 leading-5">
{slice.operationError.message}
</span>
<button
aria-label="Dismiss error"
className="shrink-0"
onClick={() => slice.clearOperationError()}
type="button"
>
<X className="size-3.5" />
</button>
</p>
) : null}
<div className="mt-4 flex gap-2"> <div className="mt-4 flex gap-2">
<Button <Button
size="sm" size="sm"

View File

@@ -40,7 +40,9 @@ interface WorkRecord {
_id: string; _id: string;
kind: string; kind: string;
message: string; message: string;
metadataJson: string;
occurredAt: number; occurredAt: number;
sequence: number;
}[]; }[];
readonly baseRevision?: string; readonly baseRevision?: string;
readonly candidateRevision?: string; readonly candidateRevision?: string;
@@ -179,6 +181,20 @@ export const useSliceOne = () => {
const [repository, setRepository] = useState(""); const [repository, setRepository] = useState("");
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [error, setError] = useState<Error>(); const [error, setError] = useState<Error>();
const [operationError, setOperationError] = useState<Error>();
const captureOperationError =
<TArgs extends unknown[], TResult>(
operation: (...args: TArgs) => Promise<TResult>
) =>
async (...args: TArgs): Promise<TResult> => {
setOperationError(undefined);
try {
return await operation(...args);
} catch (caughtError) {
setOperationError(toError(caughtError));
throw caughtError;
}
};
const selectedProjectStillExists = projects?.some( const selectedProjectStillExists = projects?.some(
(project) => project.id === (selectedProjectId as unknown as string) (project) => project.id === (selectedProjectId as unknown as string)
); );
@@ -262,15 +278,18 @@ export const useSliceOne = () => {
| "permanent-failure" | "permanent-failure"
| "cancelled", | "cancelled",
sliceId?: string sliceId?: string
) => startSimulationMutation({ scenario, sliceId, workId }); ) =>
captureOperationError(() =>
startSimulationMutation({ scenario, sliceId, workId })
)();
const cancelSimulation = (runId: Id<"workRuns">) => const cancelSimulation = (runId: Id<"workRuns">) =>
cancelSimulationMutation({ runId }); captureOperationError(() => cancelSimulationMutation({ runId }))();
const retrySimulation = (runId: Id<"workRuns">) => const retrySimulation = (runId: Id<"workRuns">) =>
retrySimulationMutation({ runId }); captureOperationError(() => retrySimulationMutation({ runId }))();
const startExecution = (workId: Id<"works">, sliceId?: string) => const startExecution = (workId: Id<"works">, sliceId?: string) =>
startExecutionMutation({ sliceId, workId }); captureOperationError(() => startExecutionMutation({ sliceId, workId }))();
const cancelExecution = (runId: Id<"workRuns">) => const cancelExecution = (runId: Id<"workRuns">) =>
cancelExecutionMutation({ runId }); captureOperationError(() => cancelExecutionMutation({ runId }))();
const attachConnection = async (connectionId: Id<"gitConnections">) => { const attachConnection = async (connectionId: Id<"gitConnections">) => {
if (!activeProjectId) { if (!activeProjectId) {
throw new Error("Select a project first"); throw new Error("Select a project first");
@@ -280,18 +299,16 @@ export const useSliceOne = () => {
projectId: activeProjectId, projectId: activeProjectId,
}); });
}; };
const connectGitea = async (input: { const connectGitea = captureOperationError(
serverUrl: string; async (input: { serverUrl: string; token: string; username?: string }) => {
token: string; const result = await connectGiteaAction(input);
username?: string; await attachConnection(result.connectionId);
}) => { }
const result = await connectGiteaAction(input); );
await attachConnection(result.connectionId); const connectLinkedGithub = captureOperationError(async () => {
};
const connectLinkedGithub = async () => {
const result = await connectGithubAction({}); const result = await connectGithubAction({});
await attachConnection(result.connectionId); await attachConnection(result.connectionId);
}; });
return { return {
agent, agent,
@@ -300,11 +317,13 @@ export const useSliceOne = () => {
authorizeGithub, authorizeGithub,
cancelExecution, cancelExecution,
cancelSimulation, cancelSimulation,
clearOperationError: () => setOperationError(undefined),
connectGitea, connectGitea,
connectLinkedGithub, connectLinkedGithub,
connectRepository, connectRepository,
error, error,
gitConnections, gitConnections,
operationError,
pending, pending,
projectGitConnection, projectGitConnection,
projects, projects,

4643
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -25,14 +25,7 @@ CONVEX_INSTANCE_NAME=zopu-production
CONVEX_INSTANCE_SECRET= CONVEX_INSTANCE_SECRET=
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 2. Self-hosted Git / Gitea — REQUIRED for issue lifecycle # 2. Model gateway — REQUIRED
# The agent daemon clones repos and creates PRs through Gitea.
# ---------------------------------------------------------------------------
GITEA_URL=https://git.openputer.com
GITEA_TOKEN=replace-with-gitea-api-token
# ---------------------------------------------------------------------------
# 3. Model gateway — REQUIRED
# All model calls route through this OpenAI-compatible endpoint. # All model calls route through this OpenAI-compatible endpoint.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
AGENT_MODEL_PROVIDER=cheaptricks AGENT_MODEL_PROVIDER=cheaptricks
@@ -44,24 +37,25 @@ AGENT_MODEL_CONTEXT_WINDOW=262000
AGENT_MODEL_MAX_TOKENS=131072 AGENT_MODEL_MAX_TOKENS=131072
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4. AgentOS / RivetKit — OPTIONAL (defaults shown) # 3. AgentOS / Rivet Engine — REQUIRED for the execution runner
# registry.start() in the daemon boots an in-process RivetKit engine # The runner creates isolated worktrees from ZOPU_SOURCE_REPOSITORY and
# (envoy mode) backed by a native Rust sidecar. createClient() connects # connects to AgentOS through the public engine endpoint.
# back to RIVET_ENDPOINT. No separate Rivet Engine process is required
# for single-node operation. Leave RIVET_ENDPOINT unset to use the
# library default (http://localhost:6420).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
#RIVET_ENDPOINT=http://localhost:6420 RIVET_ENDPOINT=https://default:@rivet.example.com
RIVET_PUBLIC_ENDPOINT=https://default@rivet.example.com
RIVET_ENVOY_VERSION=1
RIVET_WORKSPACE_TOKEN=replace-with-a-long-random-workspace-token
ZOPU_SOURCE_REPOSITORY=/opt/zopu-source
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 5. Zopu agent service (Flue) # 4. Zopu agent service (Flue)
# FLUE_DB_TOKEN authenticates the Flue persistence adapter. # FLUE_DB_TOKEN authenticates the Flue persistence adapter.
# zopu-agent.service pins the Flue Node server to port 3583. # zopu-agent.service pins the Flue Node server to port 3583.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
FLUE_DB_TOKEN=replace-with-long-random-token FLUE_DB_TOKEN=replace-with-long-random-token
AGENT_BACKEND_URL=https://zopu-agent.example.com
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 6. Daemon identity # 5. Daemon identity
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
DAEMON_ID=zopu-dedicated DAEMON_ID=zopu-dedicated
DAEMON_NAME=Zopu-Dedicated-Server DAEMON_NAME=Zopu-Dedicated-Server

View File

@@ -5,7 +5,7 @@ FROM node:24-bookworm-slim
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends g++ make python3 \ && apt-get install -y --no-install-recommends ca-certificates g++ git make python3 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app

View File

@@ -4,8 +4,26 @@ services:
context: ../../.. context: ../../..
dockerfile: deploy/zopu-runtime/runner/Dockerfile dockerfile: deploy/zopu-runtime/runner/Dockerfile
environment: environment:
AGENT_MODEL_API: ${AGENT_MODEL_API}
AGENT_MODEL_API_KEY: ${AGENT_MODEL_API_KEY}
AGENT_MODEL_BASE_URL: ${AGENT_MODEL_BASE_URL}
AGENT_MODEL_CONTEXT_WINDOW: ${AGENT_MODEL_CONTEXT_WINDOW}
AGENT_MODEL_MAX_TOKENS: ${AGENT_MODEL_MAX_TOKENS}
AGENT_MODEL_NAME: ${AGENT_MODEL_NAME}
AGENT_MODEL_PROVIDER: ${AGENT_MODEL_PROVIDER}
CONVEX_URL: ${CONVEX_URL}
DAEMON_ID: zopu-agentos-runner
FLUE_DB_TOKEN: ${FLUE_DB_TOKEN}
RIVET_ENDPOINT: ${RIVET_ENDPOINT} RIVET_ENDPOINT: ${RIVET_ENDPOINT}
RIVET_PUBLIC_ENDPOINT: ${RIVET_PUBLIC_ENDPOINT} RIVET_PUBLIC_ENDPOINT: ${RIVET_PUBLIC_ENDPOINT}
RIVET_ENVOY_VERSION: ${RIVET_ENVOY_VERSION} RIVET_ENVOY_VERSION: ${RIVET_ENVOY_VERSION}
RIVET_WORKSPACE_TOKEN: ${RIVET_WORKSPACE_TOKEN}
RIVET_POOL: default RIVET_POOL: default
ZOPU_SOURCE_REPOSITORY: /opt/zopu-source
volumes:
- ../..:/opt/zopu-source
- zopu-agentos-workspaces:/var/lib/zopu/workspaces
restart: unless-stopped restart: unless-stopped
volumes:
zopu-agentos-workspaces:

View File

@@ -1,53 +1,6 @@
{ {
"name": "code", "name": "code",
"private": true, "private": true,
"workspaces": {
"packages": [
"apps/web",
"packages/agents",
"packages/auth",
"packages/backend",
"packages/config",
"packages/env",
"packages/primitives",
"packages/ui"
],
"catalog": {
"@rivet-dev/agentos": "^0.2.7",
"@rivet-dev/agentos-core": "^0.2.10",
"@effect/platform-bun": "4.0.0-beta.99",
"dotenv": "^17.4.2",
"zod": "^4.4.3",
"lucide-react": "^1.23.0",
"next-themes": "^0.4.6",
"react": "19.2.8",
"react-dom": "19.2.8",
"sonner": "^2.0.7",
"convex": "^1.42.1",
"better-auth": "1.6.15",
"@convex-dev/better-auth": "^0.12.5",
"@tanstack/react-form": "^1.33.0",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.3.2",
"tailwind-merge": "^3.6.0",
"@better-auth/expo": "1.6.15",
"effect": "4.0.0-beta.99",
"typescript": "^6",
"@types/bun": "latest",
"heroui-native": "^1.0.5",
"vite": "^7.3.6",
"vitest": "^4.1.10",
"convex-test": "^0.0.54",
"react-native": "0.86.0",
"@types/react": "^19.2.17",
"@types/node": "^22.13.14",
"hono": "^4.8.3",
"valibot": "^1.4.2",
"streamdown": "2.5.0",
"@tailwindcss/postcss": "^4.3.2",
"@tailwindcss/vite": "^4.3.2"
}
},
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vp run -r dev", "dev": "vp run -r dev",
@@ -65,8 +18,8 @@
"dev:server": "vp run --filter @code/backend dev", "dev:server": "vp run --filter @code/backend dev",
"dev:setup": "vp run --filter @code/backend dev:setup", "dev:setup": "vp run --filter @code/backend dev:setup",
"build:agents": "vp run --filter @code/agents build", "build:agents": "vp run --filter @code/agents build",
"docs:update": "bun run scripts/update-docs.ts", "docs:update": "node scripts/update-docs.ts",
"subtree": "bun run scripts/subtree.ts", "subtree": "node scripts/subtree.ts",
"fix": "ultracite fix", "fix": "ultracite fix",
"slice1": "vp run -r dev", "slice1": "vp run -r dev",
"dev:zopu": "vp run --filter @code/agents dev", "dev:zopu": "vp run --filter @code/agents dev",
@@ -92,10 +45,5 @@
"vite-plus": "0.2.2", "vite-plus": "0.2.2",
"vitest": "catalog:" "vitest": "catalog:"
}, },
"overrides": { "packageManager": "pnpm@11.17.0"
"react": "19.2.8",
"react-dom": "19.2.8",
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2"
},
"packageManager": "bun@1.3.14"
} }

View File

@@ -1,5 +1,5 @@
import { defineConfig } from '@flue/cli/config'; import { defineConfig } from "@flue/cli/config";
export default defineConfig({ export default defineConfig({
target: 'node', target: "node",
}); });

View File

@@ -4,23 +4,24 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "bun --env-file=../../.env flue build", "build": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs build",
"start": "node --env-file=../../.env dist/server.mjs",
"check-types": "tsc --noEmit", "check-types": "tsc --noEmit",
"dev": "bun --env-file=../../.env flue dev", "dev": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
"dev:tailscale": "bun --env-file=../../.env flue dev", "dev:tailscale": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs dev",
"run": "bun --env-file=../../.env flue run", "run": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run",
"runner": "bun --env-file=../../.env src/runner.ts", "runner": "node --env-file=../../.env src/runner.ts",
"run:zopu": "bun --env-file=../../.env flue run zopu", "run:zopu": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run zopu",
"run:work-planner": "bun --env-file=../../.env flue run work-planner" "run:work-planner": "node --env-file=../../.env node_modules/@flue/cli/bin/flue.mjs run work-planner"
}, },
"dependencies": { "dependencies": {
"@agentos-software/codex-cli": "0.3.4",
"@agentos-software/git": "0.3.3", "@agentos-software/git": "0.3.3",
"@code/backend": "workspace:*", "@code/backend": "workspace:*",
"@code/env": "workspace:*", "@code/env": "workspace:*",
"@code/primitives": "workspace:*", "@code/primitives": "workspace:*",
"@flue/runtime": "latest", "@flue/runtime": "latest",
"@rivet-dev/agentos": "0.2.10", "@rivet-dev/agentos": "0.2.14",
"@rivet-dev/agentos-core": "0.2.14",
"convex": "catalog:", "convex": "catalog:",
"hono": "catalog:", "hono": "catalog:",
"rivetkit": "2.3.9", "rivetkit": "2.3.9",
@@ -30,6 +31,10 @@
"@code/config": "workspace:*", "@code/config": "workspace:*",
"@flue/cli": "latest", "@flue/cli": "latest",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:" "typescript": "catalog:"
},
"engines": {
"node": ">=22.18 <23 || >=23.6"
} }
} }

View File

@@ -1,54 +1,9 @@
import { parseAgentEnv } from "@code/env/agent"; import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime"; import { defineAgent } from "@flue/runtime";
import INSTRUCTIONS from "../prompts/zopu-instructions.md" with { type: "markdown" };
import { createSliceOneTools } from "../tools/slice-one"; import { createSliceOneTools } from "../tools/slice-one";
const INSTRUCTIONS = `You are Zopu for product Slice 1 and the Work planning handoff.
## Your role
The application stores each user message as exact evidence before you process it. You may interpret that evidence, but never supply, rewrite, or invent source text.
## Work routing loop
When a user sends a message, follow this decision flow:
1. **Assess actionability.** Does the message contain a concrete problem, request, blocker, opportunity, or decision that warrants a work unit? Greetings, questions about the system, casual conversation, and exploration do NOT create work. If the message is casual conversation, respond naturally and do nothing else.
Direct questions, casual conversation, and image-reading requests must be answered immediately without calling any tools.
2. **Identify project context.** Call list_projects. If there is one project, use it. If there are several and the request is ambiguous, ask one focused question.
3. **Create a Signal (only when actionable).** When the message is actionable:
a. Call list_signal_evidence to see the exact admitted user messages.
b. Select the message IDs that compose the problem statement.
c. Call create_signal with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention.
d. Include the projectId when the project is known.
4. **Route the Signal.** After creating the Signal:
a. Call list_proposed_work for the project.
b. If the Signal clearly describes the same desired outcome as existing Work, call attach_signal_to_work.
c. Otherwise call create_work_from_signal.
e. If genuinely uncertain whether to attach or create, ask one focused question.
5. **Explain the outcome.** Tell the user clearly what happened:
- "Captured [Signal title] and linked it to [Work title]."
- "Captured [Signal title] and proposed [Work title]."
- Keep the response brief; the product renders the durable Work card separately.
## Rules
- Never supply or rewrite the raw source message text. The control plane copies it server-side.
- You receive and can see images attached to the current user message. This model supports image input. Never claim images are unavailable, omitted, or unsupported. If a message has attached images, inspect them before responding.
- Never create Work from casual chat.
- Ask at most one focused clarification when genuinely ambiguous.
- Preserve project and organization scope at all times.
- Repeated delivery of the same message must not create duplicate Signals or attachments. The backend is idempotent.
- Never claim you created a Signal until the tool call returns successfully.
- Do not start implementation, planning, sandboxes, Git, verification, or delivery. Those are explicitly outside Slice 1.
- After creating proposed Work, the system may invoke the private work-planner. Never claim that a Definition, Design, approval, or implementation exists unless Convex reports it.
- Proposed Work is the only Work status you directly create.`;
export { export {
convexAgentRoute as attachments, convexAgentRoute as attachments,
convexAgentRoute as route, convexAgentRoute as route,

View File

@@ -1,4 +1,5 @@
import { parseAgentEnv } from "@code/env/agent"; import { parseAgentEnv } from "@code/env/agent";
import { WorkAttemptExecutionError } from "@code/primitives/execution-runtime";
import { registerProvider } from "@flue/runtime"; import { registerProvider } from "@flue/runtime";
import type { Fetchable } from "@flue/runtime/routing"; import type { Fetchable } from "@flue/runtime/routing";
import { flue } from "@flue/runtime/routing"; import { flue } from "@flue/runtime/routing";
@@ -42,8 +43,23 @@ app.post("/internal/work-attempts/execute", async (context) => {
try { try {
return context.json(await executeAgentOsAttempt(await context.req.json())); return context.json(await executeAgentOsAttempt(await context.req.json()));
} catch (error) { } catch (error) {
const failure =
error instanceof WorkAttemptExecutionError
? error
: new WorkAttemptExecutionError({
message:
error instanceof Error ? error.message : "Execution failed",
reason: "HarnessFailed",
retryable: false,
});
return context.json( return context.json(
{ error: error instanceof Error ? error.message : "Execution failed" }, {
error: {
message: failure.message,
reason: failure.reason,
retryable: failure.retryable,
},
},
500 500
); );
} }
@@ -55,7 +71,11 @@ app.post("/internal/work-attempts/:workspaceKey/cancel", async (context) => {
) { ) {
return context.json({ error: "Unauthorized" }, 401); return context.json({ error: "Unauthorized" }, 401);
} }
await cancelAgentOsAttempt(context.req.param("workspaceKey")); const body = (await context.req.json()) as { attemptId?: unknown };
if (typeof body.attemptId !== "string" || body.attemptId.length === 0) {
return context.json({ error: "attemptId is required" }, 400);
}
await cancelAgentOsAttempt(context.req.param("workspaceKey"), body.attemptId);
return context.json({ cancelled: true }); return context.json({ cancelled: true });
}); });

View File

@@ -0,0 +1,45 @@
You are Zopu for product Slice 1 and the Work planning handoff.
## Your role
The application stores each user message as exact evidence before you process it. You may interpret that evidence, but never supply, rewrite, or invent source text.
## Work routing loop
When a user sends a message, follow this decision flow:
1. **Assess actionability.** Does the message contain a concrete problem, request, blocker, opportunity, or decision that warrants a work unit? Greetings, questions about the system, casual conversation, and exploration do NOT create work. If the message is casual conversation, respond naturally and do nothing else.
Direct questions, casual conversation, and image-reading requests must be answered immediately without calling any tools.
2. **Identify project context.** Call list_projects. If there is one project, use it. If there are several and the request is ambiguous, ask one focused question.
3. **Create a Signal (only when actionable).** When the message is actionable:
a. Call list_signal_evidence to see the exact admitted user messages.
b. Select the message IDs that compose the problem statement.
c. Call create_signal with a structured problem statement (title, summary, desiredOutcome, constraints). The problem statement must faithfully represent the user's own intent. Do not invent scope they did not mention.
d. Include the projectId when the project is known.
4. **Route the Signal.** After creating the Signal:
a. Call list_proposed_work for the project.
b. If the Signal clearly describes the same desired outcome as existing Work, call attach_signal_to_work.
c. Otherwise call create_work_from_signal.
e. If genuinely uncertain whether to attach or create, ask one focused question.
5. **Explain the outcome.** Tell the user clearly what happened:
- "Captured [Signal title] and linked it to [Work title]."
- "Captured [Signal title] and proposed [Work title]."
- Keep the response brief; the product renders the durable Work card separately.
## Rules
- Never supply or rewrite the raw source message text. The control plane copies it server-side.
- You receive and can see images attached to the current user message. This model supports image input. Never claim images are unavailable, omitted, or unsupported. If a message has attached images, inspect them before responding.
- Never create Work from casual chat.
- Ask at most one focused clarification when genuinely ambiguous.
- Preserve project and organization scope at all times.
- Repeated delivery of the same message must not create duplicate Signals or attachments. The backend is idempotent.
- Never claim you created a Signal until the tool call returns successfully.
- Do not start implementation, planning, sandboxes, Git, verification, or delivery. Those are explicitly outside Slice 1.
- After creating proposed Work, the system may invoke the private work-planner. Never claim that a Definition, Design, approval, or implementation exists unless Convex reports it.
- Proposed Work is the only Work status you directly create.

View File

@@ -1,25 +1,38 @@
import { parseAgentEnv } from "@code/env/agent"; import { parseAgentEnv } from "@code/env/agent";
import { agentOS, setup } from "@rivet-dev/agentos";
import { createClient } from "@rivet-dev/agentos/client";
import { Effect } from "effect";
import { import {
codexSessionEnv, makePiAgentOsConfig,
makeCodexAgentOsConfig, makePiHomeFiles,
} from "../../../primitives/src/agent-os"; decodeWorkAttemptExecutionInput,
import { decodeWorkAttemptExecutionInput } from "../../../primitives/src/execution-runtime"; WorkAttemptExecutionError,
piSessionEnv,
} from "@code/primitives";
import type { import type {
ExecutionEvent, ExecutionEvent,
WorkAttemptExecutionResult, WorkAttemptExecutionResult,
} from "../../../primitives/src/execution-runtime"; } from "@code/primitives";
import { agentOS, setup } from "@rivet-dev/agentos";
import { createHostDirBackend } from "@rivet-dev/agentos-core";
import { createClient } from "@rivet-dev/agentos/client";
import { Effect } from "effect";
const workspace = agentOS(makeCodexAgentOsConfig() as never); import { HostRepositoryWorkspace } from "./host-repository";
const piConfig = makePiAgentOsConfig();
const workspace = agentOS<undefined, { token: string }>({
onBeforeConnect: (_context, params) => {
if (params.token !== process.env.RIVET_WORKSPACE_TOKEN) {
throw new Error("Unauthorized workspace connection");
}
},
options: {
actionTimeout: 10 * 60 * 1000,
},
permissions: piConfig.permissions,
software: piConfig.software,
});
export const runtimeRegistry = setup({ use: { workspace } }); export const runtimeRegistry = setup({ use: { workspace } });
const shellQuote = (value: string): string =>
`'${value.replaceAll("'", `'"'"'`)}'`;
const event = ( const event = (
sequence: number, sequence: number,
kind: ExecutionEvent["kind"], kind: ExecutionEvent["kind"],
@@ -33,181 +46,210 @@ const event = (
sequence, sequence,
}); });
const requireSuccess = ( const executionError = (
result: { exitCode: number; stderr: string; stdout: string }, message: string,
operation: string reason: WorkAttemptExecutionError["reason"],
) => { retryable: boolean
if (result.exitCode !== 0) { ) => new WorkAttemptExecutionError({ message, reason, retryable });
throw new Error(`${operation} failed: ${result.stderr || result.stdout}`);
}
return result.stdout.trim();
};
const gitAuthEnv = (username: string, credential: string) => ({ const classifyRuntimeFailure = (cause: unknown): WorkAttemptExecutionError => {
GIT_CONFIG_COUNT: "1", if (cause instanceof WorkAttemptExecutionError) {
GIT_CONFIG_KEY_0: "http.extraHeader", return cause;
GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`${username}:${credential}`).toString("base64")}`, }
}); const message = cause instanceof Error ? cause.message : "Execution failed";
if (/auth|credential|401|403/iu.test(message)) {
return executionError(message, "Authentication", false);
}
if (/clone|checkout|repository|git /iu.test(message)) {
return executionError(message, "RepositoryFailed", false);
}
if (/timeout|timed out/iu.test(message)) {
return executionError(message, "Timeout", true);
}
if (/connect|network|unavailable|502|503|504/iu.test(message)) {
return executionError(message, "ProviderUnavailable", true);
}
return executionError(message, "HarnessFailed", false);
};
export const executeAgentOsAttempt = async ( export const executeAgentOsAttempt = async (
rawInput: unknown rawInput: unknown
): Promise<WorkAttemptExecutionResult> => { ): Promise<WorkAttemptExecutionResult> => {
const input = await Effect.runPromise( try {
decodeWorkAttemptExecutionInput(rawInput) const input = await Effect.runPromise(
); decodeWorkAttemptExecutionInput(rawInput)
const env = parseAgentEnv(process.env);
const client = createClient<typeof runtimeRegistry>({
endpoint: env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT,
});
const vm = client.workspace.getOrCreate([input.workspaceKey]);
const events: ExecutionEvent[] = [
event(0, "runtime.preparing", "AgentOS workspace selected", {
workspaceKey: input.workspaceKey,
}),
];
const authEnv = gitAuthEnv(
input.auth.username ??
(input.auth.provider === "github" ? "x-access-token" : "git"),
input.auth.credential
);
await vm.mkdir("/workspace", { recursive: true });
if (!(await vm.exists("/workspace/repository/.git"))) {
events.push(event(1, "repository.cloning", "Cloning project repository"));
const clone = await vm.execArgv(
"git",
["clone", input.repositoryUrl, "/workspace/repository"],
{
cwd: "/workspace",
env: authEnv,
}
); );
requireSuccess(clone, "Repository clone"); const env = parseAgentEnv(process.env);
} const client = createClient<typeof runtimeRegistry>({
disableMetadataLookup: true,
const checkout = await vm.exec( endpoint: env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT,
`git fetch origin ${shellQuote(input.baseBranch)} && git checkout -B ${shellQuote(`zopu/${input.runId}`)} ${shellQuote(`origin/${input.baseBranch}`)}`, });
{ cwd: "/workspace/repository", env: authEnv } const vm = client.workspace.getOrCreate([input.workspaceKey], {
); params: { token: env.RIVET_WORKSPACE_TOKEN },
requireSuccess(checkout, "Repository checkout"); });
const baseRevision = requireSuccess( const events: ExecutionEvent[] = [
await vm.execArgv("git", ["rev-parse", "HEAD"], { event(0, "runtime.preparing", "AgentOS workspace selected", {
cwd: "/workspace/repository", workspaceKey: input.workspaceKey,
}),
"Base revision lookup"
);
events.push(
event(2, "repository.ready", "Repository checkout is ready", {
baseRevision,
})
);
const sessionId = `codex-${input.attemptId}`;
await vm.openSession({
additionalInstructions:
"Work only inside /workspace/repository. Do not reveal credentials. Make the requested change and run focused verification. Do not push or open a pull request.",
agent: "codex",
cwd: "/workspace/repository",
env: codexSessionEnv({
apiKey: env.AGENT_MODEL_API_KEY,
baseUrl: env.AGENT_MODEL_BASE_URL,
model: env.AGENT_MODEL_NAME,
}),
permissionPolicy: "allow_all",
sessionId,
});
events.push(
event(3, "harness.started", "Codex implementation session started")
);
const promptResult = await vm.prompt({
content: [{ text: input.prompt, type: "text" }],
idempotencyKey: input.attemptId,
sessionId,
});
events.push(
event(4, "harness.progress", "Codex implementation turn completed", {
stopReason: String(promptResult.stopReason),
})
);
const status = requireSuccess(
await vm.execArgv("git", ["status", "--porcelain"], {
cwd: "/workspace/repository",
}),
"Changed file lookup"
);
const diff = requireSuccess(
await vm.execArgv("git", ["diff", "--binary", "HEAD"], {
cwd: "/workspace/repository",
}),
"Diff collection"
);
const changedFiles = status
.split("\n")
.filter(Boolean)
.map((line) => line.slice(3).trim());
let candidateRevision = baseRevision;
if (changedFiles.length > 0) {
requireSuccess(
await vm.execArgv("git", ["add", "-A"], { cwd: "/workspace/repository" }),
"Candidate staging"
);
const tree = requireSuccess(
await vm.execArgv("git", ["write-tree"], {
cwd: "/workspace/repository",
}), }),
"Candidate tree creation" ];
const hostRepository = new HostRepositoryWorkspace();
const prepared = await hostRepository.prepare({
attemptId: input.attemptId,
piHomeFiles: makePiHomeFiles({
api: env.AGENT_MODEL_API,
apiKeyEnvironmentVariable: "AGENT_MODEL_API_KEY",
baseUrl: env.AGENT_MODEL_BASE_URL,
contextWindow: env.AGENT_MODEL_CONTEXT_WINDOW,
maxTokens: env.AGENT_MODEL_MAX_TOKENS,
model: env.AGENT_MODEL_NAME,
provider: env.AGENT_MODEL_PROVIDER,
}),
});
events.push(
event(
1,
"runtime.preparing",
prepared.created
? "Isolated Zopu worktree created on the execution host"
: "Isolated Zopu worktree recreated"
)
); );
candidateRevision = requireSuccess( const mounts = [
await vm.exec( {
`printf %s ${shellQuote(`Zopu candidate for ${input.attemptId}`)} | git commit-tree ${shellQuote(tree)} -p ${shellQuote(baseRevision)}`, hostPath: prepared.checkoutPath,
path: "/workspace/repository",
readOnly: false,
},
{
hostPath: prepared.sourceRepositoryPath,
path: prepared.sourceRepositoryPath,
readOnly: false,
},
{
hostPath: prepared.piHomePath,
path: "/home/zopu",
readOnly: false,
},
{
hostPath: prepared.toolsPath,
path: "/opt/zopu-tools",
readOnly: true,
},
] as const;
const mountNext = async (index: number): Promise<void> => {
const mount = mounts[index];
if (!mount) {
return;
}
try {
await vm.mountFs({
path: mount.path,
plugin: createHostDirBackend({
hostPath: mount.hostPath,
readOnly: mount.readOnly,
}),
readOnly: mount.readOnly,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw executionError(
`Failed to mount ${mount.hostPath} at ${mount.path}: ${message}`,
"HarnessFailed",
false
);
}
await mountNext(index + 1);
};
await mountNext(0);
const { baseRevision } = prepared;
events.push(
event(2, "repository.ready", "Repository checkout is ready", {
baseRevision,
})
);
const sessionId = `pi-${input.attemptId}`;
await vm.openSession({
additionalDirectories: [`${prepared.sourceRepositoryPath}/.git`],
additionalInstructions:
"Work only inside /workspace/repository. This is an isolated worktree of the Zopu product repository. Read AGENTS.md and the relevant product specifications before changing code. Never reveal credentials, modify the read-only base checkout, push, or open a pull request. Implement the requested change and run focused verification.",
agent: "pi",
cwd: "/workspace/repository",
env: piSessionEnv(env.AGENT_MODEL_API_KEY),
permissionPolicy: "allow_all",
sessionId,
});
events.push(
event(3, "harness.started", "Pi implementation session started")
);
const promptResult = await vm.prompt({
content: [{ text: input.prompt, type: "text" }],
idempotencyKey: input.attemptId,
sessionId,
});
if (promptResult.stopReason !== "end_turn") {
const reason =
promptResult.stopReason === "cancelled" ? "Cancelled" : "HarnessFailed";
throw executionError(
`Pi stopped with ${promptResult.stopReason}`,
reason,
promptResult.stopReason === "max_tokens" ||
promptResult.stopReason === "max_turn_requests"
);
}
events.push(
event(4, "harness.progress", "Pi implementation turn completed", {
stopReason: promptResult.stopReason,
})
);
const collected = await HostRepositoryWorkspace.collect({
attemptId: input.attemptId,
baseRevision,
checkoutPath: prepared.checkoutPath,
});
const { candidateRevision, changedFiles, diff } = collected;
events.push(
event(
5,
"repository.changed",
`${changedFiles.length} changed file(s) collected`,
{ {
cwd: "/workspace/repository", candidateRevision,
env: {
GIT_AUTHOR_EMAIL: "agent@zopu.dev",
GIT_AUTHOR_NAME: "Zopu Agent",
GIT_COMMITTER_EMAIL: "agent@zopu.dev",
GIT_COMMITTER_NAME: "Zopu Agent",
},
} }
), ),
"Candidate revision creation" event(6, "runtime.completed", "AgentOS execution completed")
); );
requireSuccess(
await vm.execArgv("git", ["reset"], { cwd: "/workspace/repository" }),
"Candidate index reset"
);
}
events.push(
event(
5,
"repository.changed",
`${changedFiles.length} changed file(s) collected`,
{
candidateRevision,
}
),
event(6, "runtime.completed", "AgentOS execution completed")
);
return { return {
baseRevision, baseRevision,
candidateRevision, candidateRevision,
changedFiles, changedFiles,
diff, diff,
environmentId: input.workspaceKey, environmentId: input.workspaceKey,
events, events,
summary: summary:
changedFiles.length > 0 changedFiles.length > 0
? `Codex changed ${changedFiles.length} file(s)` ? `Pi changed ${changedFiles.length} file(s)`
: "Codex completed without repository changes", : "Pi completed without repository changes",
}; };
} catch (error) {
throw classifyRuntimeFailure(error);
}
}; };
export const cancelAgentOsAttempt = async (workspaceKey: string) => { export const cancelAgentOsAttempt = async (
workspaceKey: string,
attemptId: string
) => {
const env = parseAgentEnv(process.env); const env = parseAgentEnv(process.env);
const client = createClient<typeof runtimeRegistry>({ const client = createClient<typeof runtimeRegistry>({
disableMetadataLookup: true,
endpoint: env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT, endpoint: env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT,
}); });
await client.workspace.getOrCreate([workspaceKey]).cancelPrompt({}); await client.workspace
.getOrCreate([workspaceKey], {
params: { token: env.RIVET_WORKSPACE_TOKEN },
})
.cancelPrompt({ sessionId: `pi-${attemptId}` });
}; };

View File

@@ -0,0 +1,274 @@
import { execFileSync, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { once } from "node:events";
import {
access,
chmod,
copyFile,
mkdir,
rm,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import type { PiHomeFiles } from "@code/primitives";
interface PrepareRepositoryInput {
attemptId: string;
piHomeFiles: PiHomeFiles;
}
interface PreparedRepository {
baseRevision: string;
checkoutPath: string;
created: boolean;
piHomePath: string;
sourceRepositoryPath: string;
toolsPath: string;
}
interface CollectRepositoryInput {
attemptId: string;
baseRevision: string;
checkoutPath: string;
}
interface CollectedRepository {
candidateRevision: string;
changedFiles: string[];
diff: string;
}
interface ProcessResult {
exitCode: number;
stderr: string;
stdout: string;
}
const requireSuccess = (result: ProcessResult, operation: string): string => {
if (result.exitCode !== 0) {
throw new Error(`${operation} failed: ${result.stderr || result.stdout}`);
}
return result.stdout.trim();
};
const changedFilePath = (line: string): string => {
const filePath = line.slice(3).trim();
const renameSeparator = " -> ";
const renameIndex = filePath.lastIndexOf(renameSeparator);
return renameIndex === -1
? filePath
: filePath.slice(renameIndex + renameSeparator.length);
};
const runProcess = async (
command: string,
args: readonly string[],
cwd: string,
env: Record<string, string> = {}
): Promise<ProcessResult> => {
const environment = Object.fromEntries(
Object.entries(process.env).filter(
(entry): entry is [string, string] => entry[1] !== undefined
)
);
const child = spawn(command, args, {
cwd,
env: { ...environment, ...env },
});
const stderr: Uint8Array[] = [];
const stdout: Uint8Array[] = [];
child.stderr.on("data", (chunk: Uint8Array) => stderr.push(chunk));
child.stdout.on("data", (chunk: Uint8Array) => stdout.push(chunk));
const [exitCode] = await once(child, "close");
return {
exitCode: typeof exitCode === "number" ? exitCode : 1,
stderr: Buffer.concat(stderr).toString(),
stdout: Buffer.concat(stdout).toString(),
};
};
const runGit = (cwd: string, args: readonly string[]) =>
runProcess("git", args, cwd);
const pathExists = async (target: string): Promise<boolean> => {
try {
await access(target);
return true;
} catch {
return false;
}
};
export class HostRepositoryWorkspace {
readonly #root: string;
readonly #sourceRepositoryPath: string;
readonly #installDependencies: boolean;
constructor(
root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces",
sourceRepositoryPath = process.env.ZOPU_SOURCE_REPOSITORY ??
"/opt/zopu-source",
installDependencies = true
) {
this.#root = root;
this.#sourceRepositoryPath = sourceRepositoryPath;
this.#installDependencies = installDependencies;
}
async prepare(input: PrepareRepositoryInput): Promise<PreparedRepository> {
if (!(await pathExists(path.join(this.#sourceRepositoryPath, ".git")))) {
throw new Error(
`Fixed Zopu source repository is unavailable at ${this.#sourceRepositoryPath}`
);
}
const identity = createHash("sha256")
.update(input.attemptId)
.digest("hex")
.slice(0, 24);
const workspacePath = path.join(this.#root, identity);
const checkoutPath = path.join(workspacePath, "repository");
const toolsPath = path.join(workspacePath, "tools");
const piHomePath = path.join(workspacePath, "home");
const branch = `zopu/attempt-${identity}`;
const created = !(await pathExists(path.join(checkoutPath, ".git")));
await mkdir(workspacePath, { recursive: true });
if (!created) {
requireSuccess(
await runGit(this.#sourceRepositoryPath, [
"worktree",
"remove",
"--force",
checkoutPath,
]),
"Existing worktree removal"
);
}
await rm(checkoutPath, { force: true, recursive: true });
requireSuccess(
await runGit(this.#sourceRepositoryPath, [
"worktree",
"add",
"-B",
branch,
checkoutPath,
"HEAD",
]),
"Zopu worktree creation"
);
const bunExecutable =
process.env.BUN_EXECUTABLE ??
execFileSync("which", ["bun"], { encoding: "utf-8" }).trim();
const sourceEnvPath = path.join(this.#sourceRepositoryPath, ".env");
if (await pathExists(sourceEnvPath)) {
await copyFile(sourceEnvPath, path.join(checkoutPath, ".env"));
}
if (this.#installDependencies) {
requireSuccess(
await runProcess(
bunExecutable,
["install", "--frozen-lockfile"],
checkoutPath,
{ CI: "1" }
),
"Workspace dependency installation"
);
}
const toolsBinPath = path.join(toolsPath, "bin");
await mkdir(toolsBinPath, { recursive: true });
const bunPath = path.join(toolsBinPath, "bun");
await copyFile(bunExecutable, bunPath);
await chmod(bunPath, 0o755);
const piAgentPath = path.join(piHomePath, ".pi", "agent");
await mkdir(piAgentPath, { recursive: true });
await writeFile(
path.join(piAgentPath, "models.json"),
input.piHomeFiles.models
);
await writeFile(
path.join(piAgentPath, "settings.json"),
input.piHomeFiles.settings
);
return {
baseRevision: requireSuccess(
await runGit(checkoutPath, ["rev-parse", "HEAD"]),
"Base revision lookup"
),
checkoutPath,
created,
piHomePath,
sourceRepositoryPath: this.#sourceRepositoryPath,
toolsPath,
};
}
static async collect(
input: CollectRepositoryInput
): Promise<CollectedRepository> {
const status = requireSuccess(
await runGit(input.checkoutPath, ["status", "--porcelain"]),
"Changed file lookup"
);
let diff = "";
const changedFiles = status
.split("\n")
.filter(Boolean)
.map(changedFilePath);
let candidateRevision = input.baseRevision;
if (changedFiles.length > 0) {
requireSuccess(
await runGit(input.checkoutPath, ["add", "-A"]),
"Candidate staging"
);
diff = requireSuccess(
await runGit(input.checkoutPath, [
"diff",
"--binary",
"--cached",
"--no-ext-diff",
input.baseRevision,
]),
"Diff collection"
);
const tree = requireSuccess(
await runGit(input.checkoutPath, ["write-tree"]),
"Candidate tree creation"
);
candidateRevision = requireSuccess(
await runProcess(
"git",
[
"commit-tree",
tree,
"-p",
input.baseRevision,
"-m",
`Zopu candidate for ${input.attemptId}`,
],
input.checkoutPath,
{
GIT_AUTHOR_EMAIL: "agent@zopu.dev",
GIT_AUTHOR_NAME: "Zopu Agent",
GIT_COMMITTER_EMAIL: "agent@zopu.dev",
GIT_COMMITTER_NAME: "Zopu Agent",
}
),
"Candidate revision creation"
);
requireSuccess(
await runGit(input.checkoutPath, ["reset"]),
"Candidate index reset"
);
}
return { candidateRevision, changedFiles, diff };
}
}

View File

@@ -3,6 +3,21 @@ import { ConvexError, v } from "convex/values";
import { internalMutation, mutation, query } from "./_generated/server"; import { internalMutation, mutation, query } from "./_generated/server";
import { requireCurrentOrganization, requireProjectMember } from "./authz"; import { requireCurrentOrganization, requireProjectMember } from "./authz";
// Provider ("forge") that owns a repository host. A project's source must be
// served by the same forge whose credentials are attached, so a Gitea token is
// never offered to GitHub and vice versa. Unknown hosts return null, leaving
// the caller free to attach without a forge constraint.
export const forgeForHost = (host: string): "github" | "gitea" | null => {
const normalized = host.toLowerCase();
if (normalized === "github.com" || normalized.endsWith(".githost.com")) {
return "github";
}
if (normalized === "git.openputer.com") {
return "gitea";
}
return null;
};
export const persist = internalMutation({ export const persist = internalMutation({
args: { args: {
credentialCiphertext: v.string(), credentialCiphertext: v.string(),
@@ -110,6 +125,15 @@ export const attachToProject = mutation({
if (!connection || connection.organizationId !== organizationId) { if (!connection || connection.organizationId !== organizationId) {
throw new ConvexError("Git connection not found"); throw new ConvexError("Git connection not found");
} }
const project = await ctx.db.get(args.projectId);
if (project) {
const expected = forgeForHost(project.sourceHost);
if (expected && connection.provider !== expected) {
throw new ConvexError(
`Git credential provider (${connection.provider}) does not match this project's forge (${expected})`
);
}
}
await ctx.db.patch(args.projectId, { await ctx.db.patch(args.projectId, {
gitConnectionId: connection._id, gitConnectionId: connection._id,
updatedAt: Date.now(), updatedAt: Date.now(),

View File

@@ -367,6 +367,17 @@ export default defineSchema({
workAttempts: defineTable({ workAttempts: defineTable({
classification: v.optional(attemptClassification), classification: v.optional(attemptClassification),
failureReason: v.optional(
v.union(
v.literal("Authentication"),
v.literal("Cancelled"),
v.literal("HarnessFailed"),
v.literal("InvalidInput"),
v.literal("ProviderUnavailable"),
v.literal("RepositoryFailed"),
v.literal("Timeout")
)
),
endedAt: v.optional(v.number()), endedAt: v.optional(v.number()),
leaseExpiresAt: v.optional(v.number()), leaseExpiresAt: v.optional(v.number()),
leaseOwner: v.optional(v.string()), leaseOwner: v.optional(v.string()),

View File

@@ -1,7 +1,11 @@
"use node"; "use node";
import { env } from "@code/env/convex"; import { env } from "@code/env/convex";
import { decodeWorkAttemptExecutionResult } from "@code/primitives/execution-runtime"; import {
WorkAttemptExecutionError,
decodeWorkAttemptExecutionFailure,
decodeWorkAttemptExecutionResult,
} from "@code/primitives/execution-runtime";
import { ConvexError, v } from "convex/values"; import { ConvexError, v } from "convex/values";
import { Effect } from "effect"; import { Effect } from "effect";
@@ -56,23 +60,36 @@ export const executeAttempt = internalAction({
); );
const payload = (await response.json()) as unknown; const payload = (await response.json()) as unknown;
if (!response.ok) { if (!response.ok) {
throw new ConvexError( // Decode the agent's classified failure envelope and re-throw as the
typeof payload === "object" && payload && "error" in payload // typed runtime error so the workflow handler maps reason/retryable to
? String(payload.error) // a durable attempt classification. A malformed envelope falls back to
: `Agent backend returned ${response.status}` // an InvalidInput failure (non-retryable).
const failure = await Effect.runPromise(
decodeWorkAttemptExecutionFailure(payload)
); );
throw new WorkAttemptExecutionError(failure.error);
} }
return await Effect.runPromise(decodeWorkAttemptExecutionResult(payload)); return await Effect.runPromise(decodeWorkAttemptExecutionResult(payload));
}, },
}); });
export const cancelAttempt = internalAction({ export const cancelAttempt = internalAction({
args: { workspaceKey: v.string() }, args: {
attemptId: v.string(),
workspaceKey: v.string(),
},
handler: async (_ctx, args) => { handler: async (_ctx, args) => {
// The workspace key remains the URL path segment (workspace identity),
// while the body carries the attemptId so the runtime can target the
// matching Pi ACP session for cancellation.
await fetch( await fetch(
`${backendUrl()}/internal/work-attempts/${encodeURIComponent(args.workspaceKey)}/cancel`, `${backendUrl()}/internal/work-attempts/${encodeURIComponent(args.workspaceKey)}/cancel`,
{ {
headers: { authorization: `Bearer ${env.FLUE_DB_TOKEN}` }, body: JSON.stringify({ attemptId: args.attemptId }),
headers: {
authorization: `Bearer ${env.FLUE_DB_TOKEN}`,
"content-type": "application/json",
},
method: "POST", method: "POST",
} }
); );

View File

@@ -7,94 +7,116 @@ import schema from "./schema";
const modules = import.meta.glob("./**/*.ts"); const modules = import.meta.glob("./**/*.ts");
const api = anyApi; const api = anyApi;
type TestContext = ReturnType<typeof convexTest>;
interface Seeded {
attemptId: string;
runId: string;
t: TestContext;
workId: string;
}
// Seed an in-flight real attempt so each mutation under test starts from a
// realistic running state: Work "executing", Run "running", Attempt "running".
const seedRunningAttempt = async (
overrides: {
attemptStatus?: "queued" | "claimed" | "running" | "terminal";
runStatus?: "ready" | "running" | "terminal" | "cancelled";
} = {}
): Promise<Seeded> => {
const t = convexTest({ modules, schema });
const seeded = await t.run(async (ctx) => {
const organizationId = await ctx.db.insert("organizations", {
createdAt: 1,
createdBy: "user",
kind: "personal",
name: "Personal",
});
const projectId = await ctx.db.insert("projects", {
createdAt: 1,
name: "Zopu",
normalizedSourceUrl: "https://github.com/puter/zopu",
organizationId,
repositoryPath: "puter/zopu",
sourceHost: "github.com",
sourceUrl: "https://github.com/puter/zopu",
updatedAt: 1,
});
const workId = await ctx.db.insert("works", {
createdAt: 1,
objective: "Implement a real slice",
organizationId,
projectId,
status: "executing",
title: "Real execution",
updatedAt: 1,
});
const sliceRowId = await ctx.db.insert("workSlices", {
designVersion: 1,
objective: "Change the repo",
observableBehavior: "A diff exists",
ordinal: 0,
payloadJson: "{}",
sliceId: "slice-1",
status: "running",
title: "Implementation",
workId,
});
const runId = await ctx.db.insert("workRuns", {
createdAt: 1,
designVersion: 1,
executionKind: "real",
kitId: "coding-v0",
kitVersion: "1",
scenario: "success",
sliceId: "slice-1",
sliceRowId,
status: overrides.runStatus ?? "running",
workId,
});
const attemptId = await ctx.db.insert("workAttempts", {
number: 1,
runId,
status: overrides.attemptStatus ?? "running",
workId,
workspaceKey: "workspace-1",
});
return { attemptId, runId, workId };
});
return { ...seeded, t };
};
const successResult = {
baseRevision: "base123",
candidateRevision: "candidate456",
changedFiles: ["src/index.ts"],
diff: "+export const ready = true;",
environmentId: "workspace-1",
events: [
{
kind: "runtime.completed",
message: "completed",
metadata: {},
occurredAt: 2,
sequence: 0,
},
],
summary: "Changed one file",
};
describe("real work execution persistence", () => { describe("real work execution persistence", () => {
test("records revisions, activity, diff, and terminal state atomically", async () => { test("records revisions, activity, diff, and terminal state atomically", async () => {
const t = convexTest({ modules, schema }); const { t, ...seeded } = await seedRunningAttempt();
const seeded = await t.run(async (ctx) => {
const organizationId = await ctx.db.insert("organizations", {
createdAt: 1,
createdBy: "user",
kind: "personal",
name: "Personal",
});
const projectId = await ctx.db.insert("projects", {
createdAt: 1,
name: "Zopu",
normalizedSourceUrl: "https://example.com/zopu",
organizationId,
repositoryPath: "puter/zopu",
sourceHost: "example.com",
sourceUrl: "https://example.com/zopu",
updatedAt: 1,
});
const workId = await ctx.db.insert("works", {
createdAt: 1,
objective: "Implement a real slice",
organizationId,
projectId,
status: "executing",
title: "Real execution",
updatedAt: 1,
});
const sliceRowId = await ctx.db.insert("workSlices", {
designVersion: 1,
objective: "Change the repo",
observableBehavior: "A diff exists",
ordinal: 0,
payloadJson: "{}",
sliceId: "slice-1",
status: "running",
title: "Implementation",
workId,
});
const runId = await ctx.db.insert("workRuns", {
createdAt: 1,
designVersion: 1,
executionKind: "real",
kitId: "coding-v0",
kitVersion: "1",
scenario: "success",
sliceId: "slice-1",
sliceRowId,
status: "running",
workId,
});
const attemptId = await ctx.db.insert("workAttempts", {
number: 1,
runId,
status: "running",
workId,
workspaceKey: "workspace-1",
});
return { attemptId, runId, workId };
});
await t.mutation(api.workExecutionWorkflow.completeAttempt, { await t.mutation(api.workExecutionWorkflow.completeAttempt, {
attemptId: seeded.attemptId, attemptId: seeded.attemptId,
result: { result: successResult,
baseRevision: "base123",
candidateRevision: "candidate456",
changedFiles: ["src/index.ts"],
diff: "+export const ready = true;",
environmentId: "workspace-1",
events: [
{
kind: "runtime.completed",
message: "completed",
metadata: {},
occurredAt: 2,
sequence: 0,
},
],
summary: "Changed one file",
},
}); });
const state = await t.run(async (ctx) => ({ const state = await t.run(async (ctx) => ({
artifacts: await ctx.db.query("workArtifacts").collect(), artifacts: await ctx.db.query("workArtifacts").collect(),
attempt: await ctx.db.get(seeded.attemptId), attempt: await ctx.db.get(seeded.attemptId as any),
run: await ctx.db.get(seeded.runId), run: await ctx.db.get(seeded.runId as any),
work: await ctx.db.get(seeded.workId), work: await ctx.db.get(seeded.workId as any),
})); }));
expect(state.attempt?.classification).toBe("Succeeded"); expect(state.attempt?.classification).toBe("Succeeded");
expect(state.run).toMatchObject({ expect(state.run).toMatchObject({
@@ -109,3 +131,251 @@ describe("real work execution persistence", () => {
}); });
}); });
}); });
describe("classified failure mapping", () => {
test("maps a transient runtime reason to a retryable classification", async () => {
const { t, ...seeded } = await seedRunningAttempt();
await t.mutation(api.workExecutionWorkflow.failAttempt, {
attemptId: seeded.attemptId,
reason: "ProviderUnavailable",
retryable: true,
summary: "model provider timed out",
});
const attempt = await t.run((ctx) => ctx.db.get(seeded.attemptId as any));
expect(attempt?.classification).toBe("RetryableFailure");
expect(attempt?.failureReason).toBe("ProviderUnavailable");
});
test("queues a new attempt without terminalizing a retryable run", async () => {
const { t, ...seeded } = await seedRunningAttempt();
await t.mutation(api.workExecutionWorkflow.failAttempt, {
attemptId: seeded.attemptId,
reason: "ProviderUnavailable",
retryable: true,
summary: "provider unavailable",
});
const state = await t.run(async (ctx) => ({
attempts: await ctx.db.query("workAttempts").collect(),
run: await ctx.db.get(seeded.runId as any),
work: await ctx.db.get(seeded.workId as any),
}));
expect(state.attempts).toHaveLength(2);
expect(state.attempts[1]).toMatchObject({
number: 2,
status: "queued",
workspaceKey: "workspace-1",
});
expect(state.run?.status).toBe("running");
expect(state.work?.status).toBe("executing");
});
test("maps an authentication reason to a permanent failure", async () => {
const { t, ...seeded } = await seedRunningAttempt();
await t.mutation(api.workExecutionWorkflow.failAttempt, {
attemptId: seeded.attemptId,
reason: "Authentication",
retryable: false,
summary: "token rejected",
});
const state = await t.run(async (ctx) => ({
attempt: await ctx.db.get(seeded.attemptId as any),
run: await ctx.db.get(seeded.runId as any),
work: await ctx.db.get(seeded.workId as any),
}));
expect(state.attempt?.classification).toBe("PermanentFailure");
expect(state.attempt?.failureReason).toBe("Authentication");
expect(state.run?.terminalClassification).toBe("PermanentFailure");
expect(state.work?.status).toBe("failed");
});
});
describe("cancellation fencing", () => {
test("a cancelled attempt cannot later settle success", async () => {
const { t, ...seeded } = await seedRunningAttempt();
// Simulate cancellation marking the attempt/run terminal as Cancelled.
await t.run(async (ctx) => {
await ctx.db.patch(seeded.attemptId as any, {
classification: "Cancelled",
endedAt: 5,
status: "terminal",
summary: "Execution cancelled",
});
await ctx.db.patch(seeded.runId as any, {
endedAt: 5,
status: "cancelled",
terminalClassification: "Cancelled",
terminalSummary: "Execution cancelled",
});
});
// A late-arriving completion must be ignored.
await t.mutation(api.workExecutionWorkflow.completeAttempt, {
attemptId: seeded.attemptId,
result: successResult,
});
const attempt = await t.run((ctx) => ctx.db.get(seeded.attemptId as any));
expect(attempt?.classification).toBe("Cancelled");
expect(attempt?.status).toBe("terminal");
});
test("a late failure does not overwrite a cancelled run", async () => {
const { t, ...seeded } = await seedRunningAttempt();
await t.run(async (ctx) => {
await ctx.db.patch(seeded.attemptId as any, {
classification: "Cancelled",
endedAt: 5,
status: "terminal",
});
await ctx.db.patch(seeded.runId as any, { status: "cancelled" });
});
await t.mutation(api.workExecutionWorkflow.failAttempt, {
attemptId: seeded.attemptId,
reason: "HarnessFailed",
retryable: true,
summary: "late failure",
});
const attempt = await t.run((ctx) => ctx.db.get(seeded.attemptId as any));
expect(attempt?.classification).toBe("Cancelled");
});
});
describe("empty-change rejection", () => {
test("a no-op result with no changed files fails as permanent", async () => {
const { t, ...seeded } = await seedRunningAttempt();
await t.mutation(api.workExecutionWorkflow.completeAttempt, {
attemptId: seeded.attemptId,
result: {
...successResult,
changedFiles: [],
candidateRevision: "base123",
},
});
const state = await t.run(async (ctx) => ({
attempt: await ctx.db.get(seeded.attemptId as any),
work: await ctx.db.get(seeded.workId as any),
}));
expect(state.attempt?.classification).toBe("PermanentFailure");
expect(state.attempt?.failureReason).toBe("InvalidInput");
expect(state.work?.status).toBe("failed");
});
test("identical base and candidate revisions are rejected", async () => {
const { t, ...seeded } = await seedRunningAttempt();
await t.mutation(api.workExecutionWorkflow.completeAttempt, {
attemptId: seeded.attemptId,
result: {
...successResult,
baseRevision: "same",
candidateRevision: "same",
},
});
const attempt = await t.run((ctx) => ctx.db.get(seeded.attemptId as any));
expect(attempt?.classification).toBe("PermanentFailure");
});
});
describe("attempt re-entry", () => {
test("markAttemptRunning re-claims an already-running attempt", async () => {
const { t, ...seeded } = await seedRunningAttempt();
// First claim (already "running" from seed) must still succeed so a
// workflow replay can re-enter the same live attempt.
const first = await t.mutation(
api.workExecutionWorkflow.markAttemptRunning,
{ attemptId: seeded.attemptId }
);
expect(first).toBe(true);
const attempt = await t.run((ctx) => ctx.db.get(seeded.attemptId as any));
expect(attempt?.status).toBe("running");
expect(attempt?.leaseExpiresAt).toBeGreaterThan(0);
});
test("a terminal attempt is not re-runnable", async () => {
const { t, ...seeded } = await seedRunningAttempt({
attemptStatus: "terminal",
});
const result = await t.mutation(
api.workExecutionWorkflow.markAttemptRunning,
{ attemptId: seeded.attemptId }
);
expect(result).toBe(false);
});
test("a cancelled run cannot re-enter a queued attempt", async () => {
const { t, ...seeded } = await seedRunningAttempt({
attemptStatus: "queued",
runStatus: "cancelled",
});
const result = await t.mutation(
api.workExecutionWorkflow.markAttemptRunning,
{ attemptId: seeded.attemptId }
);
expect(result).toBe(false);
const attempt = await t.run((ctx) => ctx.db.get(seeded.attemptId as any));
expect(attempt?.status).toBe("queued");
});
});
describe("forge credential validation", () => {
const identity = { tokenIdentifier: "https://convex.test|forge-user" };
const seedForgableProject = async (
provider: "github" | "gitea",
sourceHost: string
) => {
const t = convexTest({ modules, schema }).withIdentity(identity);
const ids = await t.run(async (ctx) => {
const organizationId = await ctx.db.insert("organizations", {
createdAt: 1,
createdBy: identity.tokenIdentifier,
kind: "personal",
name: "Personal",
});
await ctx.db.insert("organizationMembers", {
createdAt: 1,
organizationId,
role: "owner",
userId: identity.tokenIdentifier,
});
const projectId = await ctx.db.insert("projects", {
createdAt: 1,
name: "Zopu",
normalizedSourceUrl: `https://${sourceHost}/puter/zopu`,
organizationId,
repositoryPath: "puter/zopu",
sourceHost,
sourceUrl: `https://${sourceHost}/puter/zopu`,
updatedAt: 1,
});
const connectionId = await ctx.db.insert("gitConnections", {
connectedAt: 1,
credentialCiphertext: "x",
credentialIv: "y",
credentialKind: provider === "github" ? "oauth" : "token",
organizationId,
provider,
serverUrl: `https://${sourceHost}`,
updatedAt: 1,
username: "zopu",
});
return { connectionId, projectId };
});
return { t, ids };
};
test("rejects a git connection whose provider mismatches the project forge", async () => {
// Gitea token attached to a GitHub-hosted project.
const { t, ids } = await seedForgableProject("gitea", "github.com");
await expect(
t.mutation(api.gitConnectionData.attachToProject, {
connectionId: ids.connectionId,
projectId: ids.projectId,
})
).rejects.toThrow(/does not match this project's forge/u);
});
test("accepts a matching provider for the project forge", async () => {
const { t, ids } = await seedForgableProject("github", "github.com");
const result = await t.mutation(api.gitConnectionData.attachToProject, {
connectionId: ids.connectionId,
projectId: ids.projectId,
});
expect(result).toMatchObject({ attached: true });
});
});

View File

@@ -1,4 +1,7 @@
import { defaultCodingKitV0 } from "@code/primitives/resolver"; import { WorkAttemptExecutionError } from "@code/primitives/execution-runtime";
import type { WorkAttemptExecutionErrorReason } from "@code/primitives/execution-runtime";
import { defaultCodingKitV0, resolveOutcome } from "@code/primitives/resolver";
import type { AttemptClassification } from "@code/primitives/resolver";
import { WorkflowManager } from "@convex-dev/workflow"; import { WorkflowManager } from "@convex-dev/workflow";
import type { WorkflowId } from "@convex-dev/workflow"; import type { WorkflowId } from "@convex-dev/workflow";
import { ConvexError, v } from "convex/values"; import { ConvexError, v } from "convex/values";
@@ -8,9 +11,68 @@ import type { Doc, Id } from "./_generated/dataModel";
import { internalMutation, internalQuery, mutation } from "./_generated/server"; import { internalMutation, internalQuery, mutation } from "./_generated/server";
import type { MutationCtx } from "./_generated/server"; import type { MutationCtx } from "./_generated/server";
import { requireProjectMember } from "./authz"; import { requireProjectMember } from "./authz";
import { forgeForHost } from "./gitConnectionData";
export const workflow = new WorkflowManager(components.workflow); export const workflow = new WorkflowManager(components.workflow);
// Lease window for a running real attempt. Long enough to outlast a single
// agent turn; the workflow's own retry/lease reconciliation covers crashes.
const LEASE_MS = 5 * 60_000;
// Runtime failure reason -> durable attempt classification. The Convex
// workflow stores classifications, not provider-specific reasons, so the
// resolver and Work lifecycle stay forge-agnostic. `retryable` from the
// runtime is carried alongside and consulted by the kit retry policy.
const FAILURE_REASON_CLASSIFICATION = {
Authentication: "PermanentFailure",
Cancelled: "Cancelled",
HarnessFailed: "RetryableFailure",
InvalidInput: "PermanentFailure",
ProviderUnavailable: "RetryableFailure",
RepositoryFailed: "PermanentFailure",
Timeout: "RetryableFailure",
} as const satisfies Record<
WorkAttemptExecutionErrorReason,
AttemptClassification
>;
export const classifyFailure = (
reason: WorkAttemptExecutionErrorReason
): AttemptClassification => FAILURE_REASON_CLASSIFICATION[reason];
// Normalize an error thrown from the agent action into the durable failure
// triple (reason/retryable/summary). WorkAttemptExecutionError is the
// classified runtime failure; anything else is a Convex/infrastructure error
// treated as a transient HarnessFailed so the workflow retry policy decides.
const toExecutionFailure = (
error: unknown
): {
message: string;
reason: WorkAttemptExecutionErrorReason;
retryable: boolean;
} =>
error instanceof WorkAttemptExecutionError
? {
message: error.message,
reason: error.reason,
retryable: error.retryable,
}
: {
message: error instanceof Error ? error.message : "Execution failed",
reason: "HarnessFailed",
retryable: true,
};
const failureReasonValues = v.union(
v.literal("Authentication"),
v.literal("Cancelled"),
v.literal("HarnessFailed"),
v.literal("InvalidInput"),
v.literal("ProviderUnavailable"),
v.literal("RepositoryFailed"),
v.literal("Timeout")
);
const resolveReadySlice = async ( const resolveReadySlice = async (
ctx: MutationCtx, ctx: MutationCtx,
work: Doc<"works">, work: Doc<"works">,
@@ -36,6 +98,34 @@ const resolveReadySlice = async (
return slice; return slice;
}; };
// Deployment prerequisite gate for real execution. Rejects before any
// workflow/sandbox starts when the project lacks an attached, forge-matched
// Git connection with a cloneable source URL and default branch. Returns the
// validated connection so the caller can pass it to the agent unchanged.
const validateProjectDeployment = async (
ctx: MutationCtx,
work: Doc<"works">
): Promise<Doc<"gitConnections">> => {
const project = await ctx.db.get(work.projectId);
if (!project) {
throw new ConvexError("Project not found");
}
if (!project.gitConnectionId) {
throw new ConvexError("Connect Git credentials to this project first");
}
const connection = await ctx.db.get(project.gitConnectionId);
if (!connection) {
throw new ConvexError("Git connection not found");
}
const expected = forgeForHost(project.sourceHost);
if (expected && connection.provider !== expected) {
throw new ConvexError(
`Git credential provider (${connection.provider}) does not match this project's forge (${expected})`
);
}
return connection;
};
export const execute = workflow export const execute = workflow
.define({ args: { attemptId: v.id("workAttempts") } }) .define({ args: { attemptId: v.id("workAttempts") } })
.handler(async (step, args): Promise<void> => { .handler(async (step, args): Promise<void> => {
@@ -57,9 +147,12 @@ export const execute = workflow
}, },
}); });
} catch (error) { } catch (error) {
const failure = toExecutionFailure(error);
await step.runMutation(internal.workExecutionWorkflow.failAttempt, { await step.runMutation(internal.workExecutionWorkflow.failAttempt, {
attemptId: args.attemptId, attemptId: args.attemptId,
summary: error instanceof Error ? error.message : "Execution failed", reason: failure.reason,
retryable: failure.retryable,
summary: failure.message,
}); });
} }
}); });
@@ -91,10 +184,7 @@ export const startExecution = mutation({
) { ) {
throw new ConvexError("Execution requires exact approved versions"); throw new ConvexError("Execution requires exact approved versions");
} }
const project = await ctx.db.get(work.projectId); await validateProjectDeployment(ctx, work);
if (!project?.gitConnectionId) {
throw new ConvexError("Connect Git credentials to this project first");
}
const slice = await resolveReadySlice(ctx, work, args.sliceId); const slice = await resolveReadySlice(ctx, work, args.sliceId);
const createdAt = Date.now(); const createdAt = Date.now();
const runId = await ctx.db.insert("workRuns", { const runId = await ctx.db.insert("workRuns", {
@@ -159,6 +249,12 @@ export const executionContext = internalQuery({
if (!connection) { if (!connection) {
throw new ConvexError("Git connection not found"); throw new ConvexError("Git connection not found");
} }
const expectedForge = forgeForHost(project.sourceHost);
if (expectedForge && connection.provider !== expectedForge) {
throw new ConvexError(
`Git credential provider (${connection.provider}) does not match this project's forge (${expectedForge})`
);
}
return { return {
attempt, attempt,
connection, connection,
@@ -179,11 +275,16 @@ export const markAttemptRunning = internalMutation({
args: { attemptId: v.id("workAttempts") }, args: { attemptId: v.id("workAttempts") },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const attempt = await ctx.db.get(args.attemptId); const attempt = await ctx.db.get(args.attemptId);
if (!attempt || attempt.status !== "queued") { if (!attempt || attempt.status === "terminal") {
return false;
}
const run = await ctx.db.get(attempt.runId);
if (!run || run.status !== "running") {
return false; return false;
} }
await ctx.db.patch(attempt._id, { await ctx.db.patch(attempt._id, {
startedAt: Date.now(), leaseExpiresAt: Date.now() + LEASE_MS,
startedAt: attempt.startedAt ?? Date.now(),
status: "running", status: "running",
}); });
return true; return true;
@@ -193,7 +294,8 @@ export const markAttemptRunning = internalMutation({
const settleSliceAndWork = async ( const settleSliceAndWork = async (
ctx: MutationCtx, ctx: MutationCtx,
run: Doc<"workRuns">, run: Doc<"workRuns">,
succeeded: boolean succeeded: boolean,
workStatus: Doc<"works">["status"]
) => { ) => {
if (!run.sliceRowId) { if (!run.sliceRowId) {
return; return;
@@ -202,12 +304,18 @@ const settleSliceAndWork = async (
if (!slice) { if (!slice) {
return; return;
} }
await ctx.db.patch(slice._id, { status: succeeded ? "completed" : "ready" }); let sliceStatus: "blocked" | "completed" | "ready" = "ready";
if (succeeded) {
sliceStatus = "completed";
} else if (workStatus === "blocked") {
sliceStatus = "blocked";
}
await ctx.db.patch(slice._id, { status: sliceStatus });
const work = await ctx.db.get(run.workId); const work = await ctx.db.get(run.workId);
if (!work) { if (!work) {
return; return;
} }
let status: Doc<"works">["status"] = succeeded ? "completed" : "failed"; let status = workStatus;
if (succeeded) { if (succeeded) {
const slices = await ctx.db const slices = await ctx.db
.query("workSlices") .query("workSlices")
@@ -249,6 +357,8 @@ export const completeAttempt = internalMutation({
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const attempt = await ctx.db.get(args.attemptId); const attempt = await ctx.db.get(args.attemptId);
// Cancellation fencing: a terminal or cancelled attempt/run must never
// later settle success, even if the agent action raced to completion.
if (!attempt || attempt.status === "terminal") { if (!attempt || attempt.status === "terminal") {
return; return;
} }
@@ -257,6 +367,33 @@ export const completeAttempt = internalMutation({
if (!run || !work) { if (!run || !work) {
throw new ConvexError("Execution records not found"); throw new ConvexError("Execution records not found");
} }
if (run.status === "terminal" || run.status === "cancelled") {
return;
}
// Empty-change rejection: a no-op result (no changed files, or identical
// base/candidate revisions) is not a successful implementation. Fail it
// as a permanent InvalidInput so the resolver does not mark Work done.
const noOp =
args.result.changedFiles.length === 0 ||
args.result.baseRevision === args.result.candidateRevision;
if (noOp) {
await ctx.db.patch(attempt._id, {
classification: "PermanentFailure",
endedAt: Date.now(),
failureReason: "InvalidInput",
leaseExpiresAt: undefined,
status: "terminal",
summary: "Execution produced no repository changes",
});
await ctx.db.patch(run._id, {
endedAt: Date.now(),
status: "terminal",
terminalClassification: "PermanentFailure",
terminalSummary: "Execution produced no repository changes",
});
await settleSliceAndWork(ctx, run, false, "failed");
return;
}
for (const item of args.result.events) { for (const item of args.result.events) {
await ctx.db.insert("workAttemptEvents", { await ctx.db.insert("workAttemptEvents", {
attemptId: attempt._id, attemptId: attempt._id,
@@ -271,6 +408,7 @@ export const completeAttempt = internalMutation({
await ctx.db.patch(attempt._id, { await ctx.db.patch(attempt._id, {
classification: "Succeeded", classification: "Succeeded",
endedAt, endedAt,
leaseExpiresAt: undefined,
status: "terminal", status: "terminal",
summary: args.result.summary, summary: args.result.summary,
}); });
@@ -292,7 +430,7 @@ export const completeAttempt = internalMutation({
kind: "diff", kind: "diff",
metadataJson: JSON.stringify({ changedFiles: args.result.changedFiles }), metadataJson: JSON.stringify({ changedFiles: args.result.changedFiles }),
organizationId: work.organizationId, organizationId: work.organizationId,
producer: "agentos-codex", producer: "agentos-pi",
projectId: work.projectId, projectId: work.projectId,
provenanceJson: JSON.stringify({ provenanceJson: JSON.stringify({
baseRevision: args.result.baseRevision, baseRevision: args.result.baseRevision,
@@ -309,7 +447,7 @@ export const completeAttempt = internalMutation({
} }
: {}), : {}),
}); });
await settleSliceAndWork(ctx, run, true); await settleSliceAndWork(ctx, run, true, "completed");
await ctx.db.insert("workEvents", { await ctx.db.insert("workEvents", {
createdAt: endedAt, createdAt: endedAt,
idempotencyKey: `real-run-completed:${run._id}`, idempotencyKey: `real-run-completed:${run._id}`,
@@ -321,31 +459,107 @@ export const completeAttempt = internalMutation({
}, },
}); });
export const failAttempt = internalMutation({ export const launchAttemptWorkflow = internalMutation({
args: { attemptId: v.id("workAttempts"), summary: v.string() }, args: { attemptId: v.id("workAttempts") },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const attempt = await ctx.db.get(args.attemptId); const attempt = await ctx.db.get(args.attemptId);
if (!attempt || attempt.status === "terminal") { if (!attempt || attempt.status === "terminal") {
return; return;
} }
const run = await ctx.db.get(attempt.runId); const run = await ctx.db.get(attempt.runId);
if (!run) { if (!run || run.status !== "running") {
return; return;
} }
const workflowId: WorkflowId = await workflow.start(
ctx,
internal.workExecutionWorkflow.execute,
args
);
await ctx.db.patch(run._id, { workflowId });
},
});
export const failAttempt = internalMutation({
args: {
attemptId: v.id("workAttempts"),
reason: failureReasonValues,
retryable: v.boolean(),
summary: v.string(),
},
handler: async (ctx, args) => {
const attempt = await ctx.db.get(args.attemptId);
// Cancellation fencing: once an attempt or run is terminal/cancelled, a
// late failure from the workflow catch block must not overwrite the
// settled state. This also prevents a cancelled attempt from settling
// as a different classification after cancelExecution ran.
if (!attempt || attempt.status === "terminal") {
return;
}
const run = await ctx.db.get(attempt.runId);
if (!run || run.status === "terminal" || run.status === "cancelled") {
return;
}
const classification = classifyFailure(args.reason);
const endedAt = Date.now(); const endedAt = Date.now();
await ctx.db.patch(attempt._id, { await ctx.db.patch(attempt._id, {
classification: "PermanentFailure", classification,
endedAt, endedAt,
failureReason: args.reason,
leaseExpiresAt: undefined,
status: "terminal", status: "terminal",
summary: args.summary, summary: args.summary,
}); });
const outcome = resolveOutcome(
{ classification, retryable: args.retryable, summary: args.summary },
attempt.number,
defaultCodingKitV0.retryPolicy
);
await ctx.db.insert("resolverDecisions", {
attemptId: attempt._id,
attemptNumber: attempt.number,
classification,
createdAt: endedAt,
decision: outcome.kind,
...(outcome.kind === "terminal"
? { resultingWorkStatus: outcome.workStatus }
: {}),
runId: run._id,
summary: args.summary,
workId: attempt.workId,
});
if (outcome.kind === "retry") {
const nextAttemptId = await ctx.db.insert("workAttempts", {
number: attempt.number + 1,
runId: run._id,
status: "queued",
workId: attempt.workId,
workspaceKey: attempt.workspaceKey,
});
await ctx.scheduler.runAfter(
0,
internal.workExecutionWorkflow.launchAttemptWorkflow,
{ attemptId: nextAttemptId }
);
return;
}
await ctx.db.patch(run._id, { await ctx.db.patch(run._id, {
endedAt, endedAt,
status: "terminal", status: "terminal",
terminalClassification: "PermanentFailure", terminalClassification: classification,
terminalSummary: args.summary, terminalSummary: args.summary,
}); });
await settleSliceAndWork(ctx, run, false); await settleSliceAndWork(ctx, run, false, outcome.workStatus);
const work = await ctx.db.get(run.workId);
if (work) {
await ctx.db.insert("workEvents", {
createdAt: endedAt,
idempotencyKey: `real-run-failed:${run._id}`,
kind: "run.completed",
payloadJson: JSON.stringify({ classification }),
referenceId: String(run._id),
workId: work._id,
});
}
}, },
}); });
@@ -375,15 +589,30 @@ export const cancelExecution = mutation({
0, 0,
internal.workExecutionAgent.cancelAttempt, internal.workExecutionAgent.cancelAttempt,
{ {
attemptId: String(attempt._id),
workspaceKey: attempt.workspaceKey, workspaceKey: attempt.workspaceKey,
} }
); );
const cancelledAt = Date.now();
await ctx.db.patch(attempt._id, { await ctx.db.patch(attempt._id, {
classification: "Cancelled", classification: "Cancelled",
endedAt: Date.now(), endedAt: cancelledAt,
failureReason: "Cancelled",
leaseExpiresAt: undefined,
status: "terminal", status: "terminal",
summary: "Execution cancelled", summary: "Execution cancelled",
}); });
await ctx.db.insert("resolverDecisions", {
attemptId: attempt._id,
attemptNumber: attempt.number,
classification: "Cancelled",
createdAt: cancelledAt,
decision: "terminal",
resultingWorkStatus: "ready",
runId: run._id,
summary: "Execution cancelled",
workId: run.workId,
});
} }
await ctx.db.patch(run._id, { await ctx.db.patch(run._id, {
endedAt: Date.now(), endedAt: Date.now(),

View File

@@ -16,6 +16,8 @@ const agentEnvSchema = z.object({
OPENROUTER_API_KEY: z.string().min(1).optional(), OPENROUTER_API_KEY: z.string().min(1).optional(),
RIVET_ENDPOINT: z.url(), RIVET_ENDPOINT: z.url(),
RIVET_PUBLIC_ENDPOINT: z.url().optional(), RIVET_PUBLIC_ENDPOINT: z.url().optional(),
RIVET_WORKSPACE_TOKEN: z.string().min(32),
ZOPU_SOURCE_REPOSITORY: z.string().min(1).default("/opt/zopu-source"),
}); });
export type AgentEnv = z.infer<typeof agentEnvSchema>; export type AgentEnv = z.infer<typeof agentEnvSchema>;

View File

@@ -30,9 +30,9 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@agentos-software/codex-cli": "0.3.4", "@agentos-software/pi": "0.2.7",
"@agentos-software/git": "0.3.3", "@agentos-software/git": "0.3.3",
"@rivet-dev/agentos": "catalog:", "@rivet-dev/agentos": "0.2.14",
"effect": "catalog:" "effect": "catalog:"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { codexSessionEnv, makeCodexAgentOsConfig } from "./agent-os";
describe("Codex agent-os config", () => {
it("always includes the codex + git software bundle", () => {
const config = makeCodexAgentOsConfig();
expect(config.software).toHaveLength(2);
expect(config.software?.[0]).toBeTypeOf("object");
});
it("builds model-provider session env", () => {
const env = codexSessionEnv(
{
apiKey: "sk-test",
baseUrl: "https://ai.example.com/v1",
model: "glm-5.2",
},
{ CODEX_MODEL: "glm-5.2" }
);
expect(env.OPENAI_API_KEY).toBe("sk-test");
expect(env.OPENAI_BASE_URL).toBe("https://ai.example.com/v1");
expect(env.CODEX_MODEL).toBe("glm-5.2");
});
});

View File

@@ -1,5 +1,5 @@
/* eslint-disable max-classes-per-file -- the AgentOS service and its tagged error form one adapter contract. */ /* eslint-disable max-classes-per-file -- the AgentOS service and its tagged error form one adapter contract. */
import codex from "@agentos-software/codex-cli"; import pi from "@agentos-software/pi";
import { agentOS as createAgentOsActor } from "@rivet-dev/agentos"; import { agentOS as createAgentOsActor } from "@rivet-dev/agentos";
import type { AgentOSConfigInput } from "@rivet-dev/agentos"; import type { AgentOSConfigInput } from "@rivet-dev/agentos";
import { Context, Effect, Layer, Schema } from "effect"; import { Context, Effect, Layer, Schema } from "effect";
@@ -73,50 +73,94 @@ export const createAgentOsActorEffect = Effect.fn("createAgentOsActorEffect")(
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Codex harness configuration // Pi harness configuration
// //
// The canonical execution registry runs exactly one AgentOS actor for the // Slice 5 intentionally runs one harness against the server's fixed Zopu
// puter/zopu-code repo, booted with the Codex CLI harness + git. The model // checkout. Pi speaks ACP natively and reads its model gateway from a mounted
// provider is injected as VM env (OpenAI-compatible base URL + key) so Codex // HOME, avoiding a second executable adapter or credentials path.
// authenticates against the configured gateway without a second credentials
// path. This is a pure config builder; the RivetKit registry/server that hosts
// the actor lives in the agents package.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Software bundle for a Codex-capable VM: the Codex harness plus git. */ /** Software bundle for a Pi-capable VM: the Pi ACP harness plus git. */
export const codexSoftware = [codex, getExecutableGitSoftware()] as const; export const piSoftware = [pi, getExecutableGitSoftware()] as const;
/** Model-provider env that Codex reads inside the VM. Pass this to the session's export interface PiModelConfig {
* `env` when opening a Codex session (`agent: "codex"`). */ readonly api: "openai-completions";
export interface CodexModelEnv { readonly apiKeyEnvironmentVariable: string;
readonly apiKey: string;
readonly baseUrl: string; readonly baseUrl: string;
readonly contextWindow: number;
readonly maxTokens: number;
readonly model: string; readonly model: string;
readonly provider: string;
} }
/** Builds the VM env record Codex reads to authenticate against the model gateway. */ export interface PiHomeFiles {
export const codexSessionEnv = ( readonly models: string;
model: CodexModelEnv, readonly settings: string;
}
/** Builds the two files Pi reads from ~/.pi/agent. */
export const makePiHomeFiles = (model: PiModelConfig): PiHomeFiles => ({
models: JSON.stringify(
{
providers: {
[model.provider]: {
api: model.api,
apiKey: model.apiKeyEnvironmentVariable,
authHeader: true,
baseUrl: model.baseUrl,
models: [
{
contextWindow: model.contextWindow,
id: model.model,
maxTokens: model.maxTokens,
name: model.model,
},
],
},
},
},
null,
2
),
settings: JSON.stringify(
{
defaultModel: model.model,
defaultProvider: model.provider,
quietStartup: true,
},
null,
2
),
});
/** Environment supplied to the Pi ACP session. */
export const piSessionEnv = (
apiKey: string,
extra: Readonly<Record<string, string>> = {} extra: Readonly<Record<string, string>> = {}
): Record<string, string> => ({ ): Record<string, string> => ({
OPENAI_API_KEY: model.apiKey, AGENT_MODEL_API_KEY: apiKey,
OPENAI_BASE_URL: model.baseUrl, HOME: "/home/zopu",
PATH: "/opt/zopu-tools/bin:/usr/local/bin:/usr/bin:/bin",
...extra, ...extra,
}); });
export interface CodexAgentOsConfigInput { export interface PiAgentOsConfigInput {
/** Extra software merged after the Codex+git bundle. */ /** Extra software merged after the Pi+git bundle. */
readonly software?: NonNullable<AgentOSConfigInput<undefined>["software"]>; readonly software?: NonNullable<AgentOSConfigInput<undefined>["software"]>;
/** VM permission overrides merged over the execution policy. */
readonly permissions?: AgentOSConfigInput<undefined>["permissions"];
} }
/** export const makePiAgentOsConfig = (
* Builds an AgentOS actor config for a Codex-backed VM. The Codex CLI and git input: PiAgentOsConfigInput = {}
* are always present; software the caller passes is appended, never replacing
* the harness or git. Model-provider env is supplied per session via
* `codexSessionEnv`, not here — the actor config has no env field.
*/
export const makeCodexAgentOsConfig = (
input: CodexAgentOsConfigInput = {}
): AgentOSConfigInput<undefined> => ({ ): AgentOSConfigInput<undefined> => ({
software: [...codexSoftware, ...(input.software ?? [])], permissions: {
childProcess: "allow",
env: "allow",
fs: "allow",
network: "allow",
process: "allow",
...input.permissions,
},
software: [...piSoftware, ...(input.software ?? [])],
}); });

View File

@@ -121,6 +121,16 @@ export class WorkAttemptExecutionError extends Schema.TaggedErrorClass<WorkAttem
} }
) {} ) {}
export const WorkAttemptExecutionFailure = Schema.Struct({
error: Schema.Struct({
message: Text,
reason: WorkAttemptExecutionErrorReason,
retryable: Schema.Boolean,
}),
});
export type WorkAttemptExecutionFailure =
typeof WorkAttemptExecutionFailure.Type;
const invalidInput = (message: string) => const invalidInput = (message: string) =>
new WorkAttemptExecutionError({ new WorkAttemptExecutionError({
message, message,
@@ -143,6 +153,11 @@ export const decodeWorkAttemptExecutionResult = (input: unknown) =>
Effect.mapError((cause) => invalidInput(cause.message)) Effect.mapError((cause) => invalidInput(cause.message))
); );
export const decodeWorkAttemptExecutionFailure = (input: unknown) =>
Schema.decodeUnknownEffect(WorkAttemptExecutionFailure)(input).pipe(
Effect.mapError((cause) => invalidInput(cause.message))
);
export interface SandboxRuntime { export interface SandboxRuntime {
readonly name: string; readonly name: string;
readonly execute: ( readonly execute: (
@@ -150,6 +165,7 @@ export interface SandboxRuntime {
signal?: AbortSignal signal?: AbortSignal
) => Effect.Effect<WorkAttemptExecutionResult, WorkAttemptExecutionError>; ) => Effect.Effect<WorkAttemptExecutionResult, WorkAttemptExecutionError>;
readonly cancel: ( readonly cancel: (
workspaceKey: string workspaceKey: string,
attemptId: string
) => Effect.Effect<void, WorkAttemptExecutionError>; ) => Effect.Effect<void, WorkAttemptExecutionError>;
} }

20928
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

62
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,62 @@
packages:
- apps/web
- packages/agents
- packages/auth
- packages/backend
- packages/config
- packages/env
- packages/primitives
- packages/ui
allowBuilds:
"@google/genai": true
"@mongodb-js/zstd": true
better-sqlite3: true
cbor-extract: true
esbuild: true
isolated-vm: true
koffi: true
msgpackr-extract: true
node-liblzma: true
protobufjs: true
workerd: true
catalog:
"@rivet-dev/agentos": "^0.2.7"
"@rivet-dev/agentos-core": "^0.2.10"
"@effect/platform-bun": "4.0.0-beta.99"
dotenv: "^17.4.2"
zod: "^4.4.3"
lucide-react: "^1.23.0"
next-themes: "^0.4.6"
react: "19.2.8"
react-dom: "19.2.8"
sonner: "^2.0.7"
convex: "^1.42.1"
better-auth: "1.6.15"
"@convex-dev/better-auth": "^0.12.5"
"@tanstack/react-form": "^1.33.0"
"@types/react-dom": "^19.2.3"
tailwindcss: "^4.3.2"
tailwind-merge: "^3.6.0"
"@better-auth/expo": "1.6.15"
effect: "4.0.0-beta.99"
typescript: "^7.0.2"
"@types/bun": "latest"
heroui-native: "^1.0.5"
vite: "^7.3.6"
vitest: "^4.1.10"
convex-test: "^0.0.54"
react-native: "0.86.0"
"@types/react": "^19.2.17"
"@types/node": "^22.13.14"
hono: "^4.8.3"
valibot: "^1.4.2"
streamdown: "2.5.0"
"@tailwindcss/postcss": "^4.3.2"
"@tailwindcss/vite": "^4.3.2"
overrides:
react: 19.2.8
react-dom: 19.2.8
vite: npm:@voidzero-dev/vite-plus-core@0.2.2