Compare commits

...

46 Commits

Author SHA1 Message Date
-Puter
c644ec8d01 feat(git): thin project onboarding, provider integration, and AgentOS repository access
Effect primitives:
- git-provider: GitProvider, connection states, normalized errors, URL
  normalization, host compatibility, credential freshness window
- git-provisioning: validated Puter commands, migration states, idempotency
  keys, safe replacement rules, owner-safe guards
- git-webhook: supported events, signature verification, delivery states
- host-repository: provider-neutral credential-safe clone via GIT_ASKPASS

Normalized Convex schema:
- gitProviderAccounts, refined gitConnections, gitProviderOrganizations,
  gitRepositories, gitMigrations, gitWebhookDeliveries
- projects: gitRepositoryId + instructions fields
- Schema fields optional for backward compatibility, with backfill cron

Backend:
- Connection health: verify action, hourly reconciliation (covers stale
  active + reauth-required + undefined-state legacy connections)
- Puter provisioning: createPuterUser/Organization/Repository with owner
  binding, startGithubMigration (durable via scheduler), getMigration
- Org ownership: explicit member add with admin role + verification
- Webhook HTTP actions: HMAC verification, delivery persistence with
  idempotency, repository resolution, byte-length payload limit
- Automatic Puter webhook creation after repo creation/migration with
  fail-loud state tracking
- Repository sync after connection (Gitea + GitHub)
- AgentOS execution resolves gitRepositoryId for real clone URL
- Credential gating: state + freshness checks before execution and
  project creation
- listForOrganization for cross-project Work filtering

Frontend:
- /projects onboarding page with GitHub OAuth (linkSocial) and Puter PAT
- Zero-project redirect, repository selection, context editor
- Provider-aware settings panel (no serverUrl for Puter)
- Project selection via ?project= query param
- GitHub scopes: repo + read:org

Agent runtime:
- Clones user repository with GIT_ASKPASS credential helper (no token in
  URL, args, or git config), provider-aware username
- Removed fixed Zopu source path and .env copy
2026-07-31 14:36:56 +05:30
-Puter
88f53f550d Zopu Agent Tool Fixing 2026-07-31 01:31:12 +05:30
-Puter
54a5993274 Minor iOS phone fix 2026-07-31 01:30:59 +05:30
-Puter
def3fc949a doc 2026-07-31 01:30:45 +05:30
-Puter
9f7dd3c1a6 fuck them garbage deploys 2026-07-29 20:49:52 +05:30
-Puter
a8b2ff5e2e feat(agents): add 2026-07-29 20:11:08 +05:30
-Puter
40e0f7e1eb fix(workspace): ensure chat markdown readability on forced-dark surface 2026-07-29 19:34:20 +05:30
-Puter
bf2300a6be feat: same-origin auth via reverse proxy and secure cookies
Route /api/auth through the same origin in both dev and prod so
cookies stay first-party and the browser and React Router SSR share
one auth surface.

- Vite dev server proxies /api/auth to the Convex HTTP site.
- Better Auth uses secure cookies when the site URL is https.
- Add docs/auth-proxy.md documenting the required production ingress
  (Caddy/Traefik) and Convex SITE_URL / CONVEX_SITE_URL setup.
2026-07-29 19:32:26 +05:30
-Puter
0657037c84 feat: make Rivet endpoint optional for self-hosted runtime
RIVET_ENDPOINT and RIVET_PUBLIC_ENDPOINT are now optional in the agent
env schema; empty strings are coerced to undefined. The agent runtime
only passes an endpoint override to the Rivet client when one is set,
so the client falls back to its default (suitable for self-hosted
Rivet where no explicit endpoint is required).
2026-07-29 19:32:22 +05:30
-Puter
64a783b445 build: allow importing .ts extensions in Convex tsconfig
Enable allowImportingTsExtensions in the Convex tsconfig so the
backend code that uses explicit .ts import specifiers type-checks
cleanly.
2026-07-29 19:32:18 +05:30
-Puter
ed28943e7a fix: drop .ts extensions from primitives imports
Use extensionless import specifiers in project.ts and work.ts so the
modules resolve under Node ESM and bundler resolution, consistent with
allowImportingTsExtensions on the consuming packages.
2026-07-29 19:32:15 +05:30
-Puter
9e148489f0 chore: stop hiding repos/ in VS Code file explorer
Drop the files.exclude rule for repos/**. The directory is still
excluded from auto-imports and file watching, but no longer hidden
from the explorer.
2026-07-29 19:32:10 +05:30
-Puter
25f86d94cc chore: broaden .env ignore patterns
Add .env.* to .gitignore so per-environment secrets like
.env.staging and .env.production are never tracked, alongside the
existing .env and .env*.local rules.
2026-07-29 19:32:05 +05:30
-Puter
7668fa69cc fix: route web auth through same origin 2026-07-29 17:01:22 +05:30
-Puter
3ffa1cfc7c feat: project durable agent conversations 2026-07-29 08:19:17 +05:30
-Puter
18eb150d7d fix: serve web assets from app directory 2026-07-29 08:13:52 +05:30
-Puter
e1b0b731e0 deploy: attach backend to shared network 2026-07-29 08:09:30 +05:30
-Puter
d428a2492b fix: make primitives Node ESM compatible 2026-07-29 07:51:07 +05:30
-Puter
fe0fd9b16c deploy: enable Node TypeScript export resolution 2026-07-29 07:40:44 +05:30
-Puter
fc1fcf5d44 deploy: run agent backend with Bun 2026-07-29 07:31:36 +05:30
-Puter
3ae72864bd deploy: target agent package during Flue build 2026-07-29 07:21:02 +05:30
-Puter
0d5d54caa8 deploy: build agent without local env file 2026-07-29 07:15:19 +05:30
-Puter
830bcc4756 deploy: build applications on Node base 2026-07-29 07:09:07 +05:30
-Puter
0e56a462cd deploy: prefer Node for native install scripts 2026-07-29 07:03:32 +05:30
-Puter
9fb293a539 deploy: build native modules with Node 2026-07-29 07:00:47 +05:30
-Puter
062c00f53c deploy: install frontend build toolchain 2026-07-29 06:58:04 +05:30
-Puter
a7e70c9b2a deploy: install backend build toolchain 2026-07-29 06:56:36 +05:30
-Puter
526ed59776 deploy: fix compose build contexts 2026-07-29 06:46:07 +05:30
-Puter
fd3980c6bf deploy: split Dokploy services 2026-07-29 06:42:06 +05:30
-Puter
d8a4bbe804 chore cleanp 2026-07-29 01:24:37 +05:30
-Puter
9eb6bcd25f refactor canonical components and remove slice 1 2026-07-29 01:24:16 +05:30
-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
122 changed files with 30236 additions and 7852 deletions

View File

@@ -17,7 +17,11 @@ DAEMON_NAME=Local MacBook
DAEMON_VERSION=0.0.0
DAEMON_HEARTBEAT_MS=15000
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
ZOPU_SOURCE_REPOSITORY=/absolute/path/to/zopu-code
AGENT_WORKSPACE_ROOT=/absolute/path/to/zopu-agent-workspaces
# Flue persistence adapter
FLUE_DB_TOKEN=replace-with-a-long-random-token

1
.gitignore vendored
View File

@@ -54,3 +54,4 @@ coverage
.cache
tmp
temp
.env.*

1
.npmrc Normal file
View File

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

View File

@@ -1,9 +1,6 @@
{
"typescript.preferences.autoImportFileExcludePatterns": ["repos/**"],
"javascript.preferences.autoImportFileExcludePatterns": ["repos/**"],
"files.exclude": {
"repos/**": true
},
"files.watcherExclude": {
"repos/**": true
},

36
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
FROM oven/bun:1.3.14 AS bun
FROM node:24-bookworm-slim AS build
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
g++ \
make \
python3 \
&& rm -rf /var/lib/apt/lists/*
COPY . .
ARG VITE_AUTH_URL
ARG VITE_CONVEX_URL
ENV VITE_AUTH_URL=$VITE_AUTH_URL
ENV VITE_CONVEX_URL=$VITE_CONVEX_URL
RUN bun install --frozen-lockfile
RUN bun run --filter web build
FROM node:24-bookworm-slim
ENV HOST=0.0.0.0
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app/apps/web
COPY --from=build /app /app
EXPOSE 3000
CMD ["node", "node_modules/.bin/react-router-serve", "build/server/index.js"]

View File

@@ -0,0 +1,465 @@
import { authClient } from "@code/auth/web";
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { Button } from "@code/ui/components/button";
import { useAction, useMutation, useQuery } from "convex/react";
import { makeFunctionReference } from "convex/server";
import {
FileText,
FolderGit2,
GitBranch,
LoaderCircle,
Server,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
const connectGithubRef = makeFunctionReference<
"action",
Record<string, never>,
{ connectionId: Id<"gitConnections"> }
>("gitConnections:connectGithub");
const connectGiteaRef = makeFunctionReference<
"action",
{ token: string; username?: string },
{ connectionId: Id<"gitConnections"> }
>("gitConnections:connectGitea");
const createProjectFromRepositoryRef = makeFunctionReference<
"mutation",
{ gitRepositoryId: Id<"gitRepositories">; name: string },
{ projectId: Id<"projects"> }
>("gitProvisioning:createProjectFromRepository");
const LoadingState = () => (
<main className="grid min-h-svh place-items-center bg-[#f2f0e7]">
<LoaderCircle className="size-5 animate-spin text-[#20201d]" />
</main>
);
const EmptyProjects = () => (
<div className="mt-12 text-center">
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
<FolderGit2 className="size-5" />
</span>
<h1 className="mt-6 text-2xl font-semibold text-[#20201d]">
Connect your first project
</h1>
<p className="mt-2 text-sm text-[#68665e]">
Choose a Git provider below to get started.
</p>
</div>
);
const ProjectCard = ({
instructions,
name,
projectId,
sourceUrl,
}: {
instructions: string;
name: string;
projectId: string;
sourceUrl: string;
}) => (
<Link
className="block border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
to={`/?project=${projectId}`}
>
<div className="flex items-center gap-2">
<FolderGit2 className="size-4 text-[#747168]" />
<h3 className="text-sm font-semibold text-[#20201d]">{name}</h3>
</div>
<p className="mt-1 truncate text-xs text-[#747168]">{sourceUrl}</p>
{instructions ? (
<div className="mt-2 flex items-center gap-1 text-xs text-[#747168]">
<FileText className="size-3" />
<span className="truncate">Context configured</span>
</div>
) : null}
</Link>
);
const ContextEditor = ({
projectId,
}: {
readonly projectId: Id<"projects">;
}) => {
const instructions = useQuery(api.projects.getInstructions, {
projectId,
});
const updateInstructions = useMutation(api.projects.updateInstructions);
const [draft, setDraft] = useState<string>();
const [saving, setSaving] = useState(false);
const currentDraft = draft ?? instructions ?? "";
const dirty = draft !== undefined && draft !== (instructions ?? "");
const save = async () => {
if (!dirty || saving) {
return;
}
setSaving(true);
try {
await updateInstructions({
instructions: draft ?? "",
projectId,
});
setDraft(undefined);
} finally {
setSaving(false);
}
};
return (
<div className="mt-3">
<label
className="text-xs font-medium text-[#20201d]"
htmlFor="context-textarea"
>
Context
</label>
<textarea
className="mt-1 h-24 w-full resize-y border border-[#c9c5b9] bg-[#fffefa] p-2 text-xs outline-none focus:border-[#55564e]"
id="context-textarea"
onChange={(event) => setDraft(event.target.value)}
placeholder="Project-specific instructions for agents..."
value={currentDraft}
/>
{dirty ? (
<Button
className="mt-1 h-8 text-xs"
disabled={saving}
onClick={save}
size="sm"
type="button"
variant="outline"
>
{saving ? <LoaderCircle className="size-3 animate-spin" /> : null}
{saving ? "Saving" : "Save context"}
</Button>
) : null}
</div>
);
};
const PuterConnectForm = ({
onConnected,
}: {
readonly onConnected: () => void;
}) => {
const connectGitea = useAction(connectGiteaRef);
const [token, setToken] = useState("");
const [username, setUsername] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string>();
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (!token.trim() || pending) {
return;
}
setPending(true);
setError(undefined);
try {
await connectGitea({
token: token.trim(),
username: username.trim() || undefined,
});
setToken("");
setUsername("");
onConnected();
} catch (caughtError) {
setError(
caughtError instanceof Error ? caughtError.message : String(caughtError)
);
} finally {
setPending(false);
}
};
return (
<form className="mt-4 space-y-3" onSubmit={submit}>
<p className="text-xs text-[#68665e]">
Connect your Puter Git personal access token. The Puter Git instance is
at <code className="text-[#20201d]">git.openputer.com</code>.
</p>
<input
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
onChange={(event) => setUsername(event.target.value)}
placeholder="Puter username (optional)"
value={username}
/>
<input
className="h-10 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
onChange={(event) => setToken(event.target.value)}
placeholder="Personal access token"
required
type="password"
value={token}
/>
{error ? <p className="text-xs text-red-700">{error}</p> : null}
<Button className="h-10 w-full" disabled={pending} type="submit">
{pending ? <LoaderCircle className="size-4 animate-spin" /> : null}
{pending ? "Connecting" : "Connect Puter Git"}
</Button>
</form>
);
};
const RepositorySelector = ({
onProjectCreated,
}: {
readonly onProjectCreated: (projectId: string) => void;
}) => {
const repositories = useQuery(
makeFunctionReference<
"query",
Record<string, never>,
readonly {
cloneUrl: string;
defaultBranch: string;
fullName: string;
id: string;
name: string;
owner: string;
privacy: "public" | "private";
provider: "github" | "gitea";
webUrl: string;
}[]
>("gitProvisioning:listRepositories"),
{}
);
const createProject = useMutation(createProjectFromRepositoryRef);
const [pending, setPending] = useState<string>();
const create = async (repoId: string, name: string) => {
setPending(repoId);
try {
const result = await createProject({
gitRepositoryId: repoId as Id<"gitRepositories">,
name,
});
onProjectCreated(String(result.projectId));
} finally {
setPending(undefined);
}
};
if (repositories === undefined) {
return <LoaderCircle className="size-4 animate-spin text-[#747168]" />;
}
if (repositories.length === 0) {
return (
<p className="text-xs text-[#747168]">
No repositories found. Create one on your provider first, or import a
public repository below.
</p>
);
}
return (
<div className="mt-3 space-y-2">
{repositories.map((repo) => (
<button
className="flex w-full items-center justify-between border border-[#d7d3c7] bg-[#fffefa] p-3 text-left transition-colors hover:bg-[#f5f3eb]"
key={repo.id}
onClick={() => create(repo.id, repo.name)}
type="button"
>
<div>
<p className="text-sm font-medium text-[#20201d]">
{repo.fullName}
</p>
<p className="text-xs text-[#747168]">
{repo.provider} - {repo.defaultBranch}
</p>
</div>
{pending === repo.id ? (
<LoaderCircle className="size-4 animate-spin text-[#747168]" />
) : null}
</button>
))}
</div>
);
};
const GitHubConnectButton = () => {
const [pending, setPending] = useState(false);
const linkGithub = async () => {
setPending(true);
try {
await authClient.linkSocial({
callbackURL: `${window.location.origin}/projects?resume=github`,
provider: "github",
});
} finally {
setPending(false);
}
};
return (
<button
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
onClick={linkGithub}
type="button"
>
<GitBranch className="size-5 text-[#20201d]" />
<div className="flex-1 text-left">
<p className="text-sm font-semibold text-[#20201d]">GitHub</p>
<p className="text-xs text-[#747168]">OAuth with private repo access</p>
</div>
{pending ? (
<LoaderCircle className="size-4 animate-spin text-[#747168]" />
) : null}
</button>
);
};
const ProviderSection = () => {
const [showPuterForm, setShowPuterForm] = useState(false);
return (
<section className="mt-10">
<h2 className="text-sm font-semibold text-[#20201d]">Git providers</h2>
<p className="mt-1 text-xs text-[#68665e]">
Connect a repository to create a Project.
</p>
<div className="mt-4 space-y-3">
<GitHubConnectButton />
<button
className="flex w-full items-center gap-3 border border-[#d7d3c7] bg-[#fffefa] p-4 transition-colors hover:bg-[#f5f3eb]"
onClick={() => setShowPuterForm((v) => !v)}
type="button"
>
<Server className="size-5 text-[#20201d]" />
<div className="flex-1 text-left">
<p className="text-sm font-semibold text-[#20201d]">Puter Git</p>
<p className="text-xs text-[#747168]">
Connect with a personal access token
</p>
</div>
</button>
</div>
{showPuterForm ? (
<PuterConnectForm onConnected={() => setShowPuterForm(false)} />
) : null}
</section>
);
};
const RepositorySection = ({
onProjectCreated,
}: {
readonly onProjectCreated: (projectId: string) => void;
}) => (
<section className="mt-6">
<h2 className="text-sm font-semibold text-[#20201d]">Your repositories</h2>
<p className="mt-1 text-xs text-[#68665e]">
Select a repository to create a Project.
</p>
<RepositorySelector onProjectCreated={onProjectCreated} />
</section>
);
export const ProjectsPage = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const projects = useQuery(api.projects.list, {});
const connectGithubAction = useAction(connectGithubRef);
const [resumeError, setResumeError] = useState<string>();
// Handle GitHub OAuth callback resume
useEffect(() => {
const resume = searchParams.get("resume");
if (resume !== "github") {
return;
}
void (async () => {
try {
await connectGithubAction({});
setSearchParams({}, { replace: true });
} catch (caughtError) {
setResumeError(
caughtError instanceof Error
? caughtError.message
: "GitHub connection failed"
);
}
})();
}, [searchParams, setSearchParams, connectGithubAction]);
if (projects === undefined) {
return <LoadingState />;
}
const hasProjects = projects.length > 0;
const handleProjectCreated = (projectId: string) => {
void navigate(`/?project=${projectId}`, { replace: true });
};
return (
<main className="min-h-svh bg-[#f2f0e7] px-5 py-8 text-[#20201d]">
<div className="mx-auto max-w-2xl">
<header className="flex items-center justify-between">
<h1 className="text-lg font-semibold">Projects</h1>
{hasProjects ? (
<Button
className="h-9"
onClick={() => navigate("/")}
size="sm"
variant="outline"
>
Home
</Button>
) : null}
</header>
{resumeError ? (
<p className="mt-4 border border-red-300 bg-red-50 p-3 text-xs text-red-700">
{resumeError}
</p>
) : null}
{hasProjects ? null : <EmptyProjects />}
{hasProjects ? (
<section className="mt-6 grid gap-3">
{projects.map((project) => (
<div key={project.id}>
<ProjectCard
instructions={
project.contextDocuments.find(
(doc) => doc.kind === "readme"
)?.content ?? ""
}
name={project.name}
projectId={project.id}
sourceUrl={project.sources[0]?.url ?? ""}
/>
<ContextEditor
projectId={project.id as unknown as Id<"projects">}
/>
</div>
))}
</section>
) : null}
<RepositorySection onProjectCreated={handleProjectCreated} />
<ProviderSection />
<section className="mt-8">
<p className="text-xs text-[#68665e]">
Or import a public repository:
</p>
<Link
className="mt-2 inline-flex items-center gap-2 text-sm text-[#20201d] underline"
to="/?import=public"
>
Import public Git URL
</Link>
</section>
</div>
</main>
);
};

View File

@@ -1,789 +0,0 @@
import { projectWorkNotices } from "@code/primitives/work";
import {
Conversation,
ConversationContent,
} from "@code/ui/components/ai-elements/conversation";
import { Button } from "@code/ui/components/button";
import {
ChevronRight,
Check,
FolderGit2,
Hammer,
LoaderCircle,
Menu,
MessageSquareText,
ImagePlus,
Play,
RotateCcw,
Send,
Sparkles,
Settings,
X,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { PendingChatAttachments } from "@/components/chat/chat-attachments";
import { ChatMessage } from "@/components/chat/chat-message";
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
import { useChatImages } from "@/hooks/chat/use-chat-images";
import { useSliceOne } from "@/hooks/slice-one/use-slice-one";
import { useVisualViewportStyle } from "@/hooks/slice-one/use-visual-viewport";
import {
buildSliceOneTimeline,
findSourceMessageTarget,
} from "@/lib/slice-one/presentation";
type SliceWork = NonNullable<ReturnType<typeof useSliceOne>["works"]>[number];
const EMPTY_WORKS: readonly SliceWork[] = [];
interface WorkCardProps {
readonly onSourceSelect: (rawText: string) => void;
readonly work: SliceWork;
readonly slice: SliceOneState;
}
const starterDefinition = (work: SliceWork) => ({
acceptanceCriteria: ["The requested outcome is observable and documented"],
affectedUsers: ["Project users"],
assumptions: [],
constraints: [],
desiredOutcome: work.objective,
inScope: [work.objective],
outOfScope: ["Unrelated product changes"],
problem: work.objective,
questions: [],
requiredArtifacts: ["Simulation activity and terminal outcome"],
risk: "medium",
});
const starterDesign = (work: SliceWork) => ({
architectureSummary:
"Validate the approved Definition, then exercise one deterministic fake slice.",
callFlowDelta: [
"Work -> Run -> Attempt -> normalized events -> terminal outcome",
],
concerns: [],
evidenceRequirements: ["Terminal Run classification"],
fileTreeDelta: [],
impactMap: {
files: [],
modules: [],
risks: [],
summary: "Compact vertical-slice simulation",
},
invariants: [
"Simulation never claims implementation",
"Every Attempt reaches a terminal classification",
],
keyInterfaces: ["HarnessRuntime", "AttemptOutcome"],
slices: [
{
codeBoundaries: ["workExecution"],
dependsOn: [],
evidenceRequirements: ["Normalized activity events"],
id: "slice-1",
objective: work.objective,
observableBehavior: "A terminal fake Run is visible",
reviewRequired: false,
title: "Deterministic simulation",
verification: ["Run completes with a terminal classification"],
},
],
tradeoffs: ["Fake runtime proves contract before sandbox integration"],
});
// oxlint-disable-next-line complexity -- the expanded card intentionally keeps the three review sections together.
const WorkCard = ({ onSourceSelect, work, slice }: WorkCardProps) => {
const [sourcesOpen, setSourcesOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
const sources = work.signals.flatMap((signal) => signal.sources);
const { definition } = work;
const { design } = work;
const [latestRun] = work.runs;
return (
<article className="border border-[#d7d3c7] bg-[#fffefa] p-4 text-[#20201d] shadow-[0_10px_30px_rgba(30,30,20,0.06)]">
<div className="flex items-start gap-3">
<span className="grid size-8 shrink-0 place-items-center bg-[#dcff68]">
<Sparkles className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-[10px] font-semibold uppercase text-[#65713a]">
Proposed Work
</p>
<h2 className="mt-1 text-[15px] font-semibold leading-5">
{work.title}
</h2>
<p className="mt-1.5 text-[13px] leading-5 text-[#626057]">
{work.objective}
</p>
</div>
</div>
<button
className="mt-3 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs text-[#69675e]"
onClick={() => setSourcesOpen((open) => !open)}
type="button"
>
<span>
{sources.length} exact source{" "}
{sources.length === 1 ? "message" : "messages"}
</span>
<ChevronRight
className={`size-4 transition-transform ${sourcesOpen ? "rotate-90" : ""}`}
/>
</button>
<button
className="mt-2 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs font-medium text-[#20201d]"
onClick={() => setExpanded((open) => !open)}
type="button"
>
<span>{expanded ? "Hide Work details" : "Open Work details"}</span>
<ChevronRight
className={`size-4 transition-transform ${expanded ? "rotate-90" : ""}`}
/>
</button>
{sourcesOpen ? (
<div className="mt-3 space-y-2">
{sources.map((source) => (
<button
className="flex w-full items-start gap-2 border-l-2 border-[#a8b750] bg-[#f4f2e9] px-3 py-2 text-left text-xs leading-5 hover:bg-[#ece9dd]"
key={source.messageId}
onClick={() => onSourceSelect(source.rawText)}
type="button"
>
<MessageSquareText className="mt-1 size-3.5 shrink-0 text-[#65713a]" />
<span className="min-w-0 flex-1">{source.rawText}</span>
</button>
))}
</div>
) : null}
{expanded ? (
<div className="mt-4 space-y-4 border-t border-[#e7e3d9] pt-4 text-xs">
<section>
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
Outcome
</p>
<p className="mt-1 leading-5 text-[#626057]">{work.objective}</p>
<p className="mt-2 text-[#747168]">
Risk: {definition?.risk ?? "not defined"}
</p>
{definition?.questions?.length ? (
<p className="mt-1 text-amber-800">
{
definition.questions.filter(
(question) => question.status === "open"
).length
}{" "}
open question(s)
</p>
) : null}
<div className="mt-2 flex flex-wrap gap-2">
{work.status === "proposed" ? (
<Button
size="sm"
onClick={() => void slice.requestDefinition(work._id)}
>
<Sparkles className="size-3.5" /> Define
</Button>
) : null}
{work.status === "defining" ? (
<Button
size="sm"
variant="outline"
onClick={() =>
void slice.saveDefinition(work._id, starterDefinition(work))
}
>
<Hammer className="size-3.5" /> Save definition
</Button>
) : null}
{work.status === "awaiting-definition-approval" &&
work.definitionVersion ? (
<Button
size="sm"
onClick={() =>
void slice.approveDefinition(
work._id,
work.definitionVersion as number
)
}
>
<Check className="size-3.5" /> Approve
</Button>
) : null}
</div>
</section>
<section>
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
Design
</p>
<p className="mt-1 leading-5 text-[#626057]">
{design?.architectureSummary ?? "No Design Packet yet."}
</p>
{design?.slices?.map((item) => (
<p className="mt-1 text-[#747168]" key={item.id}>
{item.title}: {item.observableBehavior}
</p>
))}
<div className="mt-2 flex flex-wrap gap-2">
{work.status === "designing" ? (
<Button
size="sm"
variant="outline"
onClick={() =>
void slice.saveDesign(work._id, starterDesign(work))
}
>
<Hammer className="size-3.5" /> Save design
</Button>
) : null}
{work.status === "awaiting-design-approval" &&
work.definitionApprovalVersion &&
work.designVersion ? (
<Button
size="sm"
onClick={() =>
void slice.approveDesign(
work._id,
work.definitionApprovalVersion as number,
work.designVersion as number
)
}
>
<Check className="size-3.5" /> Approve design
</Button>
) : null}
</div>
</section>
<section>
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
Build
</p>
{latestRun ? (
<div className="mt-1 space-y-2 text-[#626057]">
<p className="leading-5">
{latestRun.executionKind === "real"
? "AgentOS"
: "Simulation"}{" "}
run {latestRun.status}:{" "}
{latestRun.terminalSummary ??
latestRun.terminalClassification ??
"activity is still arriving"}
</p>
{latestRun.attemptEvents?.slice(-5).map((item) => (
<p
className="border-l-2 border-[#b8c760] pl-2"
key={item._id}
>
{item.message}
</p>
))}
{latestRun.baseRevision ? (
<p className="font-mono text-[10px] text-[#747168]">
{latestRun.baseRevision.slice(0, 8)} {" "}
{latestRun.candidateRevision?.slice(0, 8) ?? "working"}
</p>
) : null}
{latestRun.artifacts?.map((artifact) => (
<a
className="block font-medium underline"
href={artifact.uri}
key={artifact._id}
rel="noreferrer"
target="_blank"
>
{artifact.title}
</a>
))}
</div>
) : (
<p className="mt-1 text-[#747168]">No implementation Run yet.</p>
)}
<div className="mt-2 flex flex-wrap gap-2">
{work.status === "ready" ? (
<Button
size="sm"
disabled={!slice.projectGitConnection}
onClick={() =>
void slice.startExecution(work._id, design?.slices?.[0]?.id)
}
>
<Play className="size-3.5" /> Run
</Button>
) : null}
{work.status === "ready" ? (
<Button
size="sm"
variant="outline"
onClick={() =>
void slice.startSimulation(
work._id,
"success",
design?.slices?.[0]?.id
)
}
>
<Play className="size-3.5" /> Simulate
</Button>
) : null}
{latestRun?.status === "running" ? (
<Button
size="sm"
variant="outline"
onClick={() =>
void (latestRun.executionKind === "real"
? slice.cancelExecution(latestRun._id)
: slice.cancelSimulation(latestRun._id))
}
>
<X className="size-3.5" /> Cancel
</Button>
) : null}
{latestRun?.status === "terminal" &&
latestRun.terminalClassification === "RetryableFailure" ? (
<Button
size="sm"
variant="outline"
onClick={() => void slice.retrySimulation(latestRun._id)}
>
<RotateCcw className="size-3.5" /> Retry
</Button>
) : null}
</div>
</section>
</div>
) : null}
</article>
);
};
const ConversationLoading = () => (
<output
aria-label="Loading conversation"
className="mx-auto flex min-h-[55vh] w-full max-w-md flex-col justify-center gap-4"
>
<span className="h-16 w-4/5 animate-pulse rounded-sm bg-[#e3e0d5]" />
<span className="ml-auto h-12 w-3/5 animate-pulse rounded-sm bg-[#dedbd0]" />
<span className="h-24 w-full animate-pulse rounded-sm bg-[#e3e0d5]" />
<span className="sr-only">Loading conversation</span>
</output>
);
const ConversationEmptyState = () => (
<div className="grid min-h-[55vh] place-items-center text-center">
<div>
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
<h1 className="mt-4 text-xl font-semibold">What should move forward?</h1>
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
Describe an outcome or problem. Casual conversation stays conversation.
</p>
</div>
</div>
);
type SliceOneState = ReturnType<typeof useSliceOne>;
const ProjectsLoading = () => (
<div className="grid min-h-svh place-items-center bg-[#f2f0e7]">
<LoaderCircle className="size-5 animate-spin" />
</div>
);
const ConnectProject = ({ slice }: { slice: SliceOneState }) => (
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] px-5 text-[#20201d]">
<form
className="w-full max-w-sm"
onSubmit={(event) => {
event.preventDefault();
void slice.connectRepository();
}}
>
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
<FolderGit2 className="size-5" />
</span>
<h1 className="mt-6 text-2xl font-semibold">Connect one project</h1>
<p className="mt-2 text-sm leading-6 text-[#68665e]">
Slice 1 turns actionable conversation into proposed Work with exact
provenance.
</p>
<input
aria-label="Public Git repository URL"
className="mt-6 h-12 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
onChange={(event) => slice.setRepository(event.target.value)}
placeholder="https://github.com/owner/repository"
required
value={slice.repository}
/>
{slice.error ? (
<p className="mt-2 text-xs text-red-700">{slice.error.message}</p>
) : null}
<Button
className="mt-3 h-12 w-full"
disabled={slice.pending}
type="submit"
>
{slice.pending ? (
<LoaderCircle className="size-4 animate-spin" />
) : null}
{slice.pending ? "Connecting" : "Connect project"}
</Button>
</form>
</main>
);
export const SliceOnePage = () => {
const slice = useSliceOne();
const viewportStyle = useVisualViewportStyle();
const [draft, setDraft] = useState("");
const attachments = useChatImages();
const imageInput = useRef<HTMLInputElement>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [giteaUrl, setGiteaUrl] = useState("https://git.openputer.com");
const [giteaUsername, setGiteaUsername] = useState("");
const [giteaToken, setGiteaToken] = useState("");
const [highlightedMessageId, setHighlightedMessageId] = useState<string>();
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const works = slice.works ?? EMPTY_WORKS;
const workById = useMemo(
() => new Map(works.map((work) => [String(work._id), work])),
[works]
);
const notices = useMemo(() => projectWorkNotices(works), [works]);
const timeline = useMemo(
() => buildSliceOneTimeline(slice.agent.messages, notices),
[notices, slice.agent.messages]
);
const busy =
slice.agent.status === "submitted" || slice.agent.status === "streaming";
const revealSourceMessage = (rawText: string) => {
const messageId = findSourceMessageTarget(slice.agent.messages, rawText);
if (!messageId) {
return;
}
setDrawerOpen(false);
setHighlightedMessageId(messageId);
if (highlightTimer.current) {
clearTimeout(highlightTimer.current);
}
requestAnimationFrame(() => {
document
.querySelector(`#${CSS.escape(`slice-message-${messageId}`)}`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
});
highlightTimer.current = setTimeout(
() => setHighlightedMessageId(undefined),
1800
);
};
if (slice.projects === undefined) {
return <ProjectsLoading />;
}
if (!slice.selectedProject) {
return <ConnectProject slice={slice} />;
}
const send = async () => {
const message = draft.trim();
if (!message || busy) {
return;
}
await slice.agent.sendMessage(message, {
images: attachments.images.map((image) => image.file),
});
setDraft("");
attachments.clear();
};
return (
<main
className="slice-one-surface fixed inset-x-0 top-0 flex min-h-0 overflow-hidden bg-[#f2f0e7] text-[#20201d]"
style={viewportStyle}
>
<section className="flex min-w-0 flex-1 flex-col">
<header className="relative flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
<div className="min-w-0 flex-1 pr-2">
<select
aria-label="Current project"
className="block h-7 max-w-full border-0 bg-transparent pr-7 text-sm font-semibold text-[#20201d] outline-none"
onChange={(event) => slice.selectProject(event.target.value)}
value={slice.selectedProject.id}
>
{slice.projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
<p className="text-[10px] uppercase text-[#858277]">
Conversation to proposed Work
</p>
</div>
<Button
aria-label="Project settings"
className="mr-2 size-9"
onClick={() => setSettingsOpen((open) => !open)}
size="icon"
variant="outline"
>
<Settings className="size-4" />
</Button>
<button
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
onClick={() => setDrawerOpen(true)}
type="button"
>
<Menu className="size-4" /> Work{" "}
{slice.works === undefined ? "…" : works.length}
</button>
{settingsOpen ? (
<section className="absolute right-4 top-12 z-40 w-[min(92vw,360px)] border border-[#c9c5b9] bg-[#fffefa] p-4 shadow-xl">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">Project Git</h2>
<button
aria-label="Close settings"
onClick={() => setSettingsOpen(false)}
type="button"
>
<X className="size-4" />
</button>
</div>
<p className="mt-1 text-xs text-[#747168]">
{slice.projectGitConnection
? `${slice.projectGitConnection.provider} · ${slice.projectGitConnection.serverUrl}`
: "No Git credentials attached"}
</p>
<div className="mt-4 flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void slice.authorizeGithub()}
>
Authorize GitHub
</Button>
<Button
size="sm"
onClick={() => void slice.connectLinkedGithub()}
>
Use GitHub
</Button>
</div>
<div className="mt-4 space-y-2 border-t border-[#e7e3d9] pt-4">
<input
aria-label="Gitea server URL"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setGiteaUrl(event.target.value)}
value={giteaUrl}
/>
<input
aria-label="Gitea username"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setGiteaUsername(event.target.value)}
placeholder="Username (optional)"
value={giteaUsername}
/>
<input
aria-label="Gitea access token"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setGiteaToken(event.target.value)}
placeholder="Personal access token"
type="password"
value={giteaToken}
/>
<Button
className="w-full"
disabled={!giteaToken.trim()}
size="sm"
onClick={() => {
void slice.connectGitea({
serverUrl: giteaUrl,
token: giteaToken,
username: giteaUsername || undefined,
});
setGiteaToken("");
}}
>
Connect Gitea
</Button>
</div>
</section>
) : null}
</header>
<Conversation className="min-h-0 flex-1">
<ConversationContent className="mx-auto min-h-full w-full max-w-2xl gap-4 px-4 py-5 sm:px-6">
{!slice.agent.historyReady && timeline.length === 0 ? (
<ConversationLoading />
) : null}
{slice.agent.historyReady && timeline.length === 0 ? (
<ConversationEmptyState />
) : null}
{timeline.map((item) => {
if (item.kind === "work") {
const work = workById.get(item.notice.workId);
return work ? (
<div
className="chat-message ml-7"
key={`notice-${item.notice.eventId}`}
>
<p className="mb-2 text-[10px] font-semibold uppercase text-[#65713a]">
Work proposed from this conversation
</p>
<WorkCard
onSourceSelect={revealSourceMessage}
slice={slice}
work={work}
/>
</div>
) : null;
}
return (
<div
className={`rounded-sm transition-colors duration-300 ${
highlightedMessageId === item.message.id
? "bg-[#dcff68]/70 ring-2 ring-[#7f9130] ring-offset-4 ring-offset-[#f2f0e7]"
: ""
}`}
id={`slice-message-${item.message.id}`}
key={item.message.id}
>
<ChatMessage message={item.message} />
</div>
);
})}
{slice.agent.status === "submitted" ? (
<ChatThinkingResponse />
) : null}
</ConversationContent>
</Conversation>
<div className="shrink-0 border-t border-[#d7d3c7] bg-[#faf9f4] p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
{attachments.images.length > 0 ? (
<div className="mx-auto max-w-2xl">
<PendingChatAttachments
images={attachments.images}
onRemove={attachments.handleRemove}
/>
</div>
) : null}
{slice.agent.error ? (
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
{slice.agent.error.message}
</p>
) : null}
{attachments.error ? (
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
{attachments.error}
</p>
) : null}
<div className="mx-auto flex max-w-2xl items-end gap-2">
<input
accept="image/*"
aria-label="Attach images"
className="sr-only"
multiple
onChange={(event) => {
attachments.addFiles(event.target.files);
event.target.value = "";
}}
ref={imageInput}
type="file"
/>
<Button
aria-label="Attach images"
className="size-11 shrink-0"
disabled={busy}
onClick={() => imageInput.current?.click()}
size="icon"
type="button"
variant="outline"
>
<ImagePlus className="size-4" />
</Button>
<textarea
aria-label="Message Zopu"
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-sm outline-none focus:border-[#55564e]"
disabled={busy}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void send();
}
}}
placeholder="Describe an outcome or problem…"
rows={1}
value={draft}
/>
<Button
aria-label="Send message"
className="size-11 shrink-0"
disabled={!draft.trim() || busy}
onClick={() => void send()}
size="icon"
type="button"
>
{busy ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
</section>
<aside className="hidden w-[380px] shrink-0 overflow-y-auto border-l border-[#d7d3c7] bg-[#e9e7de] p-4 lg:block">
<h2 className="text-sm font-semibold">Proposed Work</h2>
<p className="mb-4 text-xs text-[#747168]">
{works.length} durable outcomes
</p>
<div className="space-y-3">
{works.map((work) => (
<WorkCard
key={work._id}
onSourceSelect={revealSourceMessage}
slice={slice}
work={work}
/>
))}
</div>
</aside>
{drawerOpen ? (
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden">
<button
aria-label="Close Work drawer"
className="absolute inset-0"
onClick={() => setDrawerOpen(false)}
type="button"
/>
<section className="absolute inset-y-0 right-0 flex w-[min(92vw,380px)] flex-col bg-[#e9e7de] shadow-2xl">
<header className="flex h-14 items-center border-b border-[#cfcbc0] px-4">
<h2 className="flex-1 text-sm font-semibold">Proposed Work</h2>
<button
aria-label="Close Work drawer"
className="grid size-9 place-items-center"
onClick={() => setDrawerOpen(false)}
type="button"
>
<X className="size-4" />
</button>
</header>
<div className="flex-1 space-y-3 overflow-y-auto p-4">
{works.length === 0 ? (
<p className="py-12 text-center text-sm text-[#747168]">
Actionable messages will appear here.
</p>
) : null}
{works.map((work) => (
<WorkCard
key={work._id}
onSourceSelect={revealSourceMessage}
slice={slice}
work={work}
/>
))}
</div>
</section>
</div>
) : null}
</main>
);
};

View File

@@ -0,0 +1,112 @@
import { Button } from "@code/ui/components/button";
import { ImagePlus, LoaderCircle, Send } from "lucide-react";
import { useRef } from "react";
import { PendingChatAttachments } from "@/components/chat/chat-attachments";
import { useChatImages } from "@/hooks/chat/use-chat-images";
import type { WorkspaceState } from "@/lib/workspace/types";
export const ConversationComposer = ({
draft,
onDraftChange,
workspace,
}: {
readonly draft: string;
readonly onDraftChange: (draft: string) => void;
readonly workspace: WorkspaceState;
}) => {
const imageInput = useRef<HTMLInputElement>(null);
const attachments = useChatImages();
const busy =
workspace.agent.status === "submitted" ||
workspace.agent.status === "streaming";
const send = async () => {
const message = draft.trim();
if (!message || busy) {
return;
}
await workspace.agent.sendMessage(message, {
images: attachments.images.map((image) => image.file),
});
onDraftChange("");
attachments.clear();
};
return (
<div className="shrink-0 border-t border-[#d7d3c7] bg-[#faf9f4] p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
{attachments.images.length > 0 ? (
<div className="mx-auto max-w-2xl">
<PendingChatAttachments
images={attachments.images}
onRemove={attachments.handleRemove}
/>
</div>
) : null}
{workspace.agent.error ? (
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
{workspace.agent.error.message}
</p>
) : null}
{attachments.error ? (
<p className="mx-auto mb-2 max-w-2xl text-xs text-red-700">
{attachments.error}
</p>
) : null}
<div className="mx-auto flex max-w-2xl items-end gap-2">
<input
accept="image/*"
aria-label="Attach images"
className="sr-only"
multiple
onChange={(event) => {
attachments.addFiles(event.target.files);
event.target.value = "";
}}
ref={imageInput}
type="file"
/>
<Button
aria-label="Attach images"
className="size-11 shrink-0"
disabled={busy}
onClick={() => imageInput.current?.click()}
size="icon"
type="button"
variant="outline"
>
<ImagePlus className="size-4" />
</Button>
<textarea
aria-label="Message Zopu"
className="max-h-32 min-h-11 flex-1 resize-none border border-[#c9c5b9] bg-white px-3 py-2.5 text-base outline-none focus:border-[#55564e]"
disabled={busy}
onChange={(event) => onDraftChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void send();
}
}}
placeholder="Describe an outcome or problem…"
rows={1}
value={draft}
/>
<Button
aria-label="Send message"
className="size-11 shrink-0"
disabled={!draft.trim() || busy}
onClick={() => void send()}
size="icon"
type="button"
>
{busy ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,108 @@
import { projectWorkNotices } from "@code/primitives/work";
import {
Conversation,
ConversationContent,
} from "@code/ui/components/ai-elements/conversation";
import { MessageSquareText } from "lucide-react";
import { useMemo } from "react";
import { ChatMessage } from "@/components/chat/chat-message";
import { ChatThinkingResponse } from "@/components/chat/chat-thinking-response";
import { buildWorkspaceTimeline } from "@/lib/workspace/presentation";
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
import { WorkCard } from "./work-card";
const ConversationLoading = () => (
<output
aria-label="Loading conversation"
className="mx-auto flex min-h-[55vh] w-full max-w-md flex-col justify-center gap-4"
>
<span className="h-16 w-4/5 animate-pulse rounded-sm bg-[#e3e0d5]" />
<span className="ml-auto h-12 w-3/5 animate-pulse rounded-sm bg-[#dedbd0]" />
<span className="h-24 w-full animate-pulse rounded-sm bg-[#e3e0d5]" />
<span className="sr-only">Loading conversation</span>
</output>
);
const ConversationEmptyState = () => (
<div className="grid min-h-[55vh] place-items-center text-center">
<div>
<MessageSquareText className="mx-auto size-7 text-[#8a887f]" />
<h1 className="mt-4 text-xl font-semibold">What should move forward?</h1>
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-[#69675f]">
Describe an outcome or problem. Casual conversation stays conversation.
</p>
</div>
</div>
);
export const ConversationFeed = ({
highlightedMessageId,
onSourceSelect,
workspace,
}: {
readonly highlightedMessageId?: string;
readonly onSourceSelect: (rawText: string) => void;
readonly workspace: WorkspaceState;
}) => {
const works = workspace.works ?? [];
const workById = new Map(
works.map((work) => [String(work._id), work] as const)
);
const notices = projectWorkNotices(works);
const timeline = useMemo(
() => buildWorkspaceTimeline(workspace.agent.messages, notices),
[notices, workspace.agent.messages]
);
return (
<Conversation className="min-h-0 flex-1">
<ConversationContent className="mx-auto min-h-full w-full max-w-2xl gap-4 px-4 py-5 sm:px-6">
{!workspace.agent.historyReady && timeline.length === 0 ? (
<ConversationLoading />
) : null}
{workspace.agent.historyReady && timeline.length === 0 ? (
<ConversationEmptyState />
) : null}
{timeline.map((item) => {
if (item.kind === "work") {
const work = workById.get(item.notice.workId) as
| WorkRecord
| undefined;
return work ? (
<div
className="chat-message ml-7"
key={`notice-${item.notice.eventId}`}
>
<p className="mb-2 text-[10px] font-semibold uppercase text-[#65713a]">
Work proposed from this conversation
</p>
<div className="rounded-sm">
<span className="sr-only">Proposed Work</span>
<WorkCard
onSourceSelect={onSourceSelect}
work={work}
workspace={workspace}
/>
</div>
</div>
) : null;
}
return (
<div
className={`rounded-sm transition-colors duration-300 ${highlightedMessageId === item.message.id ? "bg-[#dcff68]/70 ring-2 ring-[#7f9130] ring-offset-4 ring-offset-[#f2f0e7]" : ""}`}
id={`workspace-message-${item.message.id}`}
key={item.message.id}
>
<ChatMessage message={item.message} />
</div>
);
})}
{workspace.agent.status === "submitted" ? (
<ChatThinkingResponse />
) : null}
</ConversationContent>
</Conversation>
);
};

View File

@@ -0,0 +1,50 @@
import { Button } from "@code/ui/components/button";
import { FolderGit2, LoaderCircle } from "lucide-react";
import type { WorkspaceState } from "@/lib/workspace/types";
export const ProjectConnectForm = ({
workspace,
}: {
readonly workspace: WorkspaceState;
}) => (
<main className="grid min-h-svh place-items-center bg-[#f2f0e7] px-5 text-[#20201d]">
<form
className="w-full max-w-sm"
onSubmit={(event) => {
event.preventDefault();
void workspace.connectRepository();
}}
>
<span className="grid size-11 place-items-center bg-[#20201d] text-white">
<FolderGit2 className="size-5" />
</span>
<h1 className="mt-6 text-2xl font-semibold">Connect a project</h1>
<p className="mt-2 text-sm leading-6 text-[#68665e]">
Turn actionable project conversation into proposed Work with exact
provenance.
</p>
<input
aria-label="Public Git repository URL"
className="mt-6 h-12 w-full border border-[#c9c5b9] bg-[#fffefa] px-3 text-sm outline-none focus:border-[#55564e]"
onChange={(event) => workspace.setRepository(event.target.value)}
placeholder="https://github.com/owner/repository"
required
value={workspace.repository}
/>
{workspace.error ? (
<p className="mt-2 text-xs text-red-700">{workspace.error.message}</p>
) : null}
<Button
className="mt-3 h-12 w-full"
disabled={workspace.pending}
type="submit"
>
{workspace.pending ? (
<LoaderCircle className="size-4 animate-spin" />
) : null}
{workspace.pending ? "Connecting" : "Connect project"}
</Button>
</form>
</main>
);

View File

@@ -0,0 +1,63 @@
import { Button } from "@code/ui/components/button";
import { Menu, Settings } from "lucide-react";
import { useState } from "react";
import type { WorkspaceState } from "@/lib/workspace/types";
import { ProjectSettingsPanel } from "./project-settings-panel";
export const ProjectHeader = ({
onOpenDrawer,
workspace,
}: {
readonly onOpenDrawer: () => void;
readonly workspace: WorkspaceState;
}) => {
const [settingsOpen, setSettingsOpen] = useState(false);
const works = workspace.works ?? [];
return (
<header className="relative flex h-14 shrink-0 items-center border-b border-[#d7d3c7] bg-[#faf9f4] px-4">
<div className="min-w-0 flex-1 pr-2">
<select
aria-label="Current project"
className="block h-7 max-w-full border-0 bg-transparent pr-7 text-sm font-semibold text-[#20201d] outline-none"
onChange={(event) => workspace.selectProject(event.target.value)}
value={workspace.selectedProject?.id ?? ""}
>
{(workspace.projects ?? []).map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
<p className="text-[10px] uppercase text-[#858277]">
Conversation to proposed Work
</p>
</div>
<Button
aria-label="Project settings"
className="mr-2 size-9"
onClick={() => setSettingsOpen((open) => !open)}
size="icon"
variant="outline"
>
<Settings className="size-4" />
</Button>
<button
className="flex h-9 items-center gap-2 border border-[#c9c5b9] bg-white px-3 text-xs lg:hidden"
onClick={onOpenDrawer}
type="button"
>
<Menu className="size-4" /> Work{" "}
{workspace.works === undefined ? "…" : works.length}
</button>
{settingsOpen ? (
<ProjectSettingsPanel
onClose={() => setSettingsOpen(false)}
workspace={workspace}
/>
) : null}
</header>
);
};

View File

@@ -0,0 +1,92 @@
import { Button } from "@code/ui/components/button";
import { AlertTriangle, X } from "lucide-react";
import { useState } from "react";
import type { WorkspaceState } from "@/lib/workspace/types";
export const ProjectSettingsPanel = ({
onClose,
workspace,
}: {
readonly onClose: () => void;
readonly workspace: WorkspaceState;
}) => {
const [username, setUsername] = useState("");
const [token, setToken] = useState("");
const handleClearOperationError = () => workspace.clearOperationError();
return (
<section className="absolute right-4 top-12 z-40 w-[min(92vw,360px)] border border-[#c9c5b9] bg-[#fffefa] p-4 shadow-xl">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">Project Git</h2>
<button aria-label="Close settings" onClick={onClose} type="button">
<X className="size-4" />
</button>
</div>
<p className="mt-1 text-xs text-[#747168]">
{workspace.projectGitConnection
? `${workspace.projectGitConnection.provider} · ${workspace.projectGitConnection.serverUrl}`
: "No Git credentials attached"}
</p>
{workspace.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">
{workspace.operationError.message}
</span>
<button
aria-label="Dismiss error"
className="shrink-0"
onClick={handleClearOperationError}
type="button"
>
<X className="size-3.5" />
</button>
</p>
) : null}
<div className="mt-4 flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void workspace.authorizeGithub()}
>
Authorize GitHub
</Button>
<Button size="sm" onClick={() => void workspace.connectLinkedGithub()}>
Use GitHub
</Button>
</div>
<div className="mt-4 space-y-2 border-t border-[#e7e3d9] pt-4">
<input
aria-label="Puter Git username"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setUsername(event.target.value)}
placeholder="Username (optional)"
value={username}
/>
<input
aria-label="Gitea access token"
className="h-9 w-full border px-2 text-xs"
onChange={(event) => setToken(event.target.value)}
placeholder="Personal access token"
type="password"
value={token}
/>
<Button
className="w-full"
disabled={!token.trim()}
size="sm"
onClick={() => {
void workspace.connectGitea({
token,
username: username || undefined,
});
setToken("");
}}
>
Connect Gitea
</Button>
</div>
</section>
);
};

View File

@@ -0,0 +1,123 @@
import { useRef, useState } from "react";
import { useProjectWorkspace } from "@/hooks/workspace/use-project-workspace";
import { useVisualViewportStyle } from "@/hooks/workspace/use-visual-viewport";
import { findSourceMessageTarget } from "@/lib/workspace/presentation";
import { ConversationComposer } from "./conversation-composer";
import { ConversationFeed } from "./conversation-feed";
import { ProjectConnectForm } from "./project-connect-form";
import { ProjectHeader } from "./project-header";
import { WorkFeed } from "./work-feed";
const ProjectLoading = () => (
<main className="grid min-h-svh place-items-center bg-[#f2f0e7]">
<span className="size-5 animate-spin rounded-full border-2 border-[#20201d] border-t-transparent" />
</main>
);
export const ProjectWorkspacePage = () => {
const workspace = useProjectWorkspace();
const viewportStyle = useVisualViewportStyle();
const [draft, setDraft] = useState("");
const [drawerOpen, setDrawerOpen] = useState(false);
const [highlightedMessageId, setHighlightedMessageId] = useState<string>();
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
if (workspace.projects === undefined) {
return <ProjectLoading />;
}
if (!workspace.selectedProject) {
return <ProjectConnectForm workspace={workspace} />;
}
const works = workspace.works ?? [];
const revealSourceMessage = (rawText: string) => {
const messageId = findSourceMessageTarget(
workspace.agent.messages,
rawText
);
if (!messageId) {
return;
}
setDrawerOpen(false);
setHighlightedMessageId(messageId);
if (highlightTimer.current) {
clearTimeout(highlightTimer.current);
}
requestAnimationFrame(() =>
document
.querySelector(`#workspace-message-${CSS.escape(messageId)}`)
?.scrollIntoView({ behavior: "smooth", block: "center" })
);
highlightTimer.current = setTimeout(
() => setHighlightedMessageId(undefined),
1800
);
};
return (
<main
className="workspace-surface fixed inset-x-0 top-0 flex min-h-0 overflow-hidden bg-[#f2f0e7] text-[#20201d]"
style={viewportStyle}
>
<section className="flex min-w-0 flex-1 flex-col">
<ProjectHeader
onOpenDrawer={() => setDrawerOpen(true)}
workspace={workspace}
/>
<ConversationFeed
highlightedMessageId={highlightedMessageId}
onSourceSelect={revealSourceMessage}
workspace={workspace}
/>
<ConversationComposer
draft={draft}
onDraftChange={setDraft}
workspace={workspace}
/>
</section>
<aside className="hidden w-[380px] shrink-0 overflow-y-auto border-l border-[#d7d3c7] bg-[#e9e7de] p-4 lg:block">
<h2 className="text-sm font-semibold">Proposed Work</h2>
<p className="mb-4 text-xs text-[#747168]">
{works.length} durable outcomes
</p>
<WorkFeed
onSourceSelect={revealSourceMessage}
works={works}
workspace={workspace}
/>
</aside>
{drawerOpen ? (
<div className="fixed inset-0 z-50 bg-black/30 lg:hidden">
<button
aria-label="Close Work drawer"
className="absolute inset-0"
onClick={() => setDrawerOpen(false)}
type="button"
/>
<section className="absolute inset-y-0 right-0 flex w-[min(92vw,380px)] flex-col bg-[#e9e7de] shadow-2xl">
<header className="flex h-14 items-center border-b border-[#cfcbc0] px-4">
<h2 className="flex-1 text-sm font-semibold">Proposed Work</h2>
<button
aria-label="Close Work drawer"
className="grid size-9 place-items-center"
onClick={() => setDrawerOpen(false)}
type="button"
>
×
</button>
</header>
<div className="flex-1 overflow-y-auto p-4">
<WorkFeed
onSourceSelect={revealSourceMessage}
works={works}
workspace={workspace}
/>
</div>
</section>
</div>
) : null}
</main>
);
};

View File

@@ -0,0 +1,395 @@
import { Button } from "@code/ui/components/button";
import {
AlertTriangle,
Check,
ChevronRight,
FileCode2,
Hammer,
Play,
RotateCcw,
ScrollText,
Sparkles,
X,
} from "lucide-react";
import { useState } from "react";
import { changedFilesFor } from "@/lib/workspace/types";
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
interface WorkCardProps {
readonly onSourceSelect: (rawText: string) => void;
readonly work: WorkRecord;
readonly workspace: WorkspaceState;
}
const starterDefinition = (work: WorkRecord) => ({
acceptanceCriteria: ["The requested outcome is observable and documented"],
affectedUsers: ["Project users"],
assumptions: [],
constraints: [],
desiredOutcome: work.objective,
inScope: [work.objective],
outOfScope: ["Unrelated product changes"],
problem: work.objective,
questions: [],
requiredArtifacts: ["Simulation activity and terminal outcome"],
risk: "medium",
});
const starterDesign = (work: WorkRecord) => ({
architectureSummary:
"Validate the approved Definition, then exercise one deterministic fake slice.",
callFlowDelta: [
"Work -> Run -> Attempt -> normalized events -> terminal outcome",
],
concerns: [],
evidenceRequirements: ["Terminal Run classification"],
fileTreeDelta: [],
impactMap: {
files: [],
modules: [],
risks: [],
summary: "Compact vertical-slice simulation",
},
invariants: [
"Simulation never claims implementation",
"Every Attempt reaches a terminal classification",
],
keyInterfaces: ["HarnessRuntime", "AttemptOutcome"],
slices: [
{
codeBoundaries: ["workExecution"],
dependsOn: [],
evidenceRequirements: ["Normalized activity events"],
id: "deterministic-simulation",
objective: work.objective,
observableBehavior: "A terminal fake Run is visible",
reviewRequired: false,
title: "Deterministic simulation",
verification: ["Run completes with a terminal classification"],
},
],
tradeoffs: ["Fake runtime proves contract before sandbox integration"],
});
// oxlint-disable-next-line complexity -- this card intentionally coordinates review actions and run evidence.
export const WorkCard = ({
onSourceSelect,
work,
workspace,
}: WorkCardProps) => {
const [sourcesOpen, setSourcesOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const sources = work.signals.flatMap((signal) => signal.sources);
const [latestRun] = work.runs;
const openQuestions =
work.definition?.questions?.filter((question) => question.status === "open")
.length ?? 0;
return (
<article className="border border-[#d7d3c7] bg-[#fffefa] p-4 text-[#20201d] shadow-[0_10px_30px_rgba(30,30,20,0.06)]">
<div className="flex items-start gap-3">
<span className="grid size-8 shrink-0 place-items-center bg-[#dcff68]">
<Sparkles className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-[10px] font-semibold uppercase text-[#65713a]">
Proposed Work
</p>
<h2 className="mt-1 text-[15px] font-semibold leading-5">
{work.title}
</h2>
<p className="mt-1.5 text-[13px] leading-5 text-[#626057]">
{work.objective}
</p>
</div>
</div>
<button
className="mt-3 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs text-[#69675e]"
onClick={() => setSourcesOpen((open) => !open)}
type="button"
>
<span>
{sources.length} exact source{" "}
{sources.length === 1 ? "message" : "messages"}
</span>
<ChevronRight
className={`size-4 transition-transform ${sourcesOpen ? "rotate-90" : ""}`}
/>
</button>
<button
className="mt-2 flex w-full items-center justify-between border-t border-[#e7e3d9] pt-3 text-left text-xs font-medium text-[#20201d]"
onClick={() => setExpanded((open) => !open)}
type="button"
>
<span>{expanded ? "Hide Work details" : "Open Work details"}</span>
<ChevronRight
className={`size-4 transition-transform ${expanded ? "rotate-90" : ""}`}
/>
</button>
{sourcesOpen ? (
<div className="mt-3 space-y-2">
{sources.map((source) => (
<button
className="flex w-full items-start gap-2 border-l-2 border-[#a8b750] bg-[#f4f2e9] px-3 py-2 text-left text-xs leading-5 hover:bg-[#ece9dd]"
key={source.messageId}
onClick={() => onSourceSelect(source.rawText)}
type="button"
>
<span className="min-w-0 flex-1">{source.rawText}</span>
</button>
))}
</div>
) : null}
{expanded ? (
<div className="mt-4 space-y-4 border-t border-[#e7e3d9] pt-4 text-xs">
<section>
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
Outcome
</p>
<p className="mt-1 leading-5 text-[#626057]">{work.objective}</p>
<p className="mt-2 text-[#747168]">
Risk: {work.definition?.risk ?? "not defined"}
</p>
{openQuestions > 0 ? (
<p className="mt-1 text-amber-800">
{openQuestions} open question(s)
</p>
) : null}
<div className="mt-2 flex flex-wrap gap-2">
{work.status === "proposed" ? (
<Button
size="sm"
onClick={() => void workspace.requestDefinition(work._id)}
>
<Sparkles className="size-3.5" /> Define
</Button>
) : null}
{work.status === "defining" ? (
<Button
size="sm"
variant="outline"
onClick={() =>
void workspace.saveDefinition(
work._id,
starterDefinition(work)
)
}
>
<Hammer className="size-3.5" /> Save definition
</Button>
) : null}
{work.status === "awaiting-definition-approval" &&
work.definitionVersion ? (
<Button
size="sm"
onClick={() =>
void workspace.approveDefinition(
work._id,
work.definitionVersion as number
)
}
>
<Check className="size-3.5" /> Approve
</Button>
) : null}
</div>
</section>
<section>
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
Design
</p>
<p className="mt-1 leading-5 text-[#626057]">
{work.design?.architectureSummary ?? "No Design Packet yet."}
</p>
{work.design?.slices?.map((item) => (
<p className="mt-1 text-[#747168]" key={item.id}>
{item.title}: {item.observableBehavior}
</p>
))}
<div className="mt-2 flex flex-wrap gap-2">
{work.status === "designing" ? (
<Button
size="sm"
variant="outline"
onClick={() =>
void workspace.saveDesign(work._id, starterDesign(work))
}
>
<Hammer className="size-3.5" /> Save design
</Button>
) : null}
{work.status === "awaiting-design-approval" &&
work.definitionApprovalVersion &&
work.designVersion ? (
<Button
size="sm"
onClick={() =>
void workspace.approveDesign(
work._id,
work.definitionApprovalVersion as number,
work.designVersion as number
)
}
>
<Check className="size-3.5" /> Approve design
</Button>
) : null}
</div>
</section>
<section>
<p className="font-semibold uppercase tracking-wide text-[#65713a]">
Build
</p>
{workspace.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">
{workspace.operationError.message}
</span>
<button
aria-label="Dismiss error"
className="shrink-0"
onClick={() => workspace.clearOperationError()}
type="button"
>
<X className="size-3.5" />
</button>
</p>
) : null}
{latestRun ? (
<div className="mt-1 space-y-2 text-[#626057]">
<p className="leading-5">
{latestRun.executionKind === "real"
? "AgentOS"
: "Simulation"}{" "}
run {latestRun.status}:{" "}
{latestRun.terminalSummary ??
latestRun.terminalClassification ??
"activity is still arriving"}
</p>
{latestRun.baseRevision ? (
<p className="font-mono text-[10px] text-[#747168]">
{latestRun.baseRevision.slice(0, 8)} {" "}
{latestRun.candidateRevision?.slice(0, 8) ?? "working"}
</p>
) : null}
{latestRun.artifacts?.map((artifact) => (
<div key={artifact._id} className="space-y-1">
<p className="font-medium">
{artifact.uri ? (
<a
className="underline"
href={artifact.uri}
rel="noreferrer"
target="_blank"
>
{artifact.title}
</a>
) : (
artifact.title
)}
</p>
{changedFilesFor(artifact).length > 0 ? (
<ul className="space-y-0.5">
{changedFilesFor(artifact).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>
) : (
<p className="mt-1 text-[#747168]">No implementation Run yet.</p>
)}
<div className="mt-2 flex flex-wrap gap-2">
{work.status === "ready" ? (
<>
<Button
size="sm"
disabled={!workspace.projectGitConnection}
onClick={() => void workspace.startExecution(work._id)}
>
<Play className="size-3.5" /> Run
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
void workspace.startSimulation(work._id, "success")
}
>
<Play className="size-3.5" /> Simulate
</Button>
</>
) : null}
{latestRun?.status === "running" ? (
<Button
size="sm"
variant="outline"
onClick={() =>
void (latestRun.executionKind === "real"
? workspace.cancelExecution(latestRun._id)
: workspace.cancelSimulation(latestRun._id))
}
>
<X className="size-3.5" /> Cancel
</Button>
) : null}
{latestRun?.executionKind !== "real" &&
latestRun?.status === "terminal" &&
latestRun.terminalClassification === "RetryableFailure" ? (
<Button
size="sm"
variant="outline"
onClick={() => void workspace.retrySimulation(latestRun._id)}
>
<RotateCcw className="size-3.5" /> Retry
</Button>
) : null}
</div>
</section>
</div>
) : null}
</article>
);
};

View File

@@ -0,0 +1,29 @@
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
import { WorkCard } from "./work-card";
export const WorkFeed = ({
onSourceSelect,
works,
workspace,
}: {
readonly onSourceSelect: (rawText: string) => void;
readonly works: readonly WorkRecord[];
readonly workspace: WorkspaceState;
}) => (
<div className="space-y-3">
{works.length === 0 ? (
<p className="py-12 text-center text-sm text-[#747168]">
Actionable messages will appear here.
</p>
) : null}
{works.map((work) => (
<WorkCard
key={work._id}
onSourceSelect={onSourceSelect}
work={work}
workspace={workspace}
/>
))}
</div>
);

View File

@@ -55,7 +55,12 @@ export const useOrganizationChatAgent = (
const projected = projectConversation(rows ?? []);
let status: AgentStatus = projected.pending ? "submitted" : "idle";
let status: AgentStatus = "idle";
if (projected.streaming) {
status = "streaming";
} else if (projected.pending) {
status = "submitted";
}
if (!organizationId || rows === undefined) {
status = organization.error ? "error" : "connecting";
} else if (projected.failedError || sendError) {

View File

@@ -4,61 +4,21 @@ import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useAction, useMutation, useQuery } from "convex/react";
import { makeFunctionReference } from "convex/server";
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import { useOrganizationChatAgent } from "@/hooks/chat/use-chat-agent";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import type { WorkRecord, WorkspaceState } from "@/lib/workspace/types";
const toError = (error: unknown) =>
error instanceof Error ? error : new Error(String(error));
interface WorkRecord {
readonly _id: Id<"works">;
readonly title: string;
readonly objective: string;
readonly status: string;
readonly definitionVersion?: number;
readonly definitionApprovalVersion?: number;
readonly designVersion?: number;
readonly designApprovalVersion?: number;
readonly signals: readonly {
sources: readonly { messageId: string; rawText: string }[];
}[];
readonly events: readonly { _id: string; createdAt: number; kind: string }[];
readonly definitions: readonly unknown[];
readonly designs: readonly unknown[];
readonly slices: readonly unknown[];
readonly runs: readonly {
readonly artifacts?: readonly {
_id: string;
kind: string;
metadataJson: string;
sourceRevision?: string;
title: string;
uri?: string;
}[];
readonly attemptEvents?: readonly {
_id: string;
kind: string;
message: string;
occurredAt: number;
}[];
readonly baseRevision?: string;
readonly candidateRevision?: string;
readonly executionKind?: string;
_id: Id<"workRuns">;
status: string;
terminalClassification?: string;
terminalSummary?: string;
}[];
readonly definition: {
risk?: string;
questions?: { status: string }[];
} | null;
readonly design: {
architectureSummary?: string;
slices?: { id: string; title: string; observableBehavior: string }[];
} | null;
}
type ExecutionScenario =
| "success"
| "transient-failure-then-success"
| "needs-input"
| "permanent-failure"
| "cancelled";
const workListRef = makeFunctionReference<
"query",
@@ -92,16 +52,7 @@ const approveDesignRef = makeFunctionReference<
>("workPlanning:approveDesign");
const startSimulationRef = makeFunctionReference<
"mutation",
{
workId: Id<"works">;
scenario:
| "success"
| "transient-failure-then-success"
| "needs-input"
| "permanent-failure"
| "cancelled";
sliceId?: string;
},
{ workId: Id<"works">; scenario: ExecutionScenario; sliceId?: string },
unknown
>("workExecution:startSimulatedExecution");
const cancelSimulationRef = makeFunctionReference<
@@ -146,7 +97,7 @@ const projectGitConnectionRef = makeFunctionReference<
>("gitConnectionData:getForProject");
const connectGiteaRef = makeFunctionReference<
"action",
{ serverUrl: string; token: string; username?: string },
{ token: string; username?: string },
{ connectionId: Id<"gitConnections"> }
>("gitConnections:connectGitea");
const connectGithubRef = makeFunctionReference<
@@ -160,13 +111,7 @@ const attachGitConnectionRef = makeFunctionReference<
unknown
>("gitConnectionData:attachToProject");
const authorizeGithub = () =>
authClient.signIn.social({
callbackURL: window.location.href,
provider: "github",
});
export const useSliceOne = () => {
export const useProjectWorkspace = (): WorkspaceState => {
const organization = usePersonalOrganization();
const projects = useQuery(
api.projects.list,
@@ -174,16 +119,25 @@ export const useSliceOne = () => {
);
const importPublicGit = useAction(api.projects.importPublicGit);
const agent = useOrganizationChatAgent(organization);
const [searchParams] = useSearchParams();
const [selectedProjectId, setSelectedProjectId] =
useState<Id<"projects"> | null>(null);
const [repository, setRepository] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<Error>();
const [operationError, setOperationError] = useState<Error>();
const projectParam = searchParams.get("project");
const paramProject = projectParam
? (projectParam as unknown as Id<"projects">)
: null;
const selectedProjectStillExists = projects?.some(
(project) => project.id === (selectedProjectId as unknown as string)
(project) =>
project.id === (selectedProjectId as unknown as string) ||
project.id === (paramProject as unknown as string)
);
const activeProjectId = selectedProjectStillExists
? selectedProjectId
? (selectedProjectId ?? paramProject)
: ((projects?.[0]?.id as unknown as Id<"projects"> | undefined) ?? null);
const works = useQuery(
workListRef,
@@ -218,6 +172,16 @@ export const useSliceOne = () => {
[activeProjectId, projects]
);
const runOperation = async <T>(operation: () => Promise<T>): Promise<T> => {
setOperationError(undefined);
try {
return await operation();
} catch (caughtError) {
setOperationError(toError(caughtError));
throw caughtError;
}
};
const connectRepository = async () => {
const repositoryUrl = repository.trim();
if (!repositoryUrl || pending) {
@@ -236,41 +200,6 @@ export const useSliceOne = () => {
}
};
const selectProject = (projectId: string) => {
setSelectedProjectId(projectId as unknown as Id<"projects">);
};
const requestDefinition = (workId: Id<"works">) =>
requestDefinitionMutation({ workId });
const saveDefinition = (workId: Id<"works">, payload: unknown) =>
saveDefinitionMutation({ payloadJson: JSON.stringify(payload), workId });
const approveDefinition = (workId: Id<"works">, version: number) =>
approveDefinitionMutation({ version, workId });
const saveDesign = (workId: Id<"works">, payload: unknown) =>
saveDesignMutation({ payloadJson: JSON.stringify(payload), workId });
const approveDesign = (
workId: Id<"works">,
definitionVersion: number,
designVersion: number
) => approveDesignMutation({ definitionVersion, designVersion, workId });
const startSimulation = (
workId: Id<"works">,
scenario:
| "success"
| "transient-failure-then-success"
| "needs-input"
| "permanent-failure"
| "cancelled",
sliceId?: string
) => startSimulationMutation({ scenario, sliceId, workId });
const cancelSimulation = (runId: Id<"workRuns">) =>
cancelSimulationMutation({ runId });
const retrySimulation = (runId: Id<"workRuns">) =>
retrySimulationMutation({ runId });
const startExecution = (workId: Id<"works">, sliceId?: string) =>
startExecutionMutation({ sliceId, workId });
const cancelExecution = (runId: Id<"workRuns">) =>
cancelExecutionMutation({ runId });
const attachConnection = async (connectionId: Id<"gitConnections">) => {
if (!activeProjectId) {
throw new Error("Select a project first");
@@ -280,44 +209,72 @@ export const useSliceOne = () => {
projectId: activeProjectId,
});
};
const connectGitea = async (input: {
serverUrl: string;
token: string;
username?: string;
}) => {
const result = await connectGiteaAction(input);
await attachConnection(result.connectionId);
};
const connectLinkedGithub = async () => {
const result = await connectGithubAction({});
await attachConnection(result.connectionId);
};
const connectGitea = (input: { token: string; username?: string }) =>
runOperation(async () => {
const result = await connectGiteaAction(input);
await attachConnection(result.connectionId);
});
const connectLinkedGithub = () =>
runOperation(async () => {
const result = await connectGithubAction({});
await attachConnection(result.connectionId);
});
return {
agent,
approveDefinition,
approveDesign,
authorizeGithub,
cancelExecution,
cancelSimulation,
approveDefinition: (workId: Id<"works">, version: number) =>
approveDefinitionMutation({ version, workId }),
approveDesign: (
workId: Id<"works">,
definitionVersion: number,
designVersion: number
) => approveDesignMutation({ definitionVersion, designVersion, workId }),
authorizeGithub: () =>
authClient.linkSocial({
callbackURL: `${window.location.origin}/projects?resume=github`,
provider: "github",
}),
cancelExecution: (runId: Id<"workRuns">) =>
runOperation(() => cancelExecutionMutation({ runId })),
cancelSimulation: (runId: Id<"workRuns">) =>
runOperation(() => cancelSimulationMutation({ runId })),
clearOperationError: () => setOperationError(undefined),
connectGitea,
connectLinkedGithub,
connectRepository,
error,
gitConnections,
operationError,
pending,
projectGitConnection,
projects,
repository,
requestDefinition,
retrySimulation,
saveDefinition,
saveDesign,
selectProject,
selectedProject,
requestDefinition: (workId: Id<"works">) =>
requestDefinitionMutation({ workId }),
retrySimulation: (runId: Id<"workRuns">) =>
runOperation(() => retrySimulationMutation({ runId })),
saveDefinition: (workId: Id<"works">, payload: unknown) =>
saveDefinitionMutation({ payloadJson: JSON.stringify(payload), workId }),
saveDesign: (workId: Id<"works">, payload: unknown) =>
saveDesignMutation({ payloadJson: JSON.stringify(payload), workId }),
selectProject: (projectId: string) =>
setSelectedProjectId(projectId as unknown as Id<"projects">),
selectedProject: selectedProject
? { id: selectedProject.id, name: selectedProject.name }
: null,
setRepository,
startExecution,
startSimulation,
startExecution: (workId: Id<"works">, sliceId?: string) =>
runOperation(() => startExecutionMutation({ sliceId, workId })),
startSimulation: (
workId: Id<"works">,
scenario: ExecutionScenario,
sliceId?: string
) =>
runOperation(() =>
startSimulationMutation({ scenario, sliceId, workId })
),
works,
} as const;
};
};

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import { visualViewportStyle } from "./use-visual-viewport";
describe("Slice 1 visual viewport", () => {
describe("Workspace visual viewport", () => {
test("shrinks the application surface to the keyboard-visible height", () => {
expect(visualViewportStyle({ height: 500, offsetTop: 0 })).toEqual({
height: "500px",

View File

@@ -33,7 +33,7 @@ export const useVisualViewportStyle = (): CSSProperties => {
});
};
root.classList.add("slice-one-viewport-lock");
root.classList.add("workspace-viewport-lock");
update();
window.addEventListener("resize", update);
viewport?.addEventListener("resize", update);
@@ -41,7 +41,7 @@ export const useVisualViewportStyle = (): CSSProperties => {
return () => {
cancelAnimationFrame(animationFrame);
root.classList.remove("slice-one-viewport-lock");
root.classList.remove("workspace-viewport-lock");
window.removeEventListener("resize", update);
viewport?.removeEventListener("resize", update);
viewport?.removeEventListener("scroll", update);

View File

@@ -6,8 +6,8 @@ body {
min-height: 100%;
}
html.slice-one-viewport-lock,
html.slice-one-viewport-lock body {
html.workspace-viewport-lock,
html.workspace-viewport-lock body {
height: 100%;
overflow: hidden;
overscroll-behavior: none;
@@ -116,9 +116,42 @@ html.slice-one-viewport-lock body {
color: var(--foreground);
}
.slice-one-surface .chat-markdown,
.slice-one-surface .chat-reasoning,
.slice-one-surface .thinking-line {
/*
* The chat always renders on the light workspace surface
* (`bg-[#f2f0e7]`, near-black text), but the app forces the dark theme
* app-wide (`forcedTheme="dark"`). Streamdown's code/table/mermaid chrome and
* the rules below read theme tokens, which under `.dark` resolve to near-black
* values — producing unreadable dark-on-dark blocks. Re-declare the relevant
* tokens to warm-light values inside the markdown context so every token-driven
* chrome (headers, copy/download/fullscreen controls, table borders/cells,
* inline code) reads correctly on the light surface without touching the
* global theme or unrelated UI.
*/
.workspace-surface .chat-markdown {
--background: #ffffff;
--foreground: #232321;
--muted: #f4f4f2;
--muted-foreground: #69675f;
--card: #ffffff;
--card-foreground: #232321;
--popover: #ffffff;
--popover-foreground: #232321;
--secondary: #f4f4f2;
--secondary-foreground: #232321;
--accent: #efece4;
--accent-foreground: #232321;
--sidebar: #ffffff;
--sidebar-foreground: #232321;
--sidebar-accent: #f4f4f2;
--sidebar-accent-foreground: #232321;
--border: #e2e0d6;
--input: #e2e0d6;
--ring: #b6b7b4;
}
.workspace-surface .chat-markdown,
.workspace-surface .chat-reasoning,
.workspace-surface .thinking-line {
color: #232321;
}
@@ -223,6 +256,16 @@ html.slice-one-viewport-lock body {
text-align: start;
}
/* Readable header band + zebra rows so tables stand out on the surface. */
.chat-markdown thead th {
background: var(--muted);
font-weight: 600;
}
.chat-markdown tbody tr:nth-child(even) {
background: color-mix(in oklch, var(--muted) 55%, transparent);
}
@media (prefers-reduced-motion: reduce) {
.chat-markdown *,
.chat-message,

View File

@@ -1,12 +1,8 @@
import { env } from "@code/env/web";
import { redirect } from "react-router";
interface AuthLoaderData {
readonly token: string | null;
}
const tokenUrl = new URL("/api/auth/convex/token", env.VITE_AUTH_URL);
export const loadAuthToken = async (
request: Request
): Promise<AuthLoaderData> => {
@@ -14,9 +10,12 @@ export const loadAuthToken = async (
if (!cookie) {
return { token: null };
}
const tokenUrl = new URL(
"/api/auth/convex/token",
new URL(request.url).origin
);
const headers = new Headers({ cookie });
headers.set("host", tokenUrl.host);
const response = await fetch(tokenUrl, { headers });
if (response.status === 401) {
return { token: null };

View File

@@ -20,7 +20,7 @@ describe("projectConversation", () => {
messageId: "user-1",
rawText: "Build it",
role: "user",
status: "processing",
status: "dispatching",
}),
row({ messageId: "assistant-1", role: "assistant", status: "queued" }),
]);
@@ -28,6 +28,21 @@ describe("projectConversation", () => {
expect(state.messages).toHaveLength(1);
});
test("projects partial assistant text as streaming", () => {
const state = projectConversation([
row({
messageId: "assistant-streaming",
rawText: "Working",
role: "assistant",
status: "running",
}),
]);
expect(state.streaming).toBe(true);
expect(state.messages[0]?.parts).toEqual([
{ state: "streaming", text: "Working", type: "text" },
]);
});
test("projects completed Convex rows into renderable messages", () => {
const state = projectConversation([
row({

View File

@@ -11,40 +11,67 @@ export interface ConversationRow {
readonly messageId: string;
readonly rawText: string;
readonly role: "assistant" | "user";
readonly status: "completed" | "failed" | "processing" | "queued";
readonly status:
| "aborted"
| "completed"
| "dispatching"
| "failed"
| "queued"
| "running";
}
export const projectConversation = (rows: readonly ConversationRow[]) => ({
failedError: rows.findLast(
(row) => row.role === "assistant" && row.status === "failed"
)?.error,
messages: rows
.filter((row) => row.role === "user" || row.status === "completed")
.map<ConversationMessage>((row) => ({
id: row.messageId,
parts: [
...row.attachments.map((attachment) => ({
filename: attachment.filename ?? undefined,
id: attachment.id,
mediaType: attachment.mediaType,
type: "file" as const,
url: attachment.url ?? undefined,
})),
...(row.rawText
? [
{
state: "done" as const,
text: row.rawText,
type: "text" as const,
},
]
: []),
],
role: row.role,
})),
pending: rows.some(
export const projectConversation = (rows: readonly ConversationRow[]) => {
const streaming = rows.some(
(row) =>
row.role === "assistant" &&
(row.status === "queued" || row.status === "processing")
),
});
row.status === "running" &&
row.rawText.length > 0
);
return {
failedError: rows.findLast(
(row) => row.role === "assistant" && row.status === "failed"
)?.error,
messages: rows
.filter(
(row) =>
row.role === "user" ||
row.status === "completed" ||
(row.role === "assistant" &&
row.status === "running" &&
row.rawText.length > 0)
)
.map<ConversationMessage>((row) => ({
id: row.messageId,
parts: [
...row.attachments.map((attachment) => ({
filename: attachment.filename ?? undefined,
id: attachment.id,
mediaType: attachment.mediaType,
type: "file" as const,
url: attachment.url ?? undefined,
})),
...(row.rawText
? [
{
state:
row.status === "running"
? ("streaming" as const)
: ("done" as const),
text: row.rawText,
type: "text" as const,
},
]
: []),
],
role: row.role,
})),
pending: rows.some(
(row) =>
row.role === "assistant" &&
(row.status === "queued" ||
row.status === "dispatching" ||
row.status === "running")
),
streaming,
};
};

View File

@@ -5,27 +5,36 @@ import { describe, expect, test } from "vitest";
const source = (relativePath: string) =>
readFileSync(new URL(relativePath, import.meta.url), "utf-8");
describe("Slice 1 frontend regression contracts", () => {
describe("Workspace frontend regression contracts", () => {
test("keeps keyboard resizing on the visual viewport instead of page scroll", () => {
const page = source("../../components/slice-one/slice-one-page.tsx");
const page = source(
"../../components/workspace/project-workspace-page.tsx"
);
const viewport = source("../../hooks/workspace/use-visual-viewport.ts");
const root = source("../../root.tsx");
const styles = source("../../index.css");
expect(root).toContain("interactive-widget=resizes-content");
expect(page).toContain("style={viewportStyle}");
expect(page).toContain("fixed inset-x-0 top-0");
expect(page).not.toContain("slice-one-surface flex h-svh");
expect(styles).toContain("html.slice-one-viewport-lock body");
expect(viewport).toContain("workspace-viewport-lock");
expect(styles).toContain("html.workspace-viewport-lock body");
expect(styles).toContain("overflow: hidden");
});
test("keeps the responsive shell shrinkable with a pinned composer", () => {
const page = source("../../components/slice-one/slice-one-page.tsx");
const page = source(
"../../components/workspace/project-workspace-page.tsx"
);
const feed = source("../../components/workspace/conversation-feed.tsx");
const composer = source(
"../../components/workspace/conversation-composer.tsx"
);
expect(page).toContain('className="flex min-w-0 flex-1 flex-col"');
expect(page).toContain('<Conversation className="min-h-0 flex-1">');
expect(page).toContain('className="shrink-0 border-t');
expect(page).toContain(
expect(feed).toContain('<Conversation className="min-h-0 flex-1">');
expect(composer).toContain('className="shrink-0 border-t');
expect(composer).toContain(
'className="mx-auto flex max-w-2xl items-end gap-2"'
);
});
@@ -50,4 +59,20 @@ describe("Slice 1 frontend regression contracts", () => {
expect(messageRenderer).toContain("mermaid");
expect(messageRenderer).toContain("plugins={streamdownPlugins}");
});
test("keeps markdown readable on the forced-dark workspace surface", () => {
const styles = source("../../index.css");
// The workspace surface is light, but the app forces the dark theme, so
// the markdown context must re-declare the theme tokens streamdown and the
// table/code rules read from. Without these the dark tokens (~22-28%
// lightness) produce near-black text, borders, and backgrounds.
expect(styles).toContain(".workspace-surface .chat-markdown");
expect(styles).toContain("--muted: #f4f4f2");
expect(styles).toContain("--border: #e2e0d6");
expect(styles).toContain("--sidebar: #ffffff");
// Table headers and zebra rows must stand out on the surface.
expect(styles).toContain(".chat-markdown thead th");
expect(styles).toContain(".chat-markdown tbody tr:nth-child(even)");
});
});

View File

@@ -3,7 +3,10 @@ import { describe, expect, test } from "vitest";
import type { ConversationMessage } from "@/lib/chat/types";
import { buildSliceOneTimeline, findSourceMessageTarget } from "./presentation";
import {
buildWorkspaceTimeline,
findSourceMessageTarget,
} from "./presentation";
const textMessage = (
id: string,
@@ -23,9 +26,9 @@ const notice: WorkNotice = {
workId: "work-1",
};
describe("Slice 1 presentation", () => {
describe("Workspace presentation", () => {
test("places proposed Work after the assistant response to its source", () => {
const timeline = buildSliceOneTimeline(
const timeline = buildWorkspaceTimeline(
[
textMessage("user-1", "user", "Build the phone flow."),
textMessage("assistant-1", "assistant", "Captured and proposed Work."),

View File

@@ -7,14 +7,9 @@ import {
} from "@/lib/chat/transforms";
import type { ConversationMessage } from "@/lib/chat/types";
export type SliceTimelineItem =
| {
readonly kind: "message";
readonly message: ConversationMessage;
}
| { readonly kind: "work"; readonly notice: WorkNotice };
import type { WorkspaceTimelineItem } from "./types";
export const isSliceOneVisibleMessage = (
export const isVisibleConversationMessage = (
message: ConversationMessage
): boolean =>
message.role === "user" ||
@@ -42,11 +37,11 @@ const targetIndexForNotice = (
return responseOffset === -1 ? sourceIndex : sourceIndex + responseOffset + 1;
};
export const buildSliceOneTimeline = (
export const buildWorkspaceTimeline = (
allMessages: readonly ConversationMessage[],
notices: readonly WorkNotice[]
): readonly SliceTimelineItem[] => {
const messages = allMessages.filter(isSliceOneVisibleMessage);
): readonly WorkspaceTimelineItem[] => {
const messages = allMessages.filter(isVisibleConversationMessage);
const noticesByMessageIndex = new Map<number, WorkNotice[]>();
for (const notice of notices) {
const targetIndex = targetIndexForNotice(messages, notice);
@@ -55,7 +50,7 @@ export const buildSliceOneTimeline = (
noticesByMessageIndex.set(targetIndex, atTarget);
}
const timeline: SliceTimelineItem[] = [];
const timeline: WorkspaceTimelineItem[] = [];
for (const [index, message] of messages.entries()) {
timeline.push({ kind: "message", message });
for (const notice of noticesByMessageIndex.get(index) ?? []) {

View File

@@ -0,0 +1,162 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import type { WorkNotice } from "@code/primitives/work";
import type { ConversationMessage } from "@/lib/chat/types";
export interface WorkRecord {
readonly _id: Id<"works">;
readonly title: string;
readonly objective: string;
readonly status: string;
readonly definitionVersion?: number;
readonly definitionApprovalVersion?: number;
readonly designVersion?: number;
readonly signals: readonly {
readonly sources: readonly { messageId: string; rawText: string }[];
}[];
readonly runs: readonly WorkRun[];
readonly definition: {
readonly risk?: string;
readonly questions?: readonly { status: string }[];
} | null;
readonly design: {
readonly architectureSummary?: string;
readonly slices?: readonly {
readonly id: string;
readonly title: string;
readonly observableBehavior: string;
}[];
} | null;
}
export interface WorkRun {
readonly artifacts?: readonly WorkArtifact[];
readonly attemptEvents?: readonly WorkAttemptEvent[];
readonly baseRevision?: string;
readonly candidateRevision?: string;
readonly executionKind?: string;
readonly _id: Id<"workRuns">;
readonly status: string;
readonly terminalClassification?: string;
readonly terminalSummary?: string;
}
export interface WorkArtifact {
readonly _id: string;
readonly metadataJson: string;
readonly title: string;
readonly uri?: string;
}
export interface WorkAttemptEvent {
readonly _id: string;
readonly message: string;
}
export interface GitConnection {
readonly id: string;
readonly provider: "github" | "gitea";
readonly serverUrl: string;
readonly username?: string;
}
export interface ProjectListItem {
readonly id: string;
readonly name: string;
}
export interface WorkspaceState {
readonly agent: {
readonly error?: Error;
readonly historyReady: boolean;
readonly messages: readonly ConversationMessage[];
readonly sendMessage: (
message: string,
options?: { readonly images?: readonly File[] }
) => Promise<void>;
readonly status:
| "connecting"
| "error"
| "idle"
| "streaming"
| "submitted";
};
readonly approveDefinition: (
workId: Id<"works">,
version: number
) => Promise<unknown>;
readonly approveDesign: (
workId: Id<"works">,
definitionVersion: number,
designVersion: number
) => Promise<unknown>;
readonly authorizeGithub: () => Promise<unknown>;
readonly cancelExecution: (runId: Id<"workRuns">) => Promise<unknown>;
readonly cancelSimulation: (runId: Id<"workRuns">) => Promise<unknown>;
readonly clearOperationError: () => void;
readonly connectGitea: (input: {
readonly token: string;
readonly username?: string;
}) => Promise<void>;
readonly connectLinkedGithub: () => Promise<void>;
readonly connectRepository: () => Promise<void>;
readonly error?: Error;
readonly gitConnections: readonly GitConnection[] | undefined;
readonly operationError?: Error;
readonly pending: boolean;
readonly projectGitConnection: GitConnection | null | undefined;
readonly projects: readonly ProjectListItem[] | undefined;
readonly repository: string;
readonly requestDefinition: (workId: Id<"works">) => Promise<unknown>;
readonly retrySimulation: (runId: Id<"workRuns">) => Promise<unknown>;
readonly saveDefinition: (
workId: Id<"works">,
payload: unknown
) => Promise<unknown>;
readonly saveDesign: (
workId: Id<"works">,
payload: unknown
) => Promise<unknown>;
readonly selectProject: (projectId: string) => void;
readonly selectedProject: ProjectListItem | null;
readonly setRepository: (value: string) => void;
readonly startExecution: (
workId: Id<"works">,
sliceId?: string
) => Promise<unknown>;
readonly startSimulation: (
workId: Id<"works">,
scenario:
| "success"
| "transient-failure-then-success"
| "needs-input"
| "permanent-failure"
| "cancelled",
sliceId?: string
) => Promise<unknown>;
readonly works: readonly WorkRecord[] | undefined;
}
export type WorkspaceTimelineItem =
| { readonly kind: "message"; readonly message: ConversationMessage }
| { readonly kind: "work"; readonly notice: WorkNotice };
export interface ArtifactMetadata {
readonly changedFiles?: readonly string[];
}
export const parseArtifactMetadata = (
artifact: WorkArtifact
): ArtifactMetadata => {
if (!artifact.metadataJson) {
return {};
}
try {
return JSON.parse(artifact.metadataJson) as ArtifactMetadata;
} catch {
return {};
}
};
export const changedFilesFor = (artifact: WorkArtifact): readonly string[] =>
parseArtifactMetadata(artifact).changedFiles ?? [];

View File

@@ -7,7 +7,7 @@ export default [
route("signup", "./routes/auth/signup/page.tsx"),
]),
layout("./routes/app/layout.tsx", [
index("./routes/app/mobile/page.tsx"),
route("dashboard", "./routes/app/dashboard/page.tsx"),
index("./routes/app/workspace/page.tsx"),
route("projects", "./routes/app/projects/page.tsx"),
]),
] satisfies RouteConfig;

View File

@@ -1,5 +0,0 @@
import { SliceOnePage } from "@/components/slice-one/slice-one-page";
export default function Dashboard() {
return <SliceOnePage />;
}

View File

@@ -1,5 +1,9 @@
import { Outlet } from "react-router";
import { api } from "@code/backend/convex/_generated/api";
import { useQuery } from "convex/react";
import { useEffect } from "react";
import { Outlet, useNavigate } from "react-router";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import { requireAuthToken } from "@/lib/auth.server";
import type { Route } from "./+types/layout";
@@ -8,5 +12,24 @@ export const loader = ({ request }: Route.LoaderArgs) =>
requireAuthToken(request);
export default function AppLayout() {
const organization = usePersonalOrganization();
const navigate = useNavigate();
const projects = useQuery(
api.projects.list,
organization.organizationId ? {} : "skip"
);
useEffect(() => {
if (
projects !== undefined &&
projects.length === 0 &&
window.location.pathname === "/" &&
!window.location.search.includes("resume=") &&
!window.location.search.includes("project=")
) {
void navigate("/projects", { replace: true });
}
}, [projects, navigate]);
return <Outlet />;
}

View File

@@ -1,5 +0,0 @@
import { SliceOnePage } from "@/components/slice-one/slice-one-page";
export default function MobileLandingRedirect() {
return <SliceOnePage />;
}

View File

@@ -0,0 +1,5 @@
import { ProjectsPage } from "@/components/projects/projects-page";
export default function ProjectsRoute() {
return <ProjectsPage />;
}

View File

@@ -0,0 +1,5 @@
import { ProjectWorkspacePage } from "@/components/workspace/project-workspace-page";
export default function ProjectWorkspaceRoute() {
return <ProjectWorkspacePage />;
}

View File

@@ -7,14 +7,17 @@ import { defineConfig } from "vite-plus";
export default defineConfig({
envDir: path.resolve(import.meta.dirname, "../.."),
plugins: [tailwindcss(), reactRouter()],
ssr: {
noExternal: true,
},
resolve: {
dedupe: ["convex", "react", "react-dom"],
tsconfigPaths: true,
},
server: {
allowedHosts: true,
proxy: {
"/api/auth": {
changeOrigin: true,
target: "https://befitting-dalmatian-161.convex.site",
},
},
},
});

4643
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,92 +0,0 @@
###############################################################################
# Zopu Runtime Deployment — Environment Template
#
# Copy to .env and fill in real values. Never commit .env to the repository.
# This file documents every environment group required by the single-node
# execution plane. Lines marked REQUIRED must be set before first start.
###############################################################################
# ---------------------------------------------------------------------------
# 1. Convex (control plane) — REQUIRED
# Public URLs for the self-hosted Convex deployment.
# ---------------------------------------------------------------------------
CONVEX_URL=https://your-deployment.convex.cloud
CONVEX_SITE_URL=https://your-deployment.convex.site
SITE_URL=http://localhost:13100
VITE_AUTH_URL=http://localhost:13100
VITE_CONVEX_URL=https://your-deployment.convex.cloud
VITE_FLUE_URL=http://localhost:3583
VITE_ZOPU_SERVER_URL=http://localhost:3590
# Self-hosted Convex origins used by convex/docker-compose.yml
CONVEX_CLOUD_ORIGIN=https://your-deployment.convex.cloud
CONVEX_SITE_ORIGIN=https://your-deployment.convex.site
CONVEX_INSTANCE_NAME=zopu-production
CONVEX_INSTANCE_SECRET=
# ---------------------------------------------------------------------------
# 2. Self-hosted Git / Gitea — REQUIRED for issue lifecycle
# 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.
# ---------------------------------------------------------------------------
AGENT_MODEL_PROVIDER=cheaptricks
AGENT_MODEL_NAME=glm-5.2
AGENT_MODEL_API=openai-completions
AGENT_MODEL_BASE_URL=https://ai.example.invalid/v1
AGENT_MODEL_API_KEY=replace-with-model-gateway-key
AGENT_MODEL_CONTEXT_WINDOW=262000
AGENT_MODEL_MAX_TOKENS=131072
# ---------------------------------------------------------------------------
# 4. AgentOS / RivetKit — OPTIONAL (defaults shown)
# registry.start() in the daemon boots an in-process RivetKit engine
# (envoy mode) backed by a native Rust sidecar. createClient() connects
# 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
# ---------------------------------------------------------------------------
# 5. Zopu agent service (Flue)
# FLUE_DB_TOKEN authenticates the Flue persistence adapter.
# zopu-agent.service pins the Flue Node server to port 3583.
# ---------------------------------------------------------------------------
FLUE_DB_TOKEN=replace-with-long-random-token
# ---------------------------------------------------------------------------
# 6. Daemon identity
# ---------------------------------------------------------------------------
DAEMON_ID=zopu-dedicated
DAEMON_NAME=Zopu-Dedicated-Server
DAEMON_VERSION=0.0.0
DAEMON_HEARTBEAT_MS=15000
DAEMON_COMMAND_LEASE_MS=60000
# ---------------------------------------------------------------------------
# 7. Docker sandbox
# The zopu service user is added to the docker group during bootstrap.
# Orb sandboxes will use Docker for full-system isolation, but the Orb
# lane contract has not landed yet. Docker access is provisioned now so
# the boundary is ready; no Docker-backed sandbox code is wired today.
# ---------------------------------------------------------------------------
# No env vars required; Docker socket access is via group membership.
# ---------------------------------------------------------------------------
# 8. Service authentication / secrets
# These tokens authenticate inter-service calls. Generate strong randoms.
# ---------------------------------------------------------------------------
# Better Auth / Convex JWT secret (if the agent service needs to mint tokens):
#AUTH_SECRET=replace-with-64-char-hex
# ---------------------------------------------------------------------------
# 9. Private networking (Tailscale) — OPTIONAL
# When Tailscale is available, set the hostname for private DNS.
# ---------------------------------------------------------------------------
#TAILSCALE_HOSTNAME=zopu-runtime

View File

@@ -1,306 +0,0 @@
# Zopu Single-Node Runtime Deployment
Deployment artifacts for the complete Zopu stack on a single Debian dedicated server: the React Router web app, a persistent self-hosted Convex control plane, the daemon (Bun/Effect + AgentOS/RivetKit), the Flue agent service, Docker Engine, and supporting infrastructure.
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Debian Dedicated Server │
│ ~12 CPU cores · ~40 GB RAM · single-node │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ zopu-web │ │ zopu-agent │ │ Docker │ │
│ │ (systemd) │ │ (systemd) │ │ Engine │ │
│ │ │ │ │ │ │ │
│ │ React Router │ │ Flue Node 22 │ │ Convex │ │
│ │ :13100 │ │ server.mjs │ │ backend │ │
│ │ │ │ :3583 │ │ :3210/:3211 │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────────┘ │
│ │ │ │
│ ┌──────▼───────┐ │
│ │ zopu-daemon │ Bun + Effect + RivetKit :6420 │
│ └──────────────┘ │
│ │
│ systemd timers: health-check (60s), docker-cleanup (daily) │
│ cron: disk-monitor (daily 06:00) │
│ ufw: deny-incoming, SSH + tailscale0 │
│ Tailscale: optional private overlay │
└─────────────────────────────────────────────────────────────┘
```
### Single-node RivetKit topology
The daemon calls `registry.start()` from `rivetkit`, which boots an **in-process RivetKit engine** (envoy mode) backed by a **native Rust sidecar** binary (`@rivet-dev/agentos-sidecar`, platform-resolved). The engine listens on `RIVET_ENDPOINT` (default `http://localhost:6420`). The daemon then calls `createClient(RIVET_ENDPOINT)` to connect back to its own in-process engine for actor dispatch.
Evidence: RivetKit source `chunk-YDUQHING.js` line 4751 — `DEFAULT_ENDPOINT = "http://localhost:6420"`. The `Registry.start()` method calls `#startEnvoy()``runtime.serveRegistry()` for serverful mode (Mode A). The `createClient()` function reads `RIVET_ENDPOINT` env or defaults to the same `http://localhost:6420`.
**No separate Rivet Engine process is required.** The engine, actor envoy, and sidecar all run inside the daemon process. A future multi-node deployment would externalize the engine, but that is out of scope.
### Docker / Orb boundary
Docker Engine is installed and the `zopu` service user is in the `docker` group. The daemon's systemd unit includes `SupplementaryGroups=docker`. However, **no Docker-backed sandbox code is currently wired**. The Orb sandbox lane contract has not landed; Docker access is provisioned now so the boundary is ready. The current agent uses the in-process AgentOS VM (Wasm/V8) sandbox, not Docker.
## Files
```
deploy/zopu-runtime/
├── bootstrap.sh # One-shot Debian installer
├── .env.template # Environment template (all groups documented)
├── README.md # This file (runbook)
├── systemd/
│ ├── zopu-daemon.service # Daemon systemd unit
│ ├── zopu-agent.service # Agent systemd unit
│ ├── zopu-web.service # React Router web systemd unit
│ ├── zopu-health.service # Health check oneshot
│ ├── zopu-health.timer # Health check every 60s
│ ├── zopu-docker-cleanup.service
│ └── zopu-docker-cleanup.timer
├── scripts/
│ ├── health-check.sh # TCP/process health probes
│ ├── update.sh # Update to branch or commit
│ ├── rollback.sh # Roll back to previous commit
│ ├── docker-cleanup.sh # Prune stopped containers/images/networks
│ └── disk-monitor.sh # Disk usage alerting
└── caddy/
└── Caddyfile # Optional reverse proxy config (documentation)
```
## Fresh install
```bash
# 1. SSH into the fresh Debian 12 server as root.
# 2. Set environment overrides (optional):
export ZOPU_REPO_URL="ssh://git@git.openputer.com:2222/puter/zopu-code.git"
export ZOPU_REPO_BRANCH="dogfood/v0"
# export TAILSCALE_AUTHKEY="tskey-..."
# export TAILSCALE_HOSTNAME="zopu-runtime"
# 3. Run the bootstrap script:
bash bootstrap.sh
# 4. Edit .env with real values:
nano /opt/zopu/.env
# 5. Start services:
systemctl start zopu-web zopu-daemon
sleep 3
systemctl start zopu-agent
# 6. Enable timers:
systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer
# 7. Verify:
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
```
## Start / stop / restart
```bash
# Start all services
systemctl start zopu-web zopu-daemon zopu-agent
# Stop all services
systemctl stop zopu-agent zopu-daemon zopu-web
# Restart (daemon first — it owns the RivetKit engine)
systemctl restart zopu-web zopu-daemon && sleep 3 && systemctl restart zopu-agent
# Enable on boot
systemctl enable zopu-web zopu-daemon zopu-agent
# Disable on boot
systemctl disable zopu-web zopu-daemon zopu-agent
```
## Log inspection
All service logs go to journald with `SyslogIdentifier` tags.
```bash
# Daemon logs (live follow)
journalctl -u zopu-daemon -f
# Agent logs (live follow)
journalctl -u zopu-agent -f
# Last 100 lines of daemon
journalctl -u zopu-daemon -n 100
# Logs since boot
journalctl -u zopu-daemon -b
# Health check timer logs
journalctl -u zopu-health.service -n 50
# Docker cleanup logs
journalctl -u zopu-docker-cleanup.service -n 50
# Disk monitor logs (cron → file)
tail -100 /var/log/zopu/disk-monitor.log
# All Zopu syslog identifiers
journalctl -t zopu-daemon -t zopu-agent --since "1 hour ago"
```
## Health checks
```bash
# Manual health check (prints all probes)
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh
# Quiet mode (exit code only)
/opt/zopu/deploy/zopu-runtime/scripts/health-check.sh --quiet
# Check systemd timer is running
systemctl status zopu-health.timer
systemctl list-timers zopu-health.timer
```
The health check probes:
1. `zopu-daemon` systemd unit is active
2. `zopu-agent` systemd unit is active
3. RivetKit engine port (default 6420) accepts TCP connections
4. Flue agent port (default 3583) accepts TCP connections
5. Docker daemon responds to `docker info`
No HTTP health endpoints are assumed. Flue does not expose one by design (per Flue docs: "Flue does not add a health endpoint"). RivetKit's health route is internal to the registry runtime and not documented as publicly addressable on the engine endpoint.
## Update to commit
```bash
# Update to latest of dogfood/v0 (default)
/opt/zopu/deploy/zopu-runtime/scripts/update.sh
# Update to a specific branch
/opt/zopu/deploy/zopu-runtime/scripts/update.sh dogfood/runtime-deploy
# Update to a specific commit
/opt/zopu/deploy/zopu-runtime/scripts/update.sh abc123def456
```
The update script:
1. Records current HEAD to `.last-deployed-sha`
2. Fetches, resolves branch-or-commit, checks out
3. `bun install`, builds daemon and agent
4. Restarts daemon, waits, restarts agent
5. Runs health check; reports failure and rollback instructions
## Rollback
```bash
# Roll back to the previously deployed commit
/opt/zopu/deploy/zopu-runtime/scripts/rollback.sh
# Roll back to a specific commit
/opt/zopu/deploy/zopu-runtime/scripts/rollback.sh abc123def456
```
Rollback reads `.last-deployed-sha` (written by `update.sh`), checks out that commit, rebuilds, and restarts services. The pre-rollback SHA is saved to `.pre-rollback-sha` for re-rollback if needed.
## Docker cleanup
```bash
# Manual cleanup
/opt/zopu/deploy/zopu-runtime/scripts/docker-cleanup.sh
# Check Docker disk usage
docker system df
# Timer runs daily; check its schedule
systemctl list-timers zopu-docker-cleanup.timer
```
Cleanup prunes:
- Stopped containers older than 24 hours
- Dangling (untagged) images
- Unused networks
Named volumes and running containers are never removed.
## Disk-space monitoring
```bash
# Manual check
/opt/zopu/deploy/zopu-runtime/scripts/disk-monitor.sh
# Custom threshold (90%)
/opt/zopu/deploy/zopu-runtime/scripts/disk-monitor.sh --warn-percent 90
```
A cron job runs at 06:00 daily and writes to `/var/log/zopu/disk-monitor.log`. Default alert threshold is 80%.
## Firewall and private networking
The firewall (`ufw`) is deny-by-default:
- SSH (port 22) is allowed on all interfaces
- All traffic on `tailscale0` is allowed (Tailscale private overlay)
- All other incoming traffic is denied
The RivetKit engine (`:6420`) and Flue agent (`:3583`) ports are **not** exposed on public interfaces. Reachability options:
1. **Tailscale** (recommended): bootstrap runs `ufw allow in on tailscale0` so all ports are reachable over the private overlay. Set `TAILSCALE_AUTHKEY` before running bootstrap to configure automatically. Other Tailscale-connected machines can reach the agent at `http://zopu-runtime:3583` and the engine at `http://zopu-runtime:6420`.
2. **Custom private interface**: if you have a non-Tailscale private network (e.g. a VLAN or wireguard interface), add an explicit rule:
```bash
ufw allow in on eth1 # or your private interface name
```
Do NOT assume direct private IP access works by default — the deny-incoming policy blocks it until an interface-specific rule is added.
3. **Caddy** (optional): install Caddy and use the annotated Caddyfile in `caddy/` if you need TLS termination or a public ingress point.
## Environment groups
See [`.env.template`](./.env.template) for the full annotated template. The eight required groups:
| Group | Variables |
| --- | --- |
| Convex | `CONVEX_URL`, `CONVEX_SITE_URL`, `SITE_URL` |
| Gitea | `GITEA_URL`, `GITEA_TOKEN` |
| Model gateway | `AGENT_MODEL_*` |
| AgentOS/RivetKit | `RIVET_ENDPOINT` (optional) |
| Zopu agent | `FLUE_DB_TOKEN` (`zopu-agent.service` sets port 3583) |
| Daemon | `DAEMON_ID`, `DAEMON_NAME`, `DAEMON_VERSION`, `DAEMON_HEARTBEAT_MS`, `DAEMON_COMMAND_LEASE_MS` |
| Docker sandbox | group membership (no env vars) |
| Service auth | `AUTH_SECRET` (if needed) |
## Public single-node routes
The checked-in Caddy example assumes Cloudflare Tunnel terminates TLS and sends the four Zopu hosts to Caddy on loopback:
- `zopu.sai-onchain.me` → React Router web app on `127.0.0.1:13100`
- `zopu-api.sai-onchain.me` → Convex API on `127.0.0.1:3210`
- `zopu-site.sai-onchain.me` → Convex HTTP actions on `127.0.0.1:3211`
- `zopu-agent.sai-onchain.me` → Flue on `127.0.0.1:3583`
Self-hosted Convex state lives in the `zopu-convex-data` Docker volume. Generate the CLI admin key after the backend is healthy:
```bash
docker compose \
--env-file /opt/zopu/.env \
-f /opt/zopu/deploy/zopu-runtime/convex/docker-compose.yml \
exec backend ./generate_admin_key.sh
```
## What is NOT deployed
- **Kubernetes**: no container orchestration.
- **PostgreSQL for Rivet**: the in-process RivetKit engine uses its own storage; no external PostgreSQL is required.
- **Multi-node coordination**: single-node only.
- **Public administration endpoints**: no admin HTTP surface.
- **Secrets in source**: `.env` is never committed; `.env.template` contains only placeholder values.
- **Docker-backed Orb sandboxes**: Docker is installed and access is provisioned, but no Orb sandbox code is wired. This is a boundary prepared for the Orb lane, not a working feature.
## Reproducibility
The deployment does not require the developer's MacBook to remain online. Once bootstrap completes and `.env` is filled in:
1. Services run under systemd with `Restart=always`.
2. Logs persist in journald.
3. Health checks run every 60 seconds via systemd timer.
4. Docker cleanup runs daily.
5. Disk usage is monitored daily.
6. Unattended-upgrades handles Debian security patches.

View File

@@ -1,290 +0,0 @@
#!/usr/bin/env bash
#
# bootstrap.sh — One-shot installer for the Zopu single-node execution plane.
#
# Run as root on a fresh Debian 12 host:
#
# bash bootstrap.sh
#
# Environment overrides (set before running):
# ZOPU_REPO_URL — SSH clone URL (default: ssh://git@git.openputer.com:2222/puter/zopu-code.git)
# ZOPU_REPO_BRANCH — branch to deploy (default: dogfood/v0)
# ZOPU_INSTALL_DIR — install path (default: /opt/zopu)
# ZOPU_SERVICE_USER — system user (default: zopu)
# TAILSCALE_AUTHKEY — if set, configure Tailscale
# TAILSCALE_HOSTNAME — Tailscale hostname (default: zopu-runtime)
#
# Installs: Docker Engine, Node.js 22, Bun, clones the repo, runs bun install, builds the
# web app, daemon, and agent, creates a non-root service user, installs systemd
# units, and configures firewall/Tailscale defaults.
set -euo pipefail
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
REPO_URL="${ZOPU_REPO_URL:-ssh://git@git.openputer.com:2222/puter/zopu-code.git}"
REPO_BRANCH="${ZOPU_REPO_BRANCH:-dogfood/v0}"
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
DEPLOY_DIR="${INSTALL_DIR}/deploy/zopu-runtime"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
log() { echo -e "${GREEN}[bootstrap]${NC} $*"; }
warn() { echo -e "${YELLOW}[bootstrap]${NC} $*"; }
err() { echo -e "${RED}[bootstrap]${NC} $*" >&2; }
# runuser is part of util-linux (essential on Debian) and always available.
# sudo is NOT assumed on minimal Debian installs.
run_as_service() {
runuser -u "$SERVICE_USER" -- "$@"
}
# ---------------------------------------------------------------------------
# Pre-flight
# ---------------------------------------------------------------------------
if [[ "$EUID" -ne 0 ]]; then
err "This script must be run as root."
exit 1
fi
if [[ -f /etc/debian_version ]]; then
log "Detected Debian $(cat /etc/debian_version)"
else
warn "This script targets Debian 12. Other distributions may need manual adjustments."
fi
# ---------------------------------------------------------------------------
# 1. System packages
# ---------------------------------------------------------------------------
log "Updating apt and installing base packages..."
apt-get update -y
apt-get install -y \
ca-certificates \
curl \
gnupg \
ufw \
git \
jq \
netcat-openbsd \
openssh-client \
unattended-upgrades \
rsyslog
# ---------------------------------------------------------------------------
# 2. Docker Engine
# ---------------------------------------------------------------------------
if ! command -v docker &>/dev/null; then
log "Installing Docker Engine..."
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg \
-o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/debian \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -y
apt-get install -y \
docker-ce \
docker-ce-cli \
containerd.io \
docker-buildx-plugin \
docker-compose-plugin
else
log "Docker Engine already installed: $(docker --version)"
fi
systemctl enable --now docker
# ---------------------------------------------------------------------------
# 3. Node.js 22 (required by the Flue Node target)
# ---------------------------------------------------------------------------
NODE_MAJOR=$(node --version 2>/dev/null | sed -n 's/^v\([0-9][0-9]*\).*/\1/p')
if [[ -z "$NODE_MAJOR" || "$NODE_MAJOR" -lt 22 ]]; then
log "Installing Node.js 22..."
curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh
bash /tmp/nodesource_setup.sh
apt-get install -y nodejs
else
log "Node.js already installed: $(node --version)"
fi
# ---------------------------------------------------------------------------
# 4. Bun
# ---------------------------------------------------------------------------
if ! command -v bun &>/dev/null; then
log "Installing Bun..."
curl -fsSL https://bun.sh/install | bash
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun
else
log "Bun already installed: $(bun --version)"
fi
# ---------------------------------------------------------------------------
# 5. Service user
# ---------------------------------------------------------------------------
if ! id "$SERVICE_USER" &>/dev/null; then
log "Creating service user: $SERVICE_USER"
useradd -r -m -d "/home/$SERVICE_USER" -s /bin/bash "$SERVICE_USER"
fi
if ! id -nG "$SERVICE_USER" | grep -qw docker; then
usermod -aG docker "$SERVICE_USER"
log "Added $SERVICE_USER to docker group"
fi
# ---------------------------------------------------------------------------
# 6. Clone or update repository
# ---------------------------------------------------------------------------
if [[ -d "$INSTALL_DIR/.git" ]]; then
log "Repository exists at $INSTALL_DIR, fetching latest..."
cd "$INSTALL_DIR"
git fetch origin
git checkout "$REPO_BRANCH"
git reset --hard "origin/$REPO_BRANCH"
else
log "Cloning $REPO_URL (branch $REPO_BRANCH) into $INSTALL_DIR..."
git clone --branch "$REPO_BRANCH" "$REPO_URL" "$INSTALL_DIR"
cd "$INSTALL_DIR"
fi
# ---------------------------------------------------------------------------
# 6b. Hand ownership of the checkout to the service user
# ---------------------------------------------------------------------------
log "Setting ownership of $INSTALL_DIR to $SERVICE_USER..."
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
# ---------------------------------------------------------------------------
# 7. Install dependencies and build
# ---------------------------------------------------------------------------
log "Running bun install..."
run_as_service bun install
log "Building web app..."
run_as_service bun run --cwd apps/web build
log "Validating daemon production build..."
run_as_service bun run build:daemon
log "Building agent service..."
run_as_service bun run build:agents
# ---------------------------------------------------------------------------
# 8. Environment file
# ---------------------------------------------------------------------------
ENV_FILE="$INSTALL_DIR/.env"
if [[ ! -f "$ENV_FILE" ]]; then
log "Copying .env.template to .env — EDIT BEFORE STARTING SERVICES"
cp "$DEPLOY_DIR/.env.template" "$ENV_FILE"
chown "$SERVICE_USER":"$SERVICE_USER" "$ENV_FILE"
chmod 600 "$ENV_FILE"
warn "Edit $ENV_FILE with real values before starting services."
else
log ".env already exists at $ENV_FILE"
# Ensure correct ownership and permissions on existing .env
chown "$SERVICE_USER":"$SERVICE_USER" "$ENV_FILE"
chmod 600 "$ENV_FILE"
fi
# ---------------------------------------------------------------------------
# 9. Persistent log directory
# ---------------------------------------------------------------------------
LOG_DIR="/var/log/zopu"
mkdir -p "$LOG_DIR"
chown "$SERVICE_USER":"$SERVICE_USER" "$LOG_DIR"
# ---------------------------------------------------------------------------
# 10. Install systemd units (substitute placeholders)
# ---------------------------------------------------------------------------
log "Installing systemd units..."
for unit in zopu-web.service zopu-daemon.service zopu-agent.service \
zopu-health.timer zopu-health.service \
zopu-docker-cleanup.timer zopu-docker-cleanup.service; do
SRC="$DEPLOY_DIR/systemd/$unit"
DST="/etc/systemd/system/$unit"
if [[ -f "$SRC" ]]; then
sed \
-e "s|__INSTALL_DIR__|$INSTALL_DIR|g" \
-e "s|__SERVICE_USER__|$SERVICE_USER|g" \
"$SRC" > "$DST"
log " installed $unit"
fi
done
systemctl daemon-reload
# ---------------------------------------------------------------------------
# 11. Firewall (deny-by-default, explicit allow for Tailscale)
# ---------------------------------------------------------------------------
log "Configuring firewall..."
if ! ufw status 2>/dev/null | grep -q "Status: active"; then
ufw allow 22/tcp
ufw default deny incoming
ufw default allow outgoing
# Allow all traffic on the Tailscale interface (if present)
# This lets the agent and engine ports be reached over the private overlay.
ufw allow in on tailscale0 || warn "tailscale0 not present yet; rule will activate when interface appears"
ufw --force enable
log "Firewall enabled: SSH (22) allowed, tailscale0 allowed."
warn "Agent and RivetKit ports are NOT exposed on public interfaces."
warn "Reachability is via Tailscale (tailscale0) only."
else
log "Firewall already active. Ensuring tailscale0 rule..."
ufw allow in on tailscale0 2>/dev/null || true
fi
# ---------------------------------------------------------------------------
# 12. Tailscale (optional)
# ---------------------------------------------------------------------------
if [[ -n "${TAILSCALE_AUTHKEY:-}" ]]; then
log "Installing and configuring Tailscale..."
if ! command -v tailscaled &>/dev/null; then
curl -fsSL https://tailscale.com/install.sh | sh
fi
tailscale up --authkey "$TAILSCALE_AUTHKEY" \
--hostname "${TAILSCALE_HOSTNAME:-zopu-runtime}" \
--accept-routes
log "Tailscale configured: $(tailscale ip -4 2>/dev/null || echo 'waiting for IP')"
# Re-apply the tailscale0 firewall rule now that the interface exists
ufw allow in on tailscale0 2>/dev/null || true
else
warn "TAILSCALE_AUTHKEY not set — skipping Tailscale setup."
warn "Without Tailscale, services are reachable only via localhost."
warn "To use a private network interface, add an explicit UFW rule:"
warn " ufw allow in on <interface>"
fi
# ---------------------------------------------------------------------------
# 13. Disk-space monitoring cron
# /etc/cron.d format REQUIRES a username field.
# ---------------------------------------------------------------------------
log "Installing disk-space monitor (daily at 06:00)..."
CRON_LINE="0 6 * * * ${SERVICE_USER} ${DEPLOY_DIR}/scripts/disk-monitor.sh --warn-percent 80 >> /var/log/zopu/disk-monitor.log 2>&1"
echo "$CRON_LINE" > /etc/cron.d/zopu-disk-monitor
chmod 644 /etc/cron.d/zopu-disk-monitor
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
log "Bootstrap complete."
echo ""
echo "Next steps:"
echo " 1. Edit $ENV_FILE with real values"
echo " 2. Start services:"
echo " systemctl start zopu-web zopu-daemon zopu-agent"
echo " 3. Enable health monitoring:"
echo " systemctl enable --now zopu-health.timer zopu-docker-cleanup.timer"
echo " 4. Verify health:"
echo " $DEPLOY_DIR/scripts/health-check.sh"
echo ""
warn "Services are NOT started automatically. Edit .env first."

View File

@@ -1,23 +0,0 @@
# Caddyfile — Public reverse proxy for the Zopu web + API.
#
# The web frontend (port 5173) is served at the root, Better Auth is proxied
# first-party under /api/auth, and Flue is mounted under /api/flue.
zopu.cheaptricks.puter.wtf {
bind 135.181.82.179 2a01:4f9:c013:4a64::1
encode zstd gzip
handle /api/auth/* {
reverse_proxy https://befitting-dalmatian-161.convex.site {
header_up Host befitting-dalmatian-161.convex.site
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Proto {scheme}
}
}
handle_path /api/flue/* {
reverse_proxy 127.0.0.1:3585
}
reverse_proxy 127.0.0.1:5173
}

View File

@@ -1,25 +0,0 @@
{
admin 127.0.0.1:2019
auto_https off
http_port 8080
}
http://zopu.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:13100
}
http://zopu-api.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:3210
}
http://zopu-site.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:3211
}
http://zopu-agent.sai-onchain.me {
bind 127.0.0.1
reverse_proxy 127.0.0.1:3583
}

View File

@@ -1,13 +0,0 @@
tunnel: 9e54ac9c-c1a1-4583-be08-3b5f1acc7b65
credentials-file: /root/.cloudflared/9e54ac9c-c1a1-4583-be08-3b5f1acc7b65.json
ingress:
- hostname: zopu.sai-onchain.me
service: http://127.0.0.1:8080
- hostname: zopu-api.sai-onchain.me
service: http://127.0.0.1:8080
- hostname: zopu-site.sai-onchain.me
service: http://127.0.0.1:8080
- hostname: zopu-agent.sai-onchain.me
service: http://127.0.0.1:8080
- service: http_status:404

View File

@@ -1,32 +0,0 @@
services:
backend:
image: ghcr.io/get-convex/convex-backend:latest
container_name: zopu-convex
restart: unless-stopped
stop_grace_period: 10s
stop_signal: SIGINT
ports:
- "127.0.0.1:3210:3210"
- "127.0.0.1:3211:3211"
volumes:
- zopu-convex-data:/convex/data
environment:
CONVEX_CLOUD_ORIGIN: ${CONVEX_CLOUD_ORIGIN}
CONVEX_SITE_ORIGIN: ${CONVEX_SITE_ORIGIN}
DISABLE_BEACON: "true"
DISABLE_METRICS_ENDPOINT: "true"
DOCUMENT_RETENTION_DELAY: "172800"
INSTANCE_NAME: ${CONVEX_INSTANCE_NAME:-zopu-production}
INSTANCE_SECRET: ${CONVEX_INSTANCE_SECRET:-}
REDACT_LOGS_TO_CLIENT: "true"
RUST_LOG: ${CONVEX_RUST_LOG:-info}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3210/version"]
interval: 5s
timeout: 3s
retries: 12
start_period: 10s
volumes:
zopu-convex-data:
name: zopu-convex-data

View File

@@ -1,16 +0,0 @@
FROM oven/bun:1.3.14 AS bun
FROM node:24-bookworm-slim
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
RUN apt-get update \
&& apt-get install -y --no-install-recommends g++ make python3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile
CMD ["bun", "packages/agents/src/runner.ts"]

View File

@@ -1,11 +0,0 @@
services:
zopu-agentos-runner:
build:
context: ../../..
dockerfile: deploy/zopu-runtime/runner/Dockerfile
environment:
RIVET_ENDPOINT: ${RIVET_ENDPOINT}
RIVET_PUBLIC_ENDPOINT: ${RIVET_PUBLIC_ENDPOINT}
RIVET_ENVOY_VERSION: ${RIVET_ENVOY_VERSION}
RIVET_POOL: default
restart: unless-stopped

View File

@@ -1,63 +0,0 @@
#!/usr/bin/env bash
#
# disk-monitor.sh — Check disk usage and alert when above threshold.
#
# Usage:
# disk-monitor.sh # print usage, warn at 80%
# disk-monitor.sh --warn-percent 90 # custom threshold
#
# Requires GNU coreutils df (standard on Debian). Uses --output for
# deterministic column ordering regardless of locale.
#
# Exit code 0 if under threshold, 1 if at or above.
set -euo pipefail
WARN_PERCENT=80
while [[ $# -gt 0 ]]; do
case "$1" in
--warn-percent)
WARN_PERCENT="$2"
shift 2
;;
*)
echo "Unknown argument: $1" >&2
exit 2
;;
esac
done
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
EXIT_CODE=0
# Partitions to check: install root and /var (Docker data-root is often here)
PARTITIONS="${PARTITIONS:-/ /var}"
for partition in $PARTITIONS; do
if [[ ! -d "$partition" ]]; then
continue
fi
# GNU df --output columns: pcent (Use%), size, avail, target (Mounted on)
read -r USAGE_PCT SIZE AVAIL MOUNT <<< "$(df -h --output=pcent,size,avail,target "$partition" | awk 'NR==2 {gsub(/%/,"",$1); print $1, $2, $3, $4}')"
if [[ -z "${USAGE_PCT:-}" || ! "${USAGE_PCT:-}" =~ ^[0-9]+$ ]]; then
echo " [skip] Could not read usage for ${partition}"
continue
fi
if [[ "$USAGE_PCT" -ge "$WARN_PERCENT" ]]; then
echo -e " ${RED}WARN${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE}) — threshold ${WARN_PERCENT}%"
EXIT_CODE=1
elif [[ "$USAGE_PCT" -ge $((WARN_PERCENT - 10)) ]]; then
echo -e " ${YELLOW}NOTE${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE}) — approaching threshold"
else
echo -e " ${GREEN}OK${NC} ${MOUNT}: ${USAGE_PCT}% used (${AVAIL} avail of ${SIZE})"
fi
done
exit "$EXIT_CODE"

View File

@@ -1,36 +0,0 @@
#!/usr/bin/env bash
#
# docker-cleanup.sh — Remove stopped containers, dangling images, and unused networks.
#
# Safe to run via systemd timer (daily). Uses Docker's built-in pruning
# commands with conservative scope.
#
# Does NOT prune volumes. Named volumes may hold durable data and cannot be
# safely auto-pruned in a deployment that mixes stateful workloads. When the
# Orb lane lands with labeled resources, volume pruning can be scoped to
# Orb-managed labels (e.g. --filter label=org.openputer.orb). Until then,
# manage volumes manually.
set -euo pipefail
echo "[docker-cleanup] $(date -u +%Y-%m-%dT%H:%M:%SZ)"
# Remove stopped containers older than 24 hours
echo "[docker-cleanup] Pruning stopped containers (>24h old)..."
docker container prune -f --filter "until=24h"
# Remove dangling images (untagged intermediate layers only)
echo "[docker-cleanup] Pruning dangling images..."
docker image prune -f
# Remove unused networks
echo "[docker-cleanup] Pruning unused networks..."
docker network prune -f
# Volumes are intentionally NOT pruned. See header comment.
# Show remaining disk usage
echo "[docker-cleanup] Docker disk usage:"
docker system df
echo "[docker-cleanup] Done."

View File

@@ -1,122 +0,0 @@
#!/usr/bin/env bash
#
# health-check.sh — Probe the Zopu execution plane.
#
# Checks (TCP/process-level; no assumed HTTP health routes):
# 1. zopu-daemon systemd unit is active
# 2. zopu-agent systemd unit is active
# 3. RivetKit engine TCP port (RIVET_ENDPOINT, default 6420) accepts connections
# 4. Flue agent TCP port (PORT, default 3583) accepts connections
# 5. Docker daemon is reachable
#
# Flue does not expose a health endpoint by design. RivetKit's health route
# is internal to the registry runtime. We use TCP connection checks only.
#
# Usage:
# health-check.sh # print results
# health-check.sh --quiet # suppress output, exit 0 only if all healthy
#
# Exit codes: 0 = all healthy, 1 = one or more unhealthy
set -euo pipefail
QUIET=false
[[ "${1:-}" == "--quiet" ]] && QUIET=true
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
pass() { $QUIET || echo -e " ${GREEN}PASS${NC} $*"; }
fail() { echo -e " ${RED}FAIL${NC} $*" >&2; FAILURES=$((FAILURES + 1)); }
FAILURES=0
# ---------------------------------------------------------------------------
# Load environment
# ---------------------------------------------------------------------------
ENV_FILE="${ENV_FILE:-/opt/zopu/.env}"
if [[ -f "$ENV_FILE" ]]; then
# shellcheck source=/dev/null
set -a
. "$ENV_FILE"
set +a
fi
RIVET_PORT="6420"
if [[ -n "${RIVET_ENDPOINT:-}" ]]; then
RIVET_PORT=$(echo "$RIVET_ENDPOINT" | sed -n 's|.*://[^:]*:\([0-9]*\).*|\1|p')
[[ -z "$RIVET_PORT" ]] && RIVET_PORT="6420"
fi
AGENT_PORT="${PORT:-3583}"
# ---------------------------------------------------------------------------
# TCP probe helper: works on Debian (nc from netcat-openbsd) and macOS.
# Falls back to bash /dev/tcp if nc is unavailable.
# ---------------------------------------------------------------------------
tcp_probe() {
local host="$1" port="$2"
if command -v nc &>/dev/null; then
nc -z -w 5 "$host" "$port" 2>/dev/null
else
timeout 5 bash -c "echo > /dev/tcp/${host}/${port}" 2>/dev/null
fi
}
# ---------------------------------------------------------------------------
# 1. Daemon systemd unit
# ---------------------------------------------------------------------------
if systemctl is-active --quiet zopu-daemon 2>/dev/null; then
pass "zopu-daemon service is active"
else
fail "zopu-daemon service is not active"
fi
# ---------------------------------------------------------------------------
# 2. Agent systemd unit
# ---------------------------------------------------------------------------
if systemctl is-active --quiet zopu-agent 2>/dev/null; then
pass "zopu-agent service is active"
else
fail "zopu-agent service is not active"
fi
# ---------------------------------------------------------------------------
# 3. RivetKit engine TCP port
# ---------------------------------------------------------------------------
if tcp_probe localhost "$RIVET_PORT"; then
pass "RivetKit engine port ${RIVET_PORT} is accepting connections"
else
fail "RivetKit engine port ${RIVET_PORT} is not accepting connections"
fi
# ---------------------------------------------------------------------------
# 4. Flue agent TCP port
# ---------------------------------------------------------------------------
if tcp_probe localhost "$AGENT_PORT"; then
pass "Flue agent port ${AGENT_PORT} is accepting connections"
else
fail "Flue agent port ${AGENT_PORT} is not accepting connections"
fi
# ---------------------------------------------------------------------------
# 5. Docker daemon
# ---------------------------------------------------------------------------
if docker info &>/dev/null; then
pass "Docker daemon is reachable"
else
fail "Docker daemon is not reachable"
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
$QUIET || echo ""
if [[ "$FAILURES" -eq 0 ]]; then
$QUIET || echo -e "${GREEN}All checks passed.${NC}"
exit 0
else
echo -e "${RED}${FAILURES} check(s) failed.${NC}" >&2
exit 1
fi

View File

@@ -1,104 +0,0 @@
#!/usr/bin/env bash
#
# rollback.sh — Roll back to the previously deployed commit.
#
# Usage:
# rollback.sh # roll back to .last-deployed-sha
# rollback.sh <sha> # roll back to a specific commit
#
# .env is gitignored and is never touched by git operations. It survives
# updates and rollbacks unchanged.
#
# Must be run as root (uses runuser to build as the service user).
set -euo pipefail
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'
log() { echo -e "${GREEN}[rollback]${NC} $*"; }
err() { echo -e "${RED}[rollback]${NC} $*" >&2; }
run_as_service() {
runuser -u "$SERVICE_USER" -- "$@"
}
cd "$INSTALL_DIR"
# Determine target
ROLLBACK_SHA="${1:-}"
if [[ -z "$ROLLBACK_SHA" ]]; then
LAST_SHA_FILE="${INSTALL_DIR}/.last-deployed-sha"
if [[ ! -f "$LAST_SHA_FILE" ]]; then
err "No previous deployment recorded in ${LAST_SHA_FILE}."
err "Pass a commit SHA explicitly: rollback.sh <sha>"
exit 1
fi
ROLLBACK_SHA=$(cat "$LAST_SHA_FILE")
fi
# Validate commit exists
if ! git rev-parse --verify "${ROLLBACK_SHA}^{commit}" &>/dev/null; then
err "Commit ${ROLLBACK_SHA} does not exist in the local repository."
exit 1
fi
CURRENT_SHA=$(git rev-parse HEAD)
log "Current: ${CURRENT_SHA:0:12}"
log "Rolling back to: ${ROLLBACK_SHA:0:12}"
# Save current state before rolling back (enables re-rollback)
echo "$CURRENT_SHA" > "${INSTALL_DIR}/.pre-rollback-sha"
# Checkout target
git checkout "$ROLLBACK_SHA"
# Restore ownership of the checkout to the service user after git operations
log "Setting ownership of checkout to $SERVICE_USER..."
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
# Confirm .env is intact and has correct permissions
if [[ -f "${INSTALL_DIR}/.env" ]]; then
log ".env preserved."
chmod 600 "${INSTALL_DIR}/.env"
else
err ".env is missing! Restore it from backup before starting services."
fi
log "Running bun install..."
run_as_service bun install
log "Building web app..."
run_as_service bun run --cwd apps/web build
log "Validating daemon production build..."
run_as_service bun run build:daemon
log "Building agent service..."
run_as_service bun run build:agents
log "Restarting services..."
systemctl restart zopu-daemon
sleep 3
systemctl restart zopu-agent
systemctl restart zopu-web
sleep 5
# Health check
HEALTH_SCRIPT="${INSTALL_DIR}/deploy/zopu-runtime/scripts/health-check.sh"
if [[ -x "$HEALTH_SCRIPT" ]]; then
log "Running health check..."
if "$HEALTH_SCRIPT"; then
log "Rollback complete and healthy."
else
err "Health check failed after rollback!"
err " journalctl -u zopu-daemon -n 50"
err " journalctl -u zopu-agent -n 50"
err " journalctl -u zopu-web -n 50"
exit 1
fi
fi

View File

@@ -1,113 +0,0 @@
#!/usr/bin/env bash
#
# update.sh — Update the Zopu runtime to a specific branch or commit.
#
# Usage:
# update.sh # update to latest dogfood/v0
# update.sh dogfood/v0 # update to latest of branch dogfood/v0
# update.sh <commit-sha> # checkout and build a specific commit
#
# The argument is treated as a branch name first; if no matching remote
# tracking branch exists it is treated as a commit SHA.
#
# .env is gitignored and is never touched by git operations. It survives
# updates and rollbacks unchanged.
#
# Must be run as root (uses runuser to build as the service user).
set -euo pipefail
INSTALL_DIR="${ZOPU_INSTALL_DIR:-/opt/zopu}"
SERVICE_USER="${ZOPU_SERVICE_USER:-zopu}"
TARGET="${1:-dogfood/v0}"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
log() { echo -e "${GREEN}[update]${NC} $*"; }
warn() { echo -e "${YELLOW}[update]${NC} $*"; }
err() { echo -e "${RED}[update]${NC} $*" >&2; }
run_as_service() {
runuser -u "$SERVICE_USER" -- "$@"
}
cd "$INSTALL_DIR"
# Record current commit for rollback
CURRENT_SHA=$(git rev-parse HEAD)
log "Current HEAD: ${CURRENT_SHA:0:12}"
echo "$CURRENT_SHA" > "${INSTALL_DIR}/.last-deployed-sha"
# Fetch all remotes
log "Fetching from origin..."
git fetch origin
# Resolve target: branch first, then commit
if git show-ref --verify --quiet "refs/remotes/origin/${TARGET}"; then
log "Target is a branch: ${TARGET}"
git checkout -B "$TARGET" "origin/${TARGET}"
elif git rev-parse --verify "${TARGET}^{commit}" &>/dev/null; then
log "Target is a commit: ${TARGET:0:12}"
git checkout "$TARGET"
else
err "'${TARGET}' is not a known branch or valid commit."
err "Available branches: $(git branch -r | tr '\n' ' ')"
exit 1
fi
NEW_SHA=$(git rev-parse HEAD)
log "Now at: ${NEW_SHA:0:12}"
# Restore ownership of the checkout to the service user after git operations
log "Setting ownership of checkout to $SERVICE_USER..."
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
# Confirm .env is intact and has correct permissions
if [[ -f "${INSTALL_DIR}/.env" ]]; then
log ".env preserved."
chmod 600 "${INSTALL_DIR}/.env"
else
err ".env is missing! Restore it from backup before starting services."
fi
# Install and build
log "Running bun install..."
run_as_service bun install
log "Building web app..."
run_as_service bun run --cwd apps/web build
log "Validating daemon production build..."
run_as_service bun run build:daemon
log "Building agent service..."
run_as_service bun run build:agents
# Graceful restart: daemon first (owns RivetKit engine), then agent and web
log "Restarting services..."
systemctl restart zopu-daemon
sleep 3
systemctl restart zopu-agent
systemctl restart zopu-web
sleep 5
# Health check
HEALTH_SCRIPT="${INSTALL_DIR}/deploy/zopu-runtime/scripts/health-check.sh"
if [[ -x "$HEALTH_SCRIPT" ]]; then
log "Running health check..."
if "$HEALTH_SCRIPT"; then
log "Update complete and healthy."
else
err "Health check failed after update!"
err " journalctl -u zopu-daemon -n 50"
err " journalctl -u zopu-agent -n 50"
err " journalctl -u zopu-web -n 50"
err "To rollback: ${INSTALL_DIR}/deploy/zopu-runtime/scripts/rollback.sh"
exit 1
fi
else
log "Update complete. Verify manually."
fi

View File

@@ -1,40 +0,0 @@
[Unit]
Description=Zopu Agent Service (Flue Node server)
After=network-online.target zopu-daemon.service
Wants=network-online.target
[Service]
Type=simple
User=__SERVICE_USER__
Group=__SERVICE_USER__
WorkingDirectory=__INSTALL_DIR__/packages/agents
EnvironmentFile=__INSTALL_DIR__/.env
Environment=PORT=3583
# Flue's Node target requires Node built-ins such as node:sqlite.
# Listens on the service-pinned port 3583.
ExecStart=/usr/bin/node dist/server.mjs
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=zopu-agent
# Resource limits
MemoryMax=8G
CPUWeight=80
# Security hardening
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
# Docker socket access for Orb sandbox runtime (lives in agent process)
SupplementaryGroups=docker
[Install]
WantedBy=multi-user.target

View File

@@ -1,42 +0,0 @@
[Unit]
Description=Zopu Daemon (Bun/Effect + AgentOS/RivetKit)
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=__SERVICE_USER__
Group=__SERVICE_USER__
WorkingDirectory=__INSTALL_DIR__
EnvironmentFile=__INSTALL_DIR__/.env
Environment=HOME=/var/lib/zopu
StateDirectory=zopu
# RivetKit in-process engine: envoy mode (serverful).
# Run the Bun source entrypoint so AgentOS software assets retain their real
# node_modules paths; Bun compiled executables do not embed .aospkg files.
ExecStart=/usr/local/bin/bun --env-file=__INSTALL_DIR__/.env apps/daemon/src/index.ts
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=zopu-daemon
# Resource limits (12-core, 40 GB host)
MemoryMax=8G
CPUWeight=100
# Security hardening
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
# Docker socket access (for future Orb sandboxes; group membership required)
SupplementaryGroups=docker
[Install]
WantedBy=multi-user.target

View File

@@ -1,7 +0,0 @@
[Unit]
Description=Zopu Docker Cleanup
After=docker.service
[Service]
Type=oneshot
ExecStart=__INSTALL_DIR__/deploy/zopu-runtime/scripts/docker-cleanup.sh

View File

@@ -1,9 +0,0 @@
[Unit]
Description=Zopu Docker Cleanup (daily)
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -1,11 +0,0 @@
[Unit]
Description=Zopu Health Check
[Service]
Type=oneshot
User=__SERVICE_USER__
EnvironmentFile=__INSTALL_DIR__/.env
# health-check.sh reads ENV_FILE to find the .env to source. Set it to the
# install path so custom install dirs work correctly.
Environment=ENV_FILE=__INSTALL_DIR__/.env
ExecStart=__INSTALL_DIR__/deploy/zopu-runtime/scripts/health-check.sh --quiet

View File

@@ -1,10 +0,0 @@
[Unit]
Description=Zopu Health Check (every 60s)
[Timer]
OnBootSec=30
OnUnitActiveSec=60
AccuracySec=5
[Install]
WantedBy=timers.target

View File

@@ -1,26 +0,0 @@
[Unit]
Description=Zopu Web
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=__SERVICE_USER__
Group=__SERVICE_USER__
WorkingDirectory=__INSTALL_DIR__/apps/web
EnvironmentFile=__INSTALL_DIR__/.env
Environment=HOST=127.0.0.1
Environment=PORT=13100
ExecStart=/usr/local/bin/bun run start
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=zopu-web
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target

312
docs/LOCAL_SETUP.md Normal file
View File

@@ -0,0 +1,312 @@
# Local Setup
This guide runs the active Zopu stack locally, including the browser chat and the AgentOS issue-to-PR path.
## What runs
```text
Browser (React Router/Vite, :5173)
-> Convex Cloud (durable product state, auth, workflows)
-> Flue agent server (:3585)
-> Rivet Engine guard endpoint (:6420)
-> AgentOS registry runner
-> isolated Pi workspace mounted from the local repository
-> Gitea branch and pull request
```
The active stack does not use `repos/`; that directory is archived reference code. In particular, the old standalone server on port `3590` is not part of this setup.
## Requirements
- macOS or Linux
- Bun and Node.js matching the repository toolchain (`packages/agents` requires Node `>=22.18 <23 || >=23.6`)
- pnpm 11
- Git with SSH access to `git.openputer.com`
- [Tea](https://gitea.com/gitea/tea) authenticated to `https://git.openputer.com`
- a Convex account with access to the development deployment
- an OpenAI-completions-compatible model endpoint and API key
Install dependencies from the repository root:
```bash
bun install --frozen-lockfile
```
Confirm Git and Tea access before testing issue or PR tools:
```bash
git ls-remote origin HEAD
tea login list
tea issues list
```
The default Tea login should target `https://git.openputer.com`. Never put credentials in committed files.
## Environment
Create the local environment file:
```bash
cp .env.example .env
```
At minimum, configure these groups.
### Application and Convex
```env
CONVEX_DEPLOYMENT=dev:<deployment-name>
CONVEX_URL=https://<deployment>.convex.cloud
CONVEX_SITE_URL=https://<deployment>.convex.site
SITE_URL=http://localhost:5173
VITE_AUTH_URL=http://localhost:5173
VITE_CONVEX_URL=https://<deployment>.convex.cloud
```
### Flue and model provider
```env
FLUE_DB_TOKEN=<shared-random-token>
FLUE_URL=http://127.0.0.1:3585
AGENT_MODEL_PROVIDER=<provider-name>
AGENT_MODEL_NAME=<model-name>
AGENT_MODEL_API=openai-completions
AGENT_MODEL_BASE_URL=https://<model-endpoint>/v1
AGENT_MODEL_API_KEY=<model-api-key>
AGENT_MODEL_CONTEXT_WINDOW=<positive-integer>
AGENT_MODEL_MAX_TOKENS=<positive-integer>
```
`FLUE_DB_TOKEN` must match the value stored in the Convex deployment.
### Rivet and AgentOS
```env
RIVET_ENDPOINT=http://127.0.0.1:6420
RIVET_PUBLIC_ENDPOINT=http://127.0.0.1:6420
RIVET_WORKSPACE_TOKEN=<random-string-at-least-32-characters>
ZOPU_SOURCE_REPOSITORY=/absolute/path/to/zopu-code
AGENT_WORKSPACE_ROOT=/absolute/path/to/zopu-agent-workspaces
```
Use the same repository checkout for `ZOPU_SOURCE_REPOSITORY`. The harness creates Git worktrees beneath `AGENT_WORKSPACE_ROOT`, copies the source checkout's `.env`, installs dependencies, and mounts the isolated checkout into AgentOS.
Port `6420` is the Rivet Engine guard endpoint used by the application. Port `6421` is an internal engine API-peer port and must not be used as `RIVET_ENDPOINT`.
### Gitea
```env
GITEA_URL=https://git.openputer.com
GITEA_TOKEN=<personal-access-token>
```
`GITEA_TOKEN` is optional for read-only agent startup but required for the complete issue-to-PR flow unless Tea and Git already have sufficient local credentials.
The legacy variables `VITE_FLUE_URL` and `VITE_ZOPU_SERVER_URL` are not used by the active web chat path.
## One-time Convex setup
On a new machine, authenticate and configure the Convex development deployment:
```bash
bun run dev:setup
```
Then configure the deployment-scoped values from `packages/backend`:
```bash
cd packages/backend
bunx convex env set SITE_URL 'http://localhost:5173'
bunx convex env set FLUE_DB_TOKEN '<same value as .env>'
bunx convex env set FLUE_URL 'http://<host-reachable-ip>:3585'
```
Convex runs in the cloud, so `FLUE_URL` must be reachable from the Convex deployment. `127.0.0.1` and `localhost` point at Convex's machine, not your laptop. For the current Mac setup, expose Flue over Tailscale and use the Mac's Tailscale address, for example `http://100.x.y.z:3585`.
`SITE_URL` is deployment-scoped and single-valued. Set it to the exact browser origin currently being tested:
```bash
# Browser opened locally
bunx convex env set SITE_URL 'http://localhost:5173'
# Browser opened from another device over Tailscale
bunx convex env set SITE_URL 'http://<tailscale-ip>:5173'
```
If work execution uses a separate agent URL, set `AGENT_BACKEND_URL`; otherwise it falls back to `FLUE_URL`.
## Start the stack
Run each process in its own terminal. The order matters for the AgentOS path.
### 1. Start Convex development
```bash
bun run dev:server
```
This watches and publishes functions to the configured Convex cloud development deployment.
### 2. Start Rivet Engine
The installed RivetKit 2.3.9 package supplies the platform-specific `rivet-engine` binary. Start it with:
```bash
./node_modules/.pnpm/@rivetkit+engine-cli-*/node_modules/@rivetkit/engine-cli-*/rivet-engine start
```
There must be exactly one local engine listening on `127.0.0.1:6420`. Do not start a second engine, and do not use `npx rivetkit dev`; that command is unavailable in the installed version.
### 3. Start the AgentOS registry runner
```bash
cd packages/agents
bun run runner
```
The runner registers the AgentOS workspace actor with Rivet Engine. Keep it running whenever a coding attempt or issue-to-PR request may execute.
### 4. Start Flue agents
For laptop-only access:
```bash
bun run dev:agents -- --port 3585
```
For Convex callbacks or access from another device, bind Flue to all interfaces:
```bash
bun run dev:tailscale:agents -- --port 3585
```
Always pass port `3585`; the Flue CLI defaults to `3583`, but the current Zopu configuration and Convex environment use `3585`.
If the shell already exports remote Rivet values, shell values override `.env`. Start Flue with explicit local values:
```bash
RIVET_ENDPOINT=http://127.0.0.1:6420 \
RIVET_PUBLIC_ENDPOINT=http://127.0.0.1:6420 \
bun run dev:tailscale:agents -- --port 3585
```
### 5. Start the web application
For laptop-only access:
```bash
bun run dev:web
```
For phone or other Tailscale-device access:
```bash
bun run dev:tailscale:web
```
Open `http://localhost:5173`, or `http://<tailscale-ip>:5173` when using the Tailscale command.
## Verify the setup
Check the listeners:
```bash
curl -I http://127.0.0.1:6420
curl -I http://127.0.0.1:3585
curl -I http://127.0.0.1:5173
```
Any HTTP response confirms the process is reachable; these roots may redirect or return `404` because their functional routes live elsewhere.
Then verify behavior in order:
1. Open the web app and sign in.
2. Send a simple chat message and confirm the response streams back through Convex.
3. Ask Zopu to list open Gitea issues.
4. For the full execution path, send `Create a PR for issue #N` for an open, unassigned test issue.
5. Confirm chat immediately reports that the issue was accepted.
6. Follow the Flue server logs for `[create_pr_for_issue]` and wait for a `completed` line with the PR URL.
7. Confirm the branch and pull request in Gitea.
The PR pipeline is asynchronous: the initial chat turn acknowledges acceptance, while AgentOS implements, commits, pushes, and opens the pull request in the background.
## Request flow
```text
Web sends a conversation mutation to Convex
-> Convex persists the exact message
-> Convex dispatches to FLUE_URL/agents/zopu/<organizationId>
-> Flue runs the Zopu agent and its typed tools
-> responses return to Convex
-> the web observes Convex's reactive projection
For code execution:
-> Flue calls the local AgentOS harness
-> the harness creates an isolated Git worktree
-> a Rivet actor boots an AgentOS VM and mounts the worktree
-> Pi implements the issue and produces a candidate revision
-> the host pushes a unique branch
-> Tea creates a Gitea pull request
```
## Common failures
### Chat stays pending or reports that Flue is unavailable
- Confirm Flue is listening on `3585`.
- Confirm the Convex deployment's `FLUE_URL` uses a host-reachable address, not `localhost`.
- Confirm `FLUE_DB_TOKEN` is identical in `.env` and the Convex deployment.
### Sign-in fails or the browser reports CORS errors
Set Convex `SITE_URL` to the exact browser origin. The shared development deployment trusts only the current single value.
### AgentOS fails while mounting the repository
- Confirm both Rivet endpoints use port `6420`.
- Confirm the engine and `bun run runner` are both running.
- Confirm `ZOPU_SOURCE_REPOSITORY` contains `.git`.
- Confirm `AGENT_WORKSPACE_ROOT` is writable.
- Restart the runner after changing AgentOS registry configuration; Flue hot reload is not enough.
The harness clients require CBOR encoding for host-directory mount descriptors. Do not remove the `encoding: "cbor"` configuration in `packages/agents/src/runtime/agent-os.ts`.
### AgentOS reports an ACP completed-message resource limit
The workspace registry raises `limits.acp.maxCompletedMessageBytes` for long Pi coding runs. Restart the runner so the updated registry configuration is registered with Rivet Engine.
### Flue connects to a remote Rivet deployment unexpectedly
Environment variables exported by the shell override values loaded from `.env`. Start Flue and the runner with explicit `RIVET_ENDPOINT` and `RIVET_PUBLIC_ENDPOINT` values pointing to `127.0.0.1:6420`.
### Gitea issue or PR commands fail
Run:
```bash
tea login list
git ls-remote origin HEAD
tea issues list
```
Confirm the default Tea login, SSH key, repository remote, and token all target `git.openputer.com`.
## Repository checks
After changing source or configuration:
```bash
bunx ultracite check <changed-files>
bun run check-types
bun run check
```
For agent-only changes, run `bun run check-types` from `packages/agents` before the root check.
See also:
- [`TECH.md`](TECH.md) for architecture and ownership boundaries
- [`deployment.md`](deployment.md) for the shared staging deployment
- [Rivet AgentOS quickstart](https://rivet.dev/docs/agent-os/quickstart)
- [Flue local development](https://flue.dev/docs/cli/dev)

View File

@@ -16,10 +16,12 @@ Dense context set for product development and coding agents.
9. evaluation.md — quality measurement and improvement
```
For a runnable development environment, see [`LOCAL_SETUP.md`](LOCAL_SETUP.md).
## Use by role
| Role | Minimum context |
|---|---|
| --- | --- |
| Product/definition agent | agent-context, product, glossary, dev-loop |
| Architecture/design agent | agent-context, tech, dev-loop, current Work Definition |
| Coding agent | agent-context, current Design Packet/Slice, tech sections, repository rules |

78
docs/auth-proxy.md Normal file
View File

@@ -0,0 +1,78 @@
# Auth Proxy — Production Ingress Requirement
The application uses **same-origin authentication**: the browser and React Router SSR both hit
`/api/auth/*` on the public application domain. This keeps cookies first-party, avoids
cross-origin credentials, and gives development and production the same API surface.
## Required production route
At the public application domain, route:
| Prefix | Target |
|---|---|
| `/api/auth/*` | Convex HTTP site |
| `/*` | React Router frontend |
### Caddy
```caddy
zopu.example.com {
handle /api/auth/* {
reverse_proxy https://befitting-dalmatian-161.convex.site {
header_up Host befitting-dalmatian-161.convex.site
}
}
handle {
reverse_proxy frontend:3000
}
}
```
### Dokploy / Traefik
Create a higher-priority path router for `/api/auth` that forwards to the external Convex
site URL. Ensure it:
- Preserves the original browser `Cookie` header
- Passes `Set-Cookie` responses back (rewrite domain if Convex emits an explicit one)
- Preserves the original method and body
- Forwards `X-Forwarded-Host` and `X-Forwarded-Proto`
- Passes the full path unchanged (e.g. `/api/auth/convex/token`)
- Does not cache auth responses
- Allows OAuth callback routes under the same prefix
## Convex environment
The Convex deployment `SITE_URL` must match the public origin users visit, not the Convex
site URL:
```bash
# Production
npx convex env set SITE_URL 'https://zopu.example.com'
# Local
npx convex env set SITE_URL 'http://100.101.157.28:5173'
# Staging
npx convex env set SITE_URL 'https://zopu.cheaptricks.puter.wtf'
```
This value populates Better Auth's `trustedOrigins`. The Convex deployment also uses
`CONVEX_SITE_URL` as the internal `baseURL` for route registration; that value is the HTTP
site URL (e.g. `https://befitting-dalmatian-161.convex.site`).
## Why same-origin
1. **First-party cookies.** No `SameSite=None`, no third-party cookie restrictions.
2. **SSR consistency.** The React Router server uses the same auth surface the browser
sees; no cross-host credential translation.
3. **No CORS complexity.** The browser talks only to its own origin.
4. **The reverse proxy handles the cross-host hop server-side.**
## Related
- [Better Auth: Trusted Origins](https://github.com/better-auth/better-auth/blob/main/docs/content/docs/reference/security.mdx)
- `apps/web/vite.config.ts` — Vite dev proxy (`/api/auth` → Convex site)
- `packages/auth/src/web/auth-client.ts``window.location.origin`
- `apps/web/src/lib/auth.server.ts` — SSR token loader via request origin

128
docs/git-provider-setup.md Normal file
View File

@@ -0,0 +1,128 @@
# Git Provider Setup
This document covers manual setup for GitHub OAuth, Puter Git (Gitea), webhooks, and Convex environment variables.
> **Never place admin or user tokens in checked-in `.env` files or browser variables.** All secrets must be Convex environment variables set via `npx convex env set`.
## GitHub OAuth Application
1. Go to GitHub Settings > Developer settings > OAuth Apps > New OAuth App.
2. Set the application name (e.g., "Zopu").
3. Set the homepage URL to your `SITE_URL` (e.g., `https://zopu.cheaptricks.puter.wtf`).
4. Set the callback URL to `<SITE_URL>/api/auth/callback/github`.
5. Generate a client secret.
6. Set Convex environment variables:
```
npx convex env set GITHUB_CLIENT_ID <your-client-id>
npx convex env set GITHUB_CLIENT_SECRET <your-client-secret>
```
### Requested scopes
Zopu requests `repo` and `read:org`. The `repo` scope grants access to private repositories. `read:org` allows listing organization repositories.
## GitHub Webhook (manual setup for OAuth-based version)
1. Go to the GitHub repository Settings > Webhooks > Add webhook.
2. Payload URL: `<CONVEX_SITE_URL>/api/git/webhooks/github`
3. Content type: `application/json`
4. Secret: generate a strong random string and set it as:
```
npx convex env set GITHUB_WEBHOOK_SECRET <your-webhook-secret>
```
5. Select individual events:
- Push
- Repository (created, deleted, transferred, renamed, visibility)
- Delete
- Create
- Public
- Fork
6. Do not select "Send me everything."
## Puter Git (Gitea) Admin Token
1. As a Gitea admin, go to Settings > Applications > Generate New Token.
2. Select scopes: `write:admin`, `write:organization`, `write:repository`.
3. Set the token:
```
npx convex env set PUTER_GIT_ADMIN_TOKEN <your-admin-token>
```
This token is used only for platform administration (user creation, organization creation, repository creation, migration). It is never used for end-user operations.
## Puter Git Webhook
After a repository is created or migrated, Zopu ensures a webhook is configured automatically. If manual setup is needed:
1. Go to the Gitea repository Settings > Webhooks > Add Webhook > Gitea.
2. Target URL: `<CONVEX_SITE_URL>/api/git/webhooks/puter`
3. HTTP method: POST
4. Content-Type: `application/json`
5. Secret: generate a strong random string and set it as:
```
npx convex env set GITEA_WEBHOOK_SECRET <your-webhook-secret>
```
6. Trigger on: Push events, Repository events.
## Credential Encryption Key
Generate a 32-byte encryption key for AES-GCM credential encryption:
```bash
openssl rand -base64 32 | base64 | tr -d '\n' | pbcopy
```
Set it as:
```
npx convex env set GIT_CREDENTIAL_ENCRYPTION_KEY <base64url-encoded-32-bytes>
```
The key must be exactly 32 bytes when base64url-decoded.
## Same-Origin Proxy for Better Auth Callbacks
Better Auth requires the callback URL to be on the same origin as `SITE_URL`. If your Convex deployment uses a different origin:
1. Configure a reverse proxy (e.g., Caddy) to forward `/api/auth/*` to the Convex deployment.
2. Set `SITE_URL` to the proxy origin.
3. Set `CONVEX_SITE_URL` to the Convex deployment URL.
## Validation Procedures
### Verify a Puter PAT connection
1. Connect a Puter PAT through the UI.
2. Verify the connection state shows `active`.
3. List private repositories to confirm token validity.
### Verify GitHub OAuth
1. Click "Connect GitHub" in the UI.
2. Complete the GitHub OAuth flow.
3. Verify the connection state shows `active`.
### Reconnection
If a token is expired or revoked:
1. The connection state shows `reauth-required`.
2. Reconnect the provider through the UI.
3. The old connection state is updated to `active`.
4. Projects, repositories, Work, and artifacts are not deleted.
## Environment Variables Summary
| Variable | Required | Description |
| --- | --- | --- |
| `SITE_URL` | Yes | Public-facing URL |
| `CONVEX_SITE_URL` | Yes | Convex deployment URL (may differ from SITE_URL with proxy) |
| `GIT_CREDENTIAL_ENCRYPTION_KEY` | Yes | Base64url-encoded 32-byte AES-GCM key |
| `PUTER_GIT_ADMIN_TOKEN` | Yes | Gitea admin token for provisioning |
| `GITHUB_CLIENT_ID` | For GitHub | OAuth app client ID |
| `GITHUB_CLIENT_SECRET` | For GitHub | OAuth app client secret |
| `GITHUB_WEBHOOK_SECRET` | For GitHub webhooks | HMAC verification secret |
| `GITEA_WEBHOOK_SECRET` | For Puter webhooks | HMAC verification secret |
| `FLUE_DB_TOKEN` | Yes | Private agent backend token |
| `FLUE_URL` | Optional | Private agent backend URL |
| `NATIVE_APP_URL` | Optional | Native app deep-link scheme |

View File

@@ -13,39 +13,39 @@
"packages/ui"
],
"catalog": {
"@rivet-dev/agentos": "^0.2.7",
"@rivet-dev/agentos-core": "^0.2.10",
"@rivet-dev/agentos": "0.2.14",
"@rivet-dev/agentos-core": "0.2.14",
"@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",
"dotenv": "17.4.2",
"zod": "4.4.3",
"lucide-react": "1.27.0",
"next-themes": "0.4.6",
"react": "19.2.8",
"react-dom": "19.2.8",
"sonner": "^2.0.7",
"convex": "^1.42.1",
"sonner": "2.0.7",
"convex": "1.42.3",
"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",
"@convex-dev/better-auth": "0.12.5",
"@tanstack/react-form": "1.33.2",
"@types/react-dom": "19.2.3",
"tailwindcss": "4.3.3",
"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",
"typescript": "7.0.2",
"@types/bun": "1.3.14",
"heroui-native": "1.0.6",
"vite": "8.1.5",
"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",
"@types/react": "19.2.17",
"@types/node": "22.20.1",
"hono": "4.12.32",
"valibot": "1.4.2",
"streamdown": "2.5.0",
"@tailwindcss/postcss": "^4.3.2",
"@tailwindcss/vite": "^4.3.2"
"@tailwindcss/postcss": "4.3.3",
"@tailwindcss/vite": "4.3.3"
}
},
"type": "module",
@@ -53,7 +53,7 @@
"dev": "vp run -r dev",
"build": "vp run -r build",
"check-types": "vp run -r check-types",
"check": "ultracite check package.json vite.config.ts apps/web/package.json apps/web/src/root.tsx apps/web/src/index.css apps/web/src/components/chat apps/web/src/components/slice-one apps/web/src/hooks/chat apps/web/src/hooks/slice-one apps/web/src/lib/chat apps/web/src/lib/slice-one apps/web/src/routes.ts apps/web/src/routes/app/dashboard/page.tsx apps/web/src/routes/app/mobile/page.tsx packages/agents/package.json packages/agents/src/agents/zopu.ts packages/agents/src/app.ts packages/agents/src/auth.ts packages/agents/src/tools/slice-one.ts packages/backend/package.json packages/backend/convex/conversationMessages.ts packages/backend/convex/conversationMessages.test.ts packages/backend/convex/fluePersistence.test.ts packages/backend/convex/projects.ts packages/backend/convex/schema.ts packages/backend/convex/signalRouting.ts packages/backend/convex/signalRouting.test.ts packages/backend/convex/works.ts packages/backend/convex/works.test.ts packages/backend/convex/workArtifacts.ts packages/backend/convex/workArtifacts.test.ts packages/backend/convex/workExecution.ts packages/backend/convex/workExecution.test.ts packages/backend/convex/workPlanning.ts packages/backend/convex/workPlanning.test.ts packages/backend/convex/crons.ts packages/primitives/src/work.ts packages/primitives/src/work.test.ts packages/primitives/src/work-artifact.ts packages/primitives/src/work-artifact.test.ts packages/primitives/src/resolver.ts packages/primitives/src/work-lifecycle.ts packages/primitives/src/work-resolution.test.ts",
"check": "ultracite check package.json vite.config.ts apps/web/package.json apps/web/src/root.tsx apps/web/src/index.css apps/web/src/components/chat apps/web/src/components/workspace apps/web/src/hooks/chat apps/web/src/hooks/workspace apps/web/src/lib/chat apps/web/src/lib/workspace apps/web/src/routes.ts apps/web/src/routes/app/dashboard/page.tsx apps/web/src/routes/app/workspace/page.tsx packages/agents/package.json packages/agents/src/agents/zopu.ts packages/agents/src/app.ts packages/agents/src/auth.ts packages/agents/src/tools/slice-one.ts packages/backend/package.json packages/backend/convex/conversationMessages.ts packages/backend/convex/conversationMessages.test.ts packages/backend/convex/fluePersistence.test.ts packages/backend/convex/projects.ts",
"lint": "oxlint --disable-nested-config",
"format": "vp fmt",
"staged": "vp staged",
@@ -65,14 +65,10 @@
"dev:server": "vp run --filter @code/backend dev",
"dev:setup": "vp run --filter @code/backend dev:setup",
"build:agents": "vp run --filter @code/agents build",
"docs:update": "bun run scripts/update-docs.ts",
"subtree": "bun run scripts/subtree.ts",
"fix": "ultracite fix",
"slice1": "vp run -r dev",
"dev:zopu": "vp run --filter @code/agents dev",
"dev:zopu:web": "vp run --filter web dev"
"docs:update": "node scripts/update-docs.ts",
"subtree": "node scripts/subtree.ts",
"fix": "ultracite fix"
},
"dependencies": {},
"devDependencies": {
"@code/backend": "workspace:*",
"@code/config": "workspace:*",
@@ -84,8 +80,8 @@
"convex": "catalog:",
"convex-test": "catalog:",
"effect": "catalog:",
"oxfmt": "latest",
"oxlint": "latest",
"oxfmt": "0.61.0",
"oxlint": "1.76.0",
"rolldown": "1.1.4",
"typescript": "catalog:",
"ultracite": "7.9.3",
@@ -97,5 +93,5 @@
"react-dom": "19.2.8",
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2"
},
"packageManager": "bun@1.3.14"
"packageManager": "pnpm@11.17.0"
}

View File

@@ -0,0 +1,32 @@
FROM oven/bun:1.3.14 AS bun
FROM node:24-bookworm-slim AS build
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
g++ \
make \
python3 \
&& rm -rf /var/lib/apt/lists/*
COPY . .
RUN bun install --frozen-lockfile
RUN node packages/agents/node_modules/@flue/cli/bin/flue.mjs build --target node --root packages/agents
FROM node:24-bookworm-slim
ENV NODE_OPTIONS=--experimental-specifier-resolution=node
ENV NODE_ENV=production
ENV PORT=3000
WORKDIR /app
COPY --from=build /app /app
EXPOSE 3000
CMD ["node", "packages/agents/dist/server.mjs"]

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
import { AsyncLocalStorage } from "node:async_hooks";
export interface TurnAdmissionContext {
readonly clientRequestId: string;
readonly turnId: string;
}
const turnAdmissionContext = new AsyncLocalStorage<TurnAdmissionContext>();
export const currentTurnAdmission = (): TurnAdmissionContext | undefined =>
turnAdmissionContext.getStore();
export const withTurnAdmission = <T>(
context: TurnAdmissionContext,
run: () => T
): T => turnAdmissionContext.run(context, run);

View File

@@ -1,59 +1,21 @@
import { parseAgentEnv } from "@code/env/agent";
import { defineAgent } from "@flue/runtime";
import { local } from "@flue/runtime/node";
import INSTRUCTIONS from "../prompts/zopu-instructions.md" with { type: "markdown" };
import { createCreatePrForIssueTool } from "../tools/create-pr-for-issue";
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 {
convexAgentRoute as attachments,
convexAgentRoute as route,
} from "../auth";
// The host checkout the chat agent explores. The `local()` sandbox reuses the
// machine's existing shell, Git, and Tea install unchanged; its cwd becomes the
// default working directory for every command the agent runs.
const ZOPU_CODE_PATH = "/Users/puter/Workspace/zopu/code";
export default defineAgent(({ env, id }) => {
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
@@ -62,7 +24,8 @@ export default defineAgent(({ env, id }) => {
"Turns actionable conversation into provenanced Signals and proposed Work.",
instructions: INSTRUCTIONS,
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
thinkingLevel: "medium",
tools: createSliceOneTools(id, env),
sandbox: local({ cwd: ZOPU_CODE_PATH }),
thinkingLevel: "high",
tools: [...createSliceOneTools(id, env), createCreatePrForIssueTool(env)],
};
});

View File

@@ -1,4 +1,5 @@
import { parseAgentEnv } from "@code/env/agent";
import { WorkAttemptExecutionError } from "@code/primitives/execution-runtime";
import { registerProvider } from "@flue/runtime";
import type { Fetchable } from "@flue/runtime/routing";
import { flue } from "@flue/runtime/routing";
@@ -42,8 +43,23 @@ app.post("/internal/work-attempts/execute", async (context) => {
try {
return context.json(await executeAgentOsAttempt(await context.req.json()));
} 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(
{ error: error instanceof Error ? error.message : "Execution failed" },
{
error: {
message: failure.message,
reason: failure.reason,
retryable: failure.retryable,
},
},
500
);
}
@@ -55,11 +71,16 @@ app.post("/internal/work-attempts/:workspaceKey/cancel", async (context) => {
) {
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 });
});
app.all("/api/rivet/*", (context) => runtimeRegistry.handler(context.req.raw));
app.route("/", flue());
export default app satisfies Fetchable;

View File

@@ -1,6 +1,8 @@
import { parseAgentEnv } from "@code/env/agent";
import type { AgentRouteHandler } from "@flue/runtime";
import { withTurnAdmission } from "./admission-context";
/** Only Convex may invoke or observe the organization-scoped Flue agent. */
export const convexAgentRoute: AgentRouteHandler = async (context, next) => {
const env = parseAgentEnv(process.env);
@@ -14,5 +16,15 @@ export const convexAgentRoute: AgentRouteHandler = async (context, next) => {
return context.json({ error: "Forbidden" }, 403);
}
return await next().then(() => context.res);
if (context.req.method !== "POST") {
return await next();
}
const clientRequestId = context.req.header("x-zopu-request-id");
const turnId = context.req.header("x-zopu-turn-id");
if (!clientRequestId || !turnId) {
return context.json({ error: "Missing turn correlation headers" }, 400);
}
return await withTurnAdmission({ clientRequestId, turnId }, () => next());
};

View File

@@ -26,11 +26,74 @@
*/
import { env } from "@code/env/server";
import { AttachmentConflictError, DEFAULT_LIST_LIMIT, DEFAULT_READ_LIMIT, MAX_LIST_LIMIT, MAX_READ_LIMIT, StreamListenerRegistry, assertSupportedFlueSchemaVersion, clampLimit, copyAttachmentBytes, createSessionStorageKey, decodeRunCursor, encodeRunCursor, formatOffset, hydratePersistedDirectSubmission, parseOffset, prepareDirectSubmission, verifyAttachmentBytes } from '@flue/runtime/adapter';
import type { AgentAttemptMarker, AgentDispatchAdmission, AgentDispatchReceipt, AgentExecutionStore, AgentSubmission, AgentSubmissionStore, AttachmentRef, AttachmentStore, ConversationProducerClaim, ConversationRecord, ConversationStreamBatch, ConversationStreamIdentity, ConversationStreamMeta, ConversationStreamReadResult, ConversationStreamStore, CreateRunInput, DirectAgentSubmissionInput, DispatchAgentSubmissionInput, DispatchInput, EndRunInput, EventStreamMeta, EventStreamReadResult, EventStreamStore, GetAttachmentInput, PersistedChunkRow, PersistenceAdapter, PersistenceStores, PutAttachmentInput, RunPointer, RunRecord, RunStore, RunStatus, StoredAttachment, SubmissionAttemptRef, SubmissionClaimRef, SubmissionDurability, SubmissionSettlementObligation, SubmissionSettledRecord } from '@flue/runtime/adapter';
import {
AttachmentConflictError,
DEFAULT_LIST_LIMIT,
DEFAULT_READ_LIMIT,
MAX_LIST_LIMIT,
MAX_READ_LIMIT,
StreamListenerRegistry,
assertSupportedFlueSchemaVersion,
clampLimit,
copyAttachmentBytes,
createSessionStorageKey,
decodeRunCursor,
encodeRunCursor,
formatOffset,
hydratePersistedDirectSubmission,
parseOffset,
prepareDirectSubmission,
verifyAttachmentBytes,
} from "@flue/runtime/adapter";
import type {
AgentAttemptMarker,
AgentDispatchAdmission,
AgentDispatchReceipt,
AgentExecutionStore,
AgentSubmission,
AgentSubmissionStore,
AttachmentRef,
AttachmentStore,
ConversationProducerClaim,
ConversationRecord,
ConversationStreamBatch,
ConversationStreamIdentity,
ConversationStreamMeta,
ConversationStreamReadResult,
ConversationStreamStore,
CreateRunInput,
DirectAgentSubmissionInput,
DispatchAgentSubmissionInput,
DispatchInput,
EndRunInput,
EventStreamMeta,
EventStreamReadResult,
EventStreamStore,
GetAttachmentInput,
PersistedChunkRow,
PersistenceAdapter,
PersistenceStores,
PutAttachmentInput,
RunPointer,
RunRecord,
RunStore,
RunStatus,
StoredAttachment,
SubmissionAttemptRef,
SubmissionClaimRef,
SubmissionDurability,
SubmissionSettlementObligation,
SubmissionSettledRecord,
} from "@flue/runtime/adapter";
import { ConvexHttpClient } from "convex/browser";
import { makeFunctionReference } from 'convex/server';
import type { FunctionArgs, FunctionReference, FunctionReturnType } from 'convex/server';
import { makeFunctionReference } from "convex/server";
import type {
FunctionArgs,
FunctionReference,
FunctionReturnType,
} from "convex/server";
import { currentTurnAdmission } from "./admission-context";
// ---------------------------------------------------------------------------
// Token + arg types
@@ -42,7 +105,10 @@ import type { FunctionArgs, FunctionReference, FunctionReturnType } from 'convex
// ---------------------------------------------------------------------------
/** Auth token present on every Convex function call. */
interface TokenArgs { readonly token: string; readonly [key: string]: unknown }
interface TokenArgs {
readonly token: string;
readonly [key: string]: unknown;
}
const TOKEN = (): string => env.FLUE_DB_TOKEN;
@@ -152,7 +218,7 @@ interface AttachmentRefWire {
// ---------------------------------------------------------------------------
const checkSchemaVersion = makeFunctionReference<"query", TokenArgs, string>(
"fluePersistence:checkSchemaVersion",
"fluePersistence:checkSchemaVersion"
);
const getSubmission = makeFunctionReference<
@@ -221,7 +287,11 @@ interface AdmitSubmissionResponse {
}
const admitSubmission = makeFunctionReference<
"mutation",
TokenArgs & { readonly input: AdmitSubmissionEnvelope },
TokenArgs & {
readonly clientRequestId?: string;
readonly input: AdmitSubmissionEnvelope;
readonly turnId?: string;
},
AdmitSubmissionResponse
>("fluePersistence:admitSubmission");
@@ -290,11 +360,9 @@ type SettleArgs = TokenArgs &
readonly outcome: "completed" | "failed";
readonly errorJson?: string;
};
const settleSubmission = makeFunctionReference<
"mutation",
SettleArgs,
boolean
>("fluePersistence:settleSubmission");
const settleSubmission = makeFunctionReference<"mutation", SettleArgs, boolean>(
"fluePersistence:settleSubmission"
);
const insertAttemptMarker = makeFunctionReference<
"mutation",
@@ -349,10 +417,16 @@ type AppendBatchArgs = TokenArgs & {
readonly producerEpoch: number;
readonly incarnation: string;
readonly producerSequence: number;
readonly submission?: { readonly submissionId: string; readonly attemptId: string };
readonly submission?: {
readonly submissionId: string;
readonly attemptId: string;
};
readonly recordsJson: string;
};
interface AppendResult { readonly offset: number; readonly appended: boolean }
interface AppendResult {
readonly offset: number;
readonly appended: boolean;
}
const appendConversationBatch = makeFunctionReference<
"mutation",
AppendBatchArgs,
@@ -438,7 +512,10 @@ const closeEventStream = makeFunctionReference<
void
>("fluePersistence:closeEventStream");
interface EventStreamMetaRow { readonly nextOffset: number; readonly closed: boolean }
interface EventStreamMetaRow {
readonly nextOffset: number;
readonly closed: boolean;
}
const getEventStreamMeta = makeFunctionReference<
"query",
TokenArgs & { readonly path: string },
@@ -454,7 +531,7 @@ type CreateRunArgs = TokenArgs & {
readonly traceCarrierJson?: string;
};
const createRun = makeFunctionReference<"mutation", CreateRunArgs, void>(
"fluePersistence:createRun",
"fluePersistence:createRun"
);
type EndRunArgs = TokenArgs & {
@@ -466,7 +543,7 @@ type EndRunArgs = TokenArgs & {
readonly errorJson?: string;
};
const endRun = makeFunctionReference<"mutation", EndRunArgs, void>(
"fluePersistence:endRun",
"fluePersistence:endRun"
);
const getRun = makeFunctionReference<
@@ -492,7 +569,7 @@ interface ListRunsResult {
readonly hasMore: boolean;
}
const listRuns = makeFunctionReference<"query", ListRunsArgs, ListRunsResult>(
"fluePersistence:listRuns",
"fluePersistence:listRuns"
);
// Attachments
@@ -534,7 +611,9 @@ const deleteAttachmentsForInstance = makeFunctionReference<
// ---------------------------------------------------------------------------
const safeJsonParse = <T>(text: string | null | undefined): T | undefined => {
if (text === null || text === undefined || text === "") {return undefined;}
if (text === null || text === undefined || text === "") {
return undefined;
}
return JSON.parse(text) as T;
};
@@ -544,7 +623,7 @@ const parseAcceptedAt = (value: string, label: string): number => {
const ms = Date.parse(value);
if (!Number.isFinite(ms)) {
throw new TypeError(
`[flue] ${label} produced non-finite acceptedAt: ${value}`,
`[flue] ${label} produced non-finite acceptedAt: ${value}`
);
}
return ms;
@@ -610,7 +689,7 @@ const hydrateSubmission = (row: SubmissionRow): AgentSubmission => {
const input = safeJsonParse<DispatchAgentSubmissionInput>(row.inputJson);
if (input === undefined) {
throw new Error(
`[flue] persisted dispatch submission ${row.submissionId} has empty inputJson`,
`[flue] persisted dispatch submission ${row.submissionId} has empty inputJson`
);
}
const inputWithCarrier =
@@ -621,7 +700,7 @@ const hydrateSubmission = (row: SubmissionRow): AgentSubmission => {
const stripped = safeJsonParse<DirectAgentSubmissionInput>(row.inputJson);
if (stripped === undefined) {
throw new Error(
`[flue] persisted direct submission ${row.submissionId} has empty inputJson`,
`[flue] persisted direct submission ${row.submissionId} has empty inputJson`
);
}
const chunks = safeJsonParse<PersistedChunkRow[]>(row.chunksJson) ?? [];
@@ -632,12 +711,12 @@ const hydrateSubmission = (row: SubmissionRow): AgentSubmission => {
};
const hydrateObligation = (
row: SettlementObligationRow,
row: SettlementObligationRow
): SubmissionSettlementObligation => {
const record = safeJsonParse<SubmissionSettledRecord>(row.recordJson);
if (record === undefined) {
throw new Error(
`[flue] persisted settlement obligation ${row.submissionId} has empty recordJson`,
`[flue] persisted settlement obligation ${row.submissionId} has empty recordJson`
);
}
return {
@@ -692,14 +771,14 @@ class ConvexClient {
query<F extends FunctionReference<"query">>(
ref: F,
args: FunctionArgs<F>,
args: FunctionArgs<F>
): Promise<FunctionReturnType<F>> {
return this.client.query(ref, args);
}
mutation<F extends FunctionReference<"mutation">>(
ref: F,
args: FunctionArgs<F>,
args: FunctionArgs<F>
): Promise<FunctionReturnType<F>> {
return this.client.mutation(ref, args);
}
@@ -757,7 +836,7 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
async replaceSubmissionAttempt(
attempt: SubmissionAttemptRef,
nextAttemptId: string,
lease?: { ownerId: string; leaseExpiresAt: number },
lease?: { ownerId: string; leaseExpiresAt: number }
): Promise<AgentSubmission | null> {
const row = await this.convex.mutation(replaceSubmissionAttempt, {
attemptId: attempt.attemptId,
@@ -808,10 +887,11 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
}
async admitDirect(
input: DirectAgentSubmissionInput,
input: DirectAgentSubmissionInput
): Promise<AgentSubmission> {
const sessionKey = createSessionStorageKey(input.id, "default", "default");
const extracted = prepareDirectSubmission(input);
const admission = currentTurnAdmission();
const res = await this.convex.mutation(admitSubmission, {
input: {
acceptedAt: parseAcceptedAt(input.acceptedAt, "admitDirect"),
@@ -825,6 +905,12 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
: { traceCarrierJson: jsonStringify(input.traceCarrier) }),
},
token: TOKEN(),
...(admission === undefined
? {}
: {
clientRequestId: admission.clientRequestId,
turnId: admission.turnId,
}),
});
if (res.kind === "submission" && res.submission !== undefined) {
return hydrateSubmission(res.submission);
@@ -833,12 +919,12 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
// submission; if the backend signals a non-submission result we cannot
// satisfy the `AgentSubmission` return type.
throw new Error(
`[flue] admitDirect for ${input.submissionId} did not return a submission`,
`[flue] admitDirect for ${input.submissionId} did not return a submission`
);
}
async markSubmissionCanonicalReady(
submissionId: string,
submissionId: string
): Promise<AgentSubmission | null> {
const row = await this.convex.mutation(markSubmissionCanonicalReady, {
submissionId,
@@ -848,7 +934,7 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
}
async claimSubmission(
claim: SubmissionClaimRef,
claim: SubmissionClaimRef
): Promise<AgentSubmission | null> {
const row = await this.convex.mutation(claimSubmission, {
attemptId: claim.attemptId,
@@ -862,7 +948,7 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
markSubmissionInputApplied(
attempt: SubmissionAttemptRef,
durability?: SubmissionDurability,
durability?: SubmissionDurability
): Promise<boolean> {
return this.convex.mutation(markSubmissionInputApplied, {
attemptId: attempt.attemptId,
@@ -872,9 +958,7 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
});
}
requestSubmissionRecovery(
attempt: SubmissionAttemptRef,
): Promise<boolean> {
requestSubmissionRecovery(attempt: SubmissionAttemptRef): Promise<boolean> {
return this.convex.mutation(requestSubmissionRecovery, {
attemptId: attempt.attemptId,
submissionId: attempt.submissionId,
@@ -890,7 +974,7 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
}
requeueSubmissionBeforeInputApplied(
attempt: SubmissionAttemptRef,
attempt: SubmissionAttemptRef
): Promise<boolean> {
return this.convex.mutation(requeueSubmissionBeforeInputApplied, {
attemptId: attempt.attemptId,
@@ -901,7 +985,7 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
async reserveSubmissionSettlement(
attempt: SubmissionAttemptRef,
settlement: { recordId: string; record: SubmissionSettledRecord },
settlement: { recordId: string; record: SubmissionSettledRecord }
): Promise<SubmissionSettlementObligation | null> {
const row = await this.convex.mutation(reserveSubmissionSettlement, {
attemptId: attempt.attemptId,
@@ -915,7 +999,7 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
finalizeSubmissionSettlement(
attempt: SubmissionAttemptRef,
recordId: string,
recordId: string
): Promise<boolean> {
return this.convex.mutation(finalizeSubmissionSettlement, {
attemptId: attempt.attemptId,
@@ -936,7 +1020,7 @@ class ConvexAgentSubmissionStore implements AgentSubmissionStore {
failSubmission(
attempt: SubmissionAttemptRef,
error: unknown,
error: unknown
): Promise<boolean> {
return this.convex.mutation(settleSubmission, {
attemptId: attempt.attemptId,
@@ -1001,7 +1085,7 @@ class ConvexConversationStreamStore implements ConversationStreamStore {
async createStream(
path: string,
identity: ConversationStreamIdentity,
identity: ConversationStreamIdentity
): Promise<void> {
await this.convex.mutation(createConversationStream, {
identity,
@@ -1012,7 +1096,7 @@ class ConvexConversationStreamStore implements ConversationStreamStore {
acquireProducer(
path: string,
producerId: string,
producerId: string
): Promise<ConversationProducerClaim> {
return this.convex.mutation(acquireConversationProducer, {
path,
@@ -1038,7 +1122,9 @@ class ConvexConversationStreamStore implements ConversationStreamStore {
producerSequence: input.producerSequence,
recordsJson: jsonStringify(input.records),
token: TOKEN(),
...(input.submission === undefined ? {} : { submission: input.submission }),
...(input.submission === undefined
? {}
: { submission: input.submission }),
});
if (result.appended) {
this.listeners.notify(input.path);
@@ -1048,12 +1134,12 @@ class ConvexConversationStreamStore implements ConversationStreamStore {
async read(
path: string,
options?: { offset?: string; limit?: number },
options?: { offset?: string; limit?: number }
): Promise<ConversationStreamReadResult> {
const limit = clampLimit(
options?.limit,
DEFAULT_READ_LIMIT,
MAX_READ_LIMIT,
MAX_READ_LIMIT
);
const result = await this.convex.query(readConversationBatches, {
afterOffset: afterOffset(options?.offset),
@@ -1077,7 +1163,9 @@ class ConvexConversationStreamStore implements ConversationStreamStore {
path,
token: TOKEN(),
});
if (row === null) {return null;}
if (row === null) {
return null;
}
return {
identity: row.identity,
incarnation: row.incarnation,
@@ -1131,7 +1219,7 @@ class ConvexEventStreamStore implements EventStreamStore {
async appendEventOnce(
path: string,
key: string,
event: unknown,
event: unknown
): Promise<string> {
const result = await this.convex.mutation(appendEventOnce, {
dataJson: jsonStringify(event),
@@ -1147,7 +1235,7 @@ class ConvexEventStreamStore implements EventStreamStore {
async readEvents(
path: string,
opts?: { offset?: string; limit?: number },
opts?: { offset?: string; limit?: number }
): Promise<EventStreamReadResult> {
const limit = clampLimit(opts?.limit, DEFAULT_READ_LIMIT, MAX_READ_LIMIT);
const result = await this.convex.query(readEventsFn, {
@@ -1179,7 +1267,9 @@ class ConvexEventStreamStore implements EventStreamStore {
path,
token: TOKEN(),
});
if (row === null) {return null;}
if (row === null) {
return null;
}
return {
closed: row.closed,
nextOffset: formatOffset(row.nextOffset),
@@ -1233,7 +1323,7 @@ class ConvexRunStore implements RunStore {
}
lookupRun(
runId: string,
runId: string
): Promise<{ runId: string; workflowName: string } | null> {
return this.convex.query(lookupRun, { runId, token: TOKEN() });
}
@@ -1281,7 +1371,7 @@ class ConvexAttachmentStore implements AttachmentStore {
attachment: attachmentRefToWire(input.attachment),
bytes: bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer,
conversationId: input.conversationId,
streamPath: input.streamPath,
@@ -1302,7 +1392,9 @@ class ConvexAttachmentStore implements AttachmentStore {
streamPath: input.streamPath,
token: TOKEN(),
});
if (row === null) {return null;}
if (row === null) {
return null;
}
const attachment = wireRefToAttachmentRef(row.attachment);
const bytes = new Uint8Array(row.bytes);
// Verify integrity before handing bytes back to the runtime; throws
@@ -1345,7 +1437,7 @@ class ConvexPersistenceAdapterImpl implements PersistenceAdapter {
connect(): PersistenceStores {
if (!this.migrated) {
throw new Error(
"[flue] ConvexPersistenceAdapter.connect() called before migrate() completed",
"[flue] ConvexPersistenceAdapter.connect() called before migrate() completed"
);
}
const submissions = new ConvexAgentSubmissionStore(this.convex);

View File

@@ -0,0 +1,57 @@
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, 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.
## Issue resolution with create_pr_for_issue
When a user wants an open Gitea issue implemented and turned into a pull request, call `create_pr_for_issue` with the issue number or URL.
- The tool accepts the request immediately and runs the AgentOS implementation, branch push, and PR creation in the background.
- Tell the user the issue was accepted for background implementation; do not claim the PR exists yet and do not wait for completion in the chat turn.
- Do not retry the tool in a tight loop. Background success and failure are written to the agent server logs with the `[create_pr_for_issue]` prefix.
- Do not run repository mutations yourself; the tool owns the implementation and PR pipeline.
## Repository access
Your shell runs inside the `puter/zopu-code` checkout at `/Users/puter/Workspace/zopu/code`, so you can answer questions about this repository and manage its Gitea issues.
- Explore the repository with read-only Git: `git log`, `git status`, `git diff`, `git branch`, `git show`, and reading files. Use this to ground answers about the codebase.
- List open issues with `tea issues list` (defaults to open issues).
- Create a Gitea issue in this repo with `tea issues create --title "<title>" [--description "<details>"]`.
- Never mutate repository state: do not run `git push`, `git merge`, `git rebase`, `git reset`, `git commit`, `git add`, force operations, or any history rewrite.
- Never close, reopen, or edit existing issues (`tea issues close|reopen|edit`); only list and create.
- Never expose credentials, tokens, or the contents of Tea/Git config files.

View File

@@ -1,25 +1,44 @@
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 {
codexSessionEnv,
makeCodexAgentOsConfig,
} from "../../../primitives/src/agent-os";
import { decodeWorkAttemptExecutionInput } from "../../../primitives/src/execution-runtime";
makePiAgentOsConfig,
makePiHomeFiles,
decodeWorkAttemptExecutionInput,
WorkAttemptExecutionError,
piSessionEnv,
} from "@code/primitives";
import type {
ExecutionEvent,
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 MAX_ACP_COMPLETED_MESSAGE_BYTES = 128 * 1024 * 1024;
const piConfig = makePiAgentOsConfig();
const workspace = agentOS<undefined, { token: string }>({
limits: {
acp: {
maxCompletedMessageBytes: MAX_ACP_COMPLETED_MESSAGE_BYTES,
},
},
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 } });
const shellQuote = (value: string): string =>
`'${value.replaceAll("'", `'"'"'`)}'`;
const event = (
sequence: number,
kind: ExecutionEvent["kind"],
@@ -33,181 +52,213 @@ const event = (
sequence,
});
const requireSuccess = (
result: { exitCode: number; stderr: string; stdout: string },
operation: string
) => {
if (result.exitCode !== 0) {
throw new Error(`${operation} failed: ${result.stderr || result.stdout}`);
}
return result.stdout.trim();
};
const executionError = (
message: string,
reason: WorkAttemptExecutionError["reason"],
retryable: boolean
) => new WorkAttemptExecutionError({ message, reason, retryable });
const gitAuthEnv = (username: string, credential: string) => ({
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "http.extraHeader",
GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`${username}:${credential}`).toString("base64")}`,
});
const classifyRuntimeFailure = (cause: unknown): WorkAttemptExecutionError => {
if (cause instanceof WorkAttemptExecutionError) {
return cause;
}
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 (
rawInput: unknown
): Promise<WorkAttemptExecutionResult> => {
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,
}
try {
const input = await Effect.runPromise(
decodeWorkAttemptExecutionInput(rawInput)
);
requireSuccess(clone, "Repository clone");
}
const checkout = await vm.exec(
`git fetch origin ${shellQuote(input.baseBranch)} && git checkout -B ${shellQuote(`zopu/${input.runId}`)} ${shellQuote(`origin/${input.baseBranch}`)}`,
{ cwd: "/workspace/repository", env: authEnv }
);
requireSuccess(checkout, "Repository checkout");
const baseRevision = requireSuccess(
await vm.execArgv("git", ["rev-parse", "HEAD"], {
cwd: "/workspace/repository",
}),
"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",
const env = parseAgentEnv(process.env);
const endpoint = env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT;
const client = createClient<typeof runtimeRegistry>({
disableMetadataLookup: true,
encoding: "cbor",
...(endpoint ? { endpoint } : {}),
});
const vm = client.workspace.getOrCreate([input.workspaceKey], {
params: { token: env.RIVET_WORKSPACE_TOKEN },
});
const events: ExecutionEvent[] = [
event(0, "runtime.preparing", "AgentOS workspace selected", {
workspaceKey: input.workspaceKey,
}),
"Candidate tree creation"
];
const hostRepository = new HostRepositoryWorkspace();
const prepared = await hostRepository.prepare({
attemptId: input.attemptId,
cloneUrl: input.repositoryUrl,
credential: input.auth.credential,
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,
}),
provider: input.auth.provider,
username: input.auth.username,
});
events.push(
event(
1,
"runtime.preparing",
prepared.created
? "Repository cloned on the execution host"
: "Repository re-cloned"
)
);
candidateRevision = requireSuccess(
await vm.exec(
`printf %s ${shellQuote(`Zopu candidate for ${input.attemptId}`)} | git commit-tree ${shellQuote(tree)} -p ${shellQuote(baseRevision)}`,
const mounts = [
{
hostPath: prepared.checkoutPath,
path: "/workspace/repository",
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.checkoutPath}/.git`],
additionalInstructions:
"Work only inside /workspace/repository. This is an isolated checkout of the project repository. Read AGENTS.md and the relevant product specifications before changing code. Never reveal credentials, 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",
env: {
GIT_AUTHOR_EMAIL: "agent@zopu.dev",
GIT_AUTHOR_NAME: "Zopu Agent",
GIT_COMMITTER_EMAIL: "agent@zopu.dev",
GIT_COMMITTER_NAME: "Zopu Agent",
},
candidateRevision,
}
),
"Candidate revision creation"
);
requireSuccess(
await vm.execArgv("git", ["reset"], { cwd: "/workspace/repository" }),
"Candidate index reset"
event(6, "runtime.completed", "AgentOS execution completed")
);
return {
baseRevision,
candidateRevision,
changedFiles,
diff,
environmentId: input.workspaceKey,
events,
summary:
changedFiles.length > 0
? `Pi changed ${changedFiles.length} file(s)`
: "Pi completed without repository changes",
};
} catch (error) {
throw classifyRuntimeFailure(error);
}
events.push(
event(
5,
"repository.changed",
`${changedFiles.length} changed file(s) collected`,
{
candidateRevision,
}
),
event(6, "runtime.completed", "AgentOS execution completed")
);
return {
baseRevision,
candidateRevision,
changedFiles,
diff,
environmentId: input.workspaceKey,
events,
summary:
changedFiles.length > 0
? `Codex changed ${changedFiles.length} file(s)`
: "Codex completed without repository changes",
};
};
export const cancelAgentOsAttempt = async (workspaceKey: string) => {
export const cancelAgentOsAttempt = async (
workspaceKey: string,
attemptId: string
) => {
const env = parseAgentEnv(process.env);
const endpoint = env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT;
const client = createClient<typeof runtimeRegistry>({
endpoint: env.RIVET_PUBLIC_ENDPOINT ?? env.RIVET_ENDPOINT,
disableMetadataLookup: true,
encoding: "cbor",
...(endpoint ? { 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,286 @@
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;
cloneUrl: string;
/** Provider credential for the initial clone, stripped after cloning. */
credential?: string;
/** Provider type: "github" or "gitea". Determines the askpass username. */
provider?: string;
/** Account username for Gitea provider authentication. */
username?: 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 #installDependencies: boolean;
constructor(
root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces",
installDependencies = true
) {
this.#root = root;
this.#installDependencies = installDependencies;
}
async prepare(input: PrepareRepositoryInput): Promise<PreparedRepository> {
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 created = !(await pathExists(path.join(checkoutPath, ".git")));
await mkdir(workspacePath, { recursive: true });
await rm(checkoutPath, { force: true, recursive: true });
// Clone the user's repository. When a credential is provided, use a
// temporary GIT_ASKPASS helper script so the token never appears in the
// clone URL, git config, or process arguments visible in process listings.
// The askpass script reads the credential from an env var that is only
// set for this clone command and cleared afterward.
const cloneEnv: Record<string, string> = {};
if (input.credential) {
const askpassPath = path.join(workspacePath, ".git-askpass");
// GitHub uses x-access-token as the username for OAuth tokens;
// Gitea uses the account's actual username.
const gitUsername =
input.provider === "gitea"
? (input.username ?? "git")
: "x-access-token";
// eslint-disable-next-line no-template-curly-in-string -- shell variable, not JS template
const tokenRef = "${ZOPU_GIT_TOKEN}";
await writeFile(
askpassPath,
`#!/bin/sh\ncase "$1" in\nUsername*) echo "${gitUsername}";;\nPassword*) echo "${tokenRef}";;\nesac\n`
);
await chmod(askpassPath, 0o700);
cloneEnv.GIT_ASKPASS = askpassPath;
cloneEnv.ZOPU_GIT_TOKEN = input.credential;
}
try {
requireSuccess(
await runProcess(
"git",
["clone", "--no-tags", input.cloneUrl, checkoutPath],
workspacePath,
cloneEnv
),
"Repository clone"
);
} finally {
// The credential env var is never written to disk or git config.
// The askpass script is cleaned up with the workspace.
cloneEnv.ZOPU_GIT_TOKEN = "";
delete process.env.ZOPU_GIT_TOKEN;
}
const bunExecutable =
process.env.BUN_EXECUTABLE ??
execFileSync("which", ["bun"], { encoding: "utf-8" }).trim();
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: checkoutPath,
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

@@ -0,0 +1,572 @@
import { spawnSync } from "node:child_process";
import type { SpawnSyncOptions } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import path from "node:path";
import { parseAgentEnv } from "@code/env/agent";
import { defineTool } from "@flue/runtime";
import type { JsonValue } from "@flue/runtime";
import * as v from "valibot";
import { executeAgentOsAttempt } from "../runtime/agent-os";
/**
* Direct Zopu chat tool that converts a Gitea issue into an AgentOS-implemented
* pull request. It owns the entire issue → harness → push → PR pipeline so the
* chat agent never needs (and must not use) shell mutation for this workflow.
*/
// The host checkout the chat agent explores. Tea and Git resolve this repo's
// Gitea connection; all Tea/Git commands run with this fixed cwd.
const TRUSTED_REPO_PATH = "/Users/puter/Workspace/zopu/code";
const REPO_SLUG = "puter/zopu-code";
// Bounded output so a verbose harness/CLI can never blow up a tool result.
const MAX_OUTPUT = 4000;
const MAX_ISSUE_BODY = 8000;
const MAX_CHANGED_FILES = 200;
const COMMAND_TIMEOUT_MS = 60_000;
const PUSH_TIMEOUT_MS = 120_000;
interface CommandResult {
readonly status: number | null;
readonly stdout: string;
readonly stderr: string;
}
interface IssueDetails {
readonly body: string;
readonly title: string;
readonly url: string;
}
interface ToolFailure {
readonly [key: string]: JsonValue;
readonly error: string;
readonly issue: string;
readonly stage: string;
}
interface ToolSuccess {
readonly [key: string]: JsonValue;
readonly baseBranch: string;
readonly changedFileCount: number;
readonly changedFiles: string[];
readonly headBranch: string;
readonly issue: string;
readonly issueNumber: number;
readonly ok: true;
readonly pullRequestUrl: string;
readonly summary: string;
}
interface ToolAccepted {
readonly [key: string]: JsonValue;
readonly accepted: true;
readonly backgroundId: string;
readonly issue: string;
readonly issueNumber: number;
readonly message: string;
readonly ok: true;
}
const cap = (value: string, limit = MAX_OUTPUT): string =>
value.length <= limit ? value : `${value.slice(0, limit)}…[truncated]`;
// Stable prefix for every background pipeline log line so operators can grep
// for issue→PR outcomes independently of chat traffic.
const LOG_PREFIX = "[create_pr_for_issue]";
/** Writes one stable, prefixed server log line for a background outcome. */
const logBackground = (level: "info" | "error", message: string): void => {
const line = `${LOG_PREFIX} ${message}`;
if (level === "error") {
console.error(line);
} else {
console.log(line);
}
};
/** Logs a structured terminal pipeline result under the stable prefix. */
const logPipelineResult = (
backgroundId: string,
result: ToolFailure | ToolSuccess
): void => {
if ("ok" in result && result.ok === true) {
logBackground(
"info",
`background=${backgroundId} completed issue=#${result.issueNumber} stage=pr url=${result.pullRequestUrl} changedFiles=${result.changedFileCount}`
);
return;
}
const detail =
"reason" in result && typeof result.reason === "string"
? ` reason=${result.reason}`
: "";
logBackground(
"error",
`background=${backgroundId} failed issue=${result.issue} issueNumber=${result.issueNumber} stage=${result.stage}: ${result.error}${detail}`
);
};
/** Last-resort logger for an unexpected throw outside the pipeline's catch. */
const logPipelineError = (backgroundId: string, error: unknown): void => {
const message = error instanceof Error ? error.message : String(error);
logBackground("error", `background=${backgroundId} unexpected: ${message}`);
};
/**
* Runs one command as a direct child process. Arguments are passed to the
* child verbatim — never through a shell — so issue titles, bodies, and
* revisions cannot inject shell metacharacters.
*/
const run = (
command: string,
args: readonly string[],
options: {
cwd?: string;
env?: Record<string, string>;
timeoutMs?: number;
} = {}
): CommandResult => {
const spawnOptions: SpawnSyncOptions = {
cwd: options.cwd ?? TRUSTED_REPO_PATH,
env: { ...process.env, ...options.env },
maxBuffer: 1024 * 1024,
timeout: options.timeoutMs ?? COMMAND_TIMEOUT_MS,
};
const result = spawnSync(command, args, spawnOptions);
return {
status: result.status,
stderr: cap((result.stderr ?? "").toString()),
stdout: cap((result.stdout ?? "").toString()),
};
};
/** Extracts the issue number from an issue number, "#23", or a full issue URL. */
const parseIssueNumber = (input: string): number | null => {
// Prefer a /issues/<n> URL path segment so anchors like #comment-45 do not
// hijack the number.
const pathMatch = input.match(/\/issues\/(?<issueNumber>\d+)(?:[^/\d]|$)/iu);
if (pathMatch?.groups?.issueNumber) {
return Math.trunc(Number(pathMatch.groups.issueNumber));
}
const hashMatch = input.match(/#(?<issueNumber>\d+)\b/u);
if (hashMatch?.groups?.issueNumber) {
return Math.trunc(Number(hashMatch.groups.issueNumber));
}
const standalone = input.trim().match(/^\d+$/u);
if (standalone) {
return Math.trunc(Number(standalone[0]));
}
// Last resort: the trailing integer anywhere in the string.
const value = Math.trunc(Number(input.match(/\d+/gu)?.at(-1)));
if (Number.isFinite(value) && value > 0) {
return value;
}
return null;
};
const readIssue = (issueNumber: number): IssueDetails | ToolFailure => {
const result = run("tea", [
"issues",
String(issueNumber),
"--fields",
"index,title,body,url,state",
"--output",
"json",
]);
if (result.status !== 0) {
return {
error: `tea could not read issue #${issueNumber}.`,
issue: String(issueNumber),
issueNumber,
stage: "read-issue",
stderr: result.stderr,
};
}
let data: {
body?: string;
state?: string;
title?: string;
url?: string;
};
try {
data = JSON.parse(result.stdout) as typeof data;
} catch (error) {
return {
error: `Could not parse the issue payload from tea: ${
error instanceof Error ? error.message : String(error)
}`,
issue: String(issueNumber),
issueNumber,
stage: "read-issue",
stderr: result.stdout,
};
}
const title = (data.title ?? "").trim();
if (title.length === 0) {
return {
error: `Issue #${issueNumber} has no title.`,
issue: String(issueNumber),
issueNumber,
stage: "read-issue",
};
}
return {
body: (data.body ?? "").trim(),
title,
url: (data.url ?? "").trim(),
};
};
const buildPrompt = (issueNumber: number, issue: IssueDetails): string => {
const body =
issue.body.length > MAX_ISSUE_BODY
? `${issue.body.slice(0, MAX_ISSUE_BODY)}…[issue body truncated]`
: issue.body;
return [
`Resolve Gitea issue #${issueNumber} in this repository and ship a complete implementation.`,
"",
`## Issue #${issueNumber}: ${issue.title}`,
"",
body || "_(The issue provided no additional description.)_",
"",
"## Instructions",
"- Read AGENTS.md and the relevant product/architecture specifications before changing code.",
"- Implement exactly the change described by the issue with focused, correct edits.",
"- Reuse existing conventions; do not introduce unrelated scope.",
"- Run focused verification for your change before finishing.",
"- Do NOT push, open a pull request, rewrite history, or modify the read-only base checkout.",
" The orchestrator collects your working-tree changes, pushes a candidate branch, and opens the pull request after you finish.",
].join("\n");
};
/** Mirrors HostRepositoryWorkspace's worktree layout to locate the harness checkout. */
const harnessCheckoutPath = (attemptId: string): string => {
const root = process.env.AGENT_WORKSPACE_ROOT ?? "/var/lib/zopu/workspaces";
const identity = createHash("sha256")
.update(attemptId)
.digest("hex")
.slice(0, 24);
return path.join(root, identity, "repository");
};
const detectBaseBranch = (): string => {
const result = run("git", ["rev-parse", "--abbrev-ref", "origin/HEAD"], {
cwd: TRUSTED_REPO_PATH,
});
if (result.status === 0) {
const ref = result.stdout.trim();
const branch = ref.startsWith("origin/")
? ref.slice("origin/".length)
: ref;
if (branch.length > 0) {
return branch;
}
}
return "master";
};
const pushCandidate = (
checkoutPath: string,
candidateRevision: string,
pushBranch: string
): true | ToolFailure => {
const result = run(
"git",
[
"-C",
checkoutPath,
"push",
"origin",
`${candidateRevision}:refs/heads/${pushBranch}`,
],
{ cwd: checkoutPath, timeoutMs: PUSH_TIMEOUT_MS }
);
if (result.status !== 0) {
return {
error: `Could not push candidate branch "${pushBranch}" from the harness worktree.`,
issue: "",
stage: "push",
stderr: result.stderr,
};
}
return true;
};
const findPullUrl = (head: string): string => {
const result = run("tea", [
"pulls",
"list",
"--state",
"open",
"--fields",
"index,title,head,url",
"--output",
"json",
]);
if (result.status !== 0) {
return "";
}
try {
const pulls = JSON.parse(result.stdout) as {
head?: string;
url?: string;
}[];
const match = pulls.find(
(pull) => pull.head === head || (pull.head ?? "").endsWith(`:${head}`)
);
return (match?.url ?? "").trim();
} catch {
return "";
}
};
const createPullRequest = (
issueNumber: number,
title: string,
head: string,
base: string
): ToolFailure | { url: string } => {
const prTitle = `Fix #${issueNumber}: ${title}`;
const result = run("tea", [
"pulls",
"create",
"--head",
head,
"--base",
base,
"--title",
prTitle,
"--description",
`Fixes #${issueNumber}`,
]);
if (result.status !== 0) {
return {
error: `tea could not create the pull request for branch "${head}".`,
issue: "",
stage: "create-pr",
stderr: result.stderr,
stdout: result.stdout,
};
}
const combined = `${result.stdout}\n${result.stderr}`;
const urlMatch = combined.match(/https?:\/\/[^\s)]+\/pulls\/\d+/u);
if (urlMatch) {
return { url: urlMatch[0] };
}
// Fall back to listing open pulls and matching the head branch.
const fallback = findPullUrl(head);
if (fallback.length > 0) {
return { url: fallback };
}
return {
error:
"The pull request was created, but its URL could not be captured from tea. Inspect open pulls in Gitea.",
issue: "",
stage: "create-pr",
stdout: result.stdout,
};
};
/**
* Runs the full issue → AgentOS → push → PR pipeline and returns a structured
* terminal result. Used both by the background scheduler (chat path) and any
* direct/internal caller that wants the structured outcome. Never rejects: every
* failure is caught and returned as a {@link ToolFailure}.
*/
const runIssuePrPipeline = async (params: {
readonly giteaUrl: string;
readonly giteaToken: string | undefined;
readonly issue: string;
readonly issueNumber: number;
readonly identifiers: {
readonly attemptId: string;
readonly headBranch: string;
readonly runId: string;
readonly workspaceKey: string;
};
}): Promise<ToolFailure | ToolSuccess> => {
const { giteaUrl, giteaToken, issue, issueNumber, identifiers } = params;
try {
const issueOrFailure = readIssue(issueNumber);
if ("error" in issueOrFailure) {
return issueOrFailure;
}
const issueDetails = issueOrFailure;
const baseBranch = detectBaseBranch();
const prompt = buildPrompt(issueNumber, issueDetails);
// The fixed-checkout harness path ignores `auth`/`repositoryUrl` for
// cloning (it always uses the mounted Zopu source worktree); they are
// still supplied as valid schema values. GITEA_TOKEN is optional.
const result = await executeAgentOsAttempt({
attemptId: identifiers.attemptId,
auth: {
credential: giteaToken ?? "unused-by-fixed-checkout",
provider: "gitea",
serverUrl: giteaUrl,
},
baseBranch,
prompt,
repositoryUrl: `${giteaUrl}/${REPO_SLUG}`,
runId: identifiers.runId,
workId: `issue-${issueNumber}`,
workspaceKey: identifiers.workspaceKey,
});
const changedFiles = result.changedFiles.slice(0, MAX_CHANGED_FILES);
if (
result.changedFiles.length === 0 ||
result.candidateRevision === result.baseRevision
) {
return {
error:
"The AgentOS session completed without producing any repository changes, so there is nothing to turn into a pull request.",
issue,
issueNumber,
stage: "harness",
summary: result.summary,
};
}
const checkoutPath = harnessCheckoutPath(identifiers.attemptId);
const pushed = pushCandidate(
checkoutPath,
result.candidateRevision,
identifiers.headBranch
);
if (pushed !== true) {
return {
...pushed,
attemptId: identifiers.attemptId,
baseRevision: result.baseRevision,
candidateRevision: result.candidateRevision,
headBranch: identifiers.headBranch,
issue,
issueNumber,
workspaceKey: identifiers.workspaceKey,
};
}
const pr = createPullRequest(
issueNumber,
issueDetails.title,
identifiers.headBranch,
baseBranch
);
if ("error" in pr) {
return {
...pr,
attemptId: identifiers.attemptId,
baseRevision: result.baseRevision,
candidateRevision: result.candidateRevision,
headBranch: identifiers.headBranch,
issue,
issueNumber,
workspaceKey: identifiers.workspaceKey,
};
}
return {
baseBranch,
changedFileCount: result.changedFiles.length,
changedFiles,
headBranch: identifiers.headBranch,
issue,
issueNumber,
ok: true,
pullRequestUrl: pr.url,
summary: result.summary,
};
} catch (error) {
const message =
error instanceof Error ? error.message : "Unexpected failure";
const reason =
error !== null &&
typeof error === "object" &&
"reason" in error &&
typeof (error as { reason: unknown }).reason === "string"
? (error as { reason: string }).reason
: undefined;
return {
error: message,
issue,
issueNumber,
...(reason ? { reason } : {}),
stage: "harness",
};
}
};
export const createCreatePrForIssueTool = (
runtimeEnv: Record<string, string | undefined>
) => {
const agentEnv = parseAgentEnv(runtimeEnv);
const giteaUrl = agentEnv.GITEA_URL;
return defineTool({
description:
'Validate a Gitea issue number or URL immediately, then return an accepted acknowledgement right away while an AgentOS coding session implements the issue, pushes a candidate branch, and opens a PR titled "Fix #N: <title>" with body "Fixes #N" in the background. Pass a Gitea issue number or full issue URL. The created PR URL and any failure are logged to the server log with the "[create_pr_for_issue]" prefix rather than returned to chat; malformed input returns a parse failure immediately.',
input: v.object({
issue: v.pipe(
v.string(),
v.description(
"A Gitea issue number like 23, or a full issue URL such as https://git.openputer.com/puter/zopu-code/issues/23."
)
),
}),
name: "create_pr_for_issue",
run({ input }): ToolFailure | ToolAccepted {
const issueRef = input.issue.trim();
const issueNumber = parseIssueNumber(issueRef);
if (issueNumber === null) {
return {
error: `Could not parse an issue number from "${input.issue}". Provide a Gitea issue number or URL.`,
issue: input.issue,
stage: "parse",
};
}
// Background identifiers are generated up front so the accepted
// response can reference them and the pipeline can reuse them.
const backgroundId = randomUUID();
const attemptId = `create-pr-${issueNumber}-${randomUUID()}`;
const workspaceKey = `issue-${issueNumber}-pr-${randomUUID()}`;
const runId = `issue-pr-${randomUUID()}`;
const shortId = randomUUID().slice(0, 8);
const headBranch = `zopu/issue-${issueNumber}-${shortId}`;
// Schedule the full issue → AgentOS → push → PR pipeline on the next
// microtask boundary so this turn returns immediately. The pipeline
// catches its own failures and logs a stable prefixed line; the IIFE
// never rejects, so the originating chat turn cannot fail.
queueMicrotask(() => {
void (async () => {
try {
const result = await runIssuePrPipeline({
giteaToken: runtimeEnv.GITEA_TOKEN,
giteaUrl,
identifiers: { attemptId, headBranch, runId, workspaceKey },
issue: input.issue,
issueNumber,
});
logPipelineResult(backgroundId, result);
} catch (error) {
logPipelineError(backgroundId, error);
}
})();
});
return {
accepted: true,
backgroundId,
issue: input.issue,
issueNumber,
message: `Issue #${issueNumber} accepted. An AgentOS session will implement it, push branch "${headBranch}", and open a pull request in the background. The created PR URL and any failure are logged with the "${LOG_PREFIX}" prefix.`,
ok: true,
};
},
});
};

View File

@@ -1,6 +1,14 @@
{
"extends": "@code/config/tsconfig.base.json",
"compilerOptions": { "types": ["bun"] },
"include": ["src/**/*.ts"],
"exclude": ["dist"]
}
"compilerOptions": {
"types": [
"bun"
]
},
"include": [
"src/**/*.ts"
],
"exclude": [
"dist"
]
}

View File

@@ -1,8 +1,7 @@
import { env } from "@code/env/web";
import { convexClient } from "@convex-dev/better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: env.VITE_AUTH_URL,
baseURL: typeof window === "undefined" ? undefined : window.location.origin,
plugins: [convexClient()],
});

View File

@@ -11,6 +11,7 @@
import type * as auth from "../auth.js";
import type * as authz from "../authz.js";
import type * as conversationMessages from "../conversationMessages.js";
import type * as conversationProjections from "../conversationProjections.js";
import type * as crons from "../crons.js";
import type * as fluePersistence from "../fluePersistence.js";
import type * as gitConnectionData from "../gitConnectionData.js";
@@ -39,6 +40,7 @@ declare const fullApi: ApiFromModules<{
auth: typeof auth;
authz: typeof authz;
conversationMessages: typeof conversationMessages;
conversationProjections: typeof conversationProjections;
crons: typeof crons;
fluePersistence: typeof fluePersistence;
gitConnectionData: typeof gitConnectionData;

View File

@@ -17,6 +17,7 @@ export const authComponent = createClient<DataModel>(components.betterAuth);
const createAuth = (ctx: GenericCtx<DataModel>) =>
betterAuth({
advanced: { useSecureCookies: siteUrl.startsWith("https://") },
baseURL: env.CONVEX_SITE_URL,
database: authComponent.adapter(ctx),
emailAndPassword: {
@@ -36,6 +37,7 @@ const createAuth = (ctx: GenericCtx<DataModel>) =>
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
scope: ["repo", "read:org"],
},
}
: {},

View File

@@ -78,3 +78,24 @@ export const requireProjectMember = async (
const userId = await requireOrganizationMember(ctx, project.organizationId);
return { organizationId: project.organizationId, userId };
};
/**
* Prove the authenticated user is an owner of the given organization.
* Provisioning functions require owner-level authorization.
*/
export const requireCurrentOrganizationOwner = async (
ctx: QueryCtx | MutationCtx,
organizationId: Id<"organizations">
): Promise<string> => {
const userId = await requireAuthUserId(ctx);
const membership = await ctx.db
.query("organizationMembers")
.withIndex("by_organizationId_and_userId", (q) =>
q.eq("organizationId", organizationId).eq("userId", userId)
)
.unique();
if (!membership || membership.role !== "owner") {
throw new ConvexError("Organization owner role required");
}
return userId;
};

View File

@@ -20,17 +20,6 @@ const markProcessingRef = makeFunctionReference<
{ turnId: string; attempt: number; leaseOwner: string },
boolean
>("conversationMessages:markProcessing");
const completeTurnRef = makeFunctionReference<
"mutation",
{
turnId: string;
attempt: number;
leaseOwner: string;
submissionId: string;
text: string;
},
boolean
>("conversationMessages:completeTurn");
const failTurnRef = makeFunctionReference<
"mutation",
{
@@ -137,7 +126,7 @@ describe("conversationMessages", () => {
).rejects.toThrow(/Organization membership required/u);
});
test("fences stale turn attempts from overwriting a retry", async () => {
test("fences stale dispatch failures after admission", async () => {
const t = newTest();
const organization = await ensureOrg(t, identityA);
const sent = await t
@@ -146,7 +135,7 @@ describe("conversationMessages", () => {
clientRequestId: "request-fenced",
images: [],
organizationId: organization._id,
rawText: "Keep only the current response",
rawText: "Keep only the admitted submission",
});
expect(
@@ -156,46 +145,27 @@ describe("conversationMessages", () => {
turnId: sent.turnId,
})
).toBe(true);
await t.run(async (ctx) => {
await ctx.db.patch(sent.turnId, {
leaseExpiresAt: undefined,
leaseOwner: undefined,
status: "running",
submissionId: "submission-1",
});
});
expect(
await t.mutation(failTurnRef, {
attempt: 1,
error: "retry",
error: "lost 202",
leaseOwner: "worker-1",
retry: true,
turnId: sent.turnId,
})
).toBe(true);
expect(
await t.mutation(completeTurnRef, {
attempt: 1,
leaseOwner: "worker-1",
submissionId: "stale",
text: "stale response",
turnId: sent.turnId,
})
).toBe(false);
expect(
await t.mutation(markProcessingRef, {
attempt: 2,
leaseOwner: "worker-2",
turnId: sent.turnId,
})
).toBe(true);
expect(
await t.mutation(completeTurnRef, {
attempt: 2,
leaseOwner: "worker-2",
submissionId: "current",
text: "current response",
turnId: sent.turnId,
})
).toBe(true);
const messages = await t
.withIdentity(identityA)
.query(api.conversationMessages.listForCurrentOrganization, {
organizationId: organization._id,
});
expect(messages[1]?.rawText).toBe("current response");
expect(await t.run((ctx) => ctx.db.get(sent.turnId))).toMatchObject({
status: "running",
submissionId: "submission-1",
});
});
});

View File

@@ -38,17 +38,6 @@ const markProcessingRef = makeFunctionReference<
},
boolean
>("conversationMessages:markProcessing");
const completeTurnRef = makeFunctionReference<
"mutation",
{
turnId: Id<"conversationTurns">;
attempt: number;
leaseOwner: string;
submissionId: string;
text: string;
},
boolean
>("conversationMessages:completeTurn");
const failTurnRef = makeFunctionReference<
"mutation",
{
@@ -268,47 +257,7 @@ export const markProcessing = internalMutation({
error: undefined,
leaseExpiresAt: Date.now() + 60_000,
leaseOwner: args.leaseOwner,
status: "processing",
});
return true;
},
});
export const completeTurn = internalMutation({
args: {
attempt: v.number(),
leaseOwner: v.string(),
submissionId: v.string(),
text: v.string(),
turnId: v.id("conversationTurns"),
},
handler: async (ctx, args): Promise<boolean> => {
const turn = await ctx.db.get(args.turnId);
if (
!turn ||
turn.status !== "processing" ||
(turn.attemptNumber ?? 1) !== args.attempt ||
turn.leaseOwner !== args.leaseOwner ||
(turn.leaseExpiresAt !== undefined && turn.leaseExpiresAt < Date.now())
) {
return false;
}
const assistant = await ctx.db
.query("conversationMessages")
.withIndex("by_turnId_and_role", (q) =>
q.eq("turnId", args.turnId).eq("role", "assistant")
)
.unique();
if (assistant) {
await ctx.db.patch(assistant._id, { content: args.text });
}
await ctx.db.patch(args.turnId, {
completedAt: Date.now(),
error: undefined,
leaseExpiresAt: undefined,
leaseOwner: undefined,
status: "completed",
submissionId: args.submissionId,
status: "dispatching",
});
return true;
},
@@ -326,10 +275,9 @@ export const failTurn = internalMutation({
const turn = await ctx.db.get(args.turnId);
if (
!turn ||
turn.status !== "processing" ||
(turn.attemptNumber ?? 1) !== args.attempt ||
turn.status !== "dispatching" ||
turn.leaseOwner !== args.leaseOwner ||
(turn.leaseExpiresAt !== undefined && turn.leaseExpiresAt < Date.now())
(turn.attemptNumber ?? 1) !== args.attempt
) {
return false;
}
@@ -392,7 +340,6 @@ export const runTurn = internalAction({
`agents/zopu/${encodeURIComponent(String(turn.organizationId))}`,
`${flueUrl.replace(/\/+$/u, "")}/`
);
endpoint.searchParams.set("wait", "result");
const response = await fetch(endpoint, {
body: JSON.stringify({ images, message: turn.user.content }),
headers: {
@@ -400,41 +347,29 @@ export const runTurn = internalAction({
"content-type": "application/json",
"x-zopu-organization-id": String(turn.organizationId),
"x-zopu-request-id": turn.turn.clientRequestId,
"x-zopu-turn-id": String(args.turnId),
},
method: "POST",
});
const payload: unknown = await response.json().catch(() => null);
if (
!response.ok ||
typeof payload !== "object" ||
payload === null ||
!("submissionId" in payload) ||
typeof payload.submissionId !== "string" ||
!("result" in payload) ||
typeof payload.result !== "object" ||
payload.result === null ||
!("text" in payload.result) ||
typeof payload.result.text !== "string"
) {
throw new Error(`Flue turn failed (${response.status})`);
if (!response.ok) {
throw new Error(
`Flue admission failed (${response.status}): ${await response.text().catch(() => "")}`
);
}
const admitted = await ctx.runQuery(getTurnRef, { turnId: args.turnId });
if (admitted?.turn.submissionId === undefined) {
throw new Error("Flue admission did not bind the product turn");
}
await ctx.runMutation(completeTurnRef, {
attempt: args.attempt,
leaseOwner,
submissionId: payload.submissionId,
text: payload.result.text,
turnId: args.turnId,
});
} catch (error) {
const retry = args.attempt < MAX_ATTEMPTS;
await ctx.runMutation(failTurnRef, {
const failed = await ctx.runMutation(failTurnRef, {
attempt: args.attempt,
error: error instanceof Error ? error.message : String(error),
leaseOwner,
retry,
turnId: args.turnId,
});
if (retry) {
if (failed && retry) {
await ctx.scheduler.runAfter(args.attempt * 1000, runTurnRef, {
attempt: args.attempt + 1,
turnId: args.turnId,
@@ -451,10 +386,18 @@ export const reconcileExpiredTurns = internalMutation({
const expired = await ctx.db
.query("conversationTurns")
.withIndex("by_status_and_leaseExpiresAt", (q) =>
q.eq("status", "processing").lt("leaseExpiresAt", Date.now())
q.eq("status", "dispatching").lt("leaseExpiresAt", Date.now())
)
.collect();
for (const turn of expired) {
if (turn.submissionId !== undefined) {
await ctx.db.patch(turn._id, {
leaseExpiresAt: undefined,
leaseOwner: undefined,
status: "running",
});
continue;
}
const attempt = turn.attemptNumber ?? 1;
const retry = attempt < MAX_ATTEMPTS;
await ctx.db.patch(turn._id, {

View File

@@ -0,0 +1,145 @@
import type { Id } from "./_generated/dataModel";
import type { MutationCtx } from "./_generated/server";
interface ProjectionContext {
readonly assistantMessageId: Id<"conversationMessages"> | null;
readonly turnId: Id<"conversationTurns"> | null;
}
const parseStringField = (record: unknown, key: string): string | undefined => {
if (typeof record !== "object" || record === null) {
return undefined;
}
const value = (record as Record<string, unknown>)[key];
return typeof value === "string" ? value : undefined;
};
const resolveContext = async (
ctx: MutationCtx,
submissionId: string | undefined
): Promise<ProjectionContext> => {
if (submissionId === undefined) {
return { assistantMessageId: null, turnId: null };
}
const turn = await ctx.db
.query("conversationTurns")
.withIndex("by_submissionId", (q) => q.eq("submissionId", submissionId))
.unique();
if (turn === null) {
return { assistantMessageId: null, turnId: null };
}
const assistant = await ctx.db
.query("conversationMessages")
.withIndex("by_turnId_and_role", (q) =>
q.eq("turnId", turn._id).eq("role", "assistant")
)
.unique();
return {
assistantMessageId: assistant?._id ?? null,
turnId: turn._id,
};
};
const terminalError = (record: unknown): string => {
if (typeof record !== "object" || record === null) {
return "Flue submission failed";
}
const value = (record as Record<string, unknown>).error;
if (typeof value === "string") {
return value.slice(0, 2000);
}
if (typeof value === "object" && value !== null) {
const { message } = value as Record<string, unknown>;
if (typeof message === "string") {
return message.slice(0, 2000);
}
}
return "Flue submission failed";
};
/**
* Project the small product view from Flue's canonical conversation stream.
* The raw stream remains authoritative; this function only updates the existing
* assistant message and turn row that the web already reads.
*/
export const projectConversationRecords = async (
ctx: MutationCtx,
recordsJson: string,
submissionId: string | undefined
): Promise<void> => {
let records: unknown[];
try {
const parsed = JSON.parse(recordsJson) as unknown;
if (!Array.isArray(parsed)) {
return;
}
records = parsed;
} catch {
return;
}
const projection = await resolveContext(ctx, submissionId);
if (projection.turnId === null) {
return;
}
for (const record of records) {
const type = parseStringField(record, "type");
if (type === "assistant_text_delta" && projection.assistantMessageId) {
const delta = parseStringField(record, "delta");
if (delta !== undefined) {
const assistant = await ctx.db.get(projection.assistantMessageId);
if (assistant !== null) {
await ctx.db.patch(assistant._id, {
content: `${assistant.content}${delta}`,
});
}
}
continue;
}
if (type !== "submission_settled") {
continue;
}
const turn = await ctx.db.get(projection.turnId);
if (
turn === null ||
turn.status === "completed" ||
turn.status === "failed" ||
turn.status === "aborted"
) {
continue;
}
const outcome = parseStringField(record, "outcome");
if (outcome === "completed" && projection.assistantMessageId) {
const result =
typeof record === "object" && record !== null
? (record as Record<string, unknown>).result
: undefined;
const text =
typeof result === "object" && result !== null
? (result as Record<string, unknown>).text
: undefined;
if (typeof text === "string") {
await ctx.db.patch(projection.assistantMessageId, { content: text });
}
}
let status: "aborted" | "completed" | "failed" = "failed";
if (outcome === "completed") {
status = "completed";
} else if (outcome === "aborted") {
status = "aborted";
}
await ctx.db.patch(turn._id, {
completedAt: Date.now(),
error: outcome === "failed" ? terminalError(record) : undefined,
leaseExpiresAt: undefined,
leaseOwner: undefined,
status,
});
}
};

View File

@@ -10,10 +10,13 @@ const app = defineApp({
FLUE_URL: v.optional(v.string()),
GITEA_TOKEN: v.optional(v.string()),
GITEA_URL: v.optional(v.string()),
GITEA_WEBHOOK_SECRET: v.optional(v.string()),
GITHUB_CLIENT_ID: v.optional(v.string()),
GITHUB_CLIENT_SECRET: v.optional(v.string()),
GITHUB_WEBHOOK_SECRET: v.optional(v.string()),
GIT_CREDENTIAL_ENCRYPTION_KEY: v.optional(v.string()),
NATIVE_APP_URL: v.optional(v.string()),
PUTER_GIT_ADMIN_TOKEN: v.optional(v.string()),
SITE_URL: v.string(),
},
});

View File

@@ -12,6 +12,16 @@ const reconcileConversationTurnsRef = makeFunctionReference<
Record<string, never>,
{ reconciled: number }
>("conversationMessages:reconcileExpiredTurns");
const reconcileGitConnectionsRef = makeFunctionReference<
"action",
Record<string, never>,
{ checked: number }
>("gitConnectionHealth:reconcileStaleConnections");
const backfillConnectionsRef = makeFunctionReference<
"mutation",
Record<string, never>,
{ migrated: number }
>("gitConnectionData:backfillConnections");
const crons = cronJobs();
@@ -25,5 +35,16 @@ crons.interval(
{ seconds: 30 },
reconcileConversationTurnsRef
);
crons.cron(
"reconcile stale git connections",
"0 * * * *",
reconcileGitConnectionsRef
);
// Backfill legacy connections on startup and every 6 hours until all are migrated.
crons.interval(
"backfill legacy git connections",
{ hours: 6 },
backfillConnectionsRef
);
export default crons;

View File

@@ -74,6 +74,53 @@ describe("Flue Convex persistence", () => {
});
});
test("binds a direct admission to the product turn atomically", async () => {
const t = convexTest({ modules, schema });
const organizationId = await t.run(async (ctx) =>
ctx.db.insert("organizations", {
createdAt: 1,
createdBy: "user-1",
kind: "personal",
name: "Test",
})
);
const conversationId = await t.run(async (ctx) =>
ctx.db.insert("conversations", { createdAt: 1, organizationId })
);
const turnId = await t.run(async (ctx) =>
ctx.db.insert("conversationTurns", {
attemptNumber: 1,
clientRequestId: "request-1",
conversationId,
createdAt: 1,
leaseExpiresAt: 100,
leaseOwner: "worker",
status: "dispatching",
})
);
await t.mutation(api.fluePersistence.admitSubmission, {
clientRequestId: "request-1",
input: {
acceptedAt: 1,
chunksJson: "[]",
inputJson: '{"kind":"direct"}',
kind: "direct",
sessionKey: "agent/instance/default",
submissionId: "submission-1",
},
token,
turnId,
});
const turn = await t.run((ctx) => ctx.db.get(turnId));
expect(turn).toMatchObject({
status: "running",
submissionId: "submission-1",
});
expect(turn).not.toHaveProperty("leaseExpiresAt");
expect(turn).not.toHaveProperty("leaseOwner");
});
test("fences stale conversation producers and conflicting attachments", async () => {
const t = convexTest({ modules, schema });
await t.mutation(api.fluePersistence.createConversationStream, {

View File

@@ -1,9 +1,11 @@
/* eslint-disable unicorn/no-array-sort, no-await-in-loop, unicorn/no-await-expression-member, unicorn/filename-case, unicorn/prefer-at, unicorn/no-array-reduce, @typescript-eslint/no-non-null-assertion */
import { env } from "@code/env/convex";
import { v } from "convex/values";
import type { Doc } from "./_generated/dataModel";
import { mutation, query } from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { v } from "convex/values";
import { projectConversationRecords } from "./conversationProjections";
const FLUE_SCHEMA_VERSION = "4";
const DURABILITY_DEFAULT_MAX_ATTEMPTS = 10;
@@ -129,16 +131,24 @@ interface AttachmentWire {
readonly bytes: ArrayBuffer;
}
interface AdmitSubmissionResponse {
readonly kind: "submission" | "retained_receipt" | "conflict";
readonly submission?: SubmissionRow;
readonly receipt?: { readonly submissionId: string; readonly acceptedAt: number };
readonly receipt?: {
readonly submissionId: string;
readonly acceptedAt: number;
};
}
interface AppendResult { readonly offset: number; readonly appended: boolean }
interface AppendResult {
readonly offset: number;
readonly appended: boolean;
}
interface ListRunsCursor { readonly startedAt: string; readonly runId: string }
interface ListRunsCursor {
readonly startedAt: string;
readonly runId: string;
}
interface OwnedConversationRecord {
readonly id?: string;
@@ -151,7 +161,7 @@ const submissionKind = v.union(v.literal("dispatch"), v.literal("direct"));
const runStatus = v.union(
v.literal("active"),
v.literal("completed"),
v.literal("errored"),
v.literal("errored")
);
const attachmentRef = v.object({
digest: v.string(),
@@ -199,7 +209,7 @@ const toSubmissionRow = (doc: SubmissionDoc): SubmissionRow => ({
});
const toSettlementObligationRow = (
doc: SubmissionDoc,
doc: SubmissionDoc
): SettlementObligationRow | null => {
if (
doc.attemptId === undefined ||
@@ -224,7 +234,7 @@ const toAttemptMarkerRow = (doc: AttemptMarkerDoc): AttemptMarkerRow => ({
});
const toConversationStreamRow = (
doc: ConversationStreamDoc,
doc: ConversationStreamDoc
): ConversationStreamRow => ({
identity: safeJsonParse<ConversationStreamIdentity>(doc.identityJson),
incarnation: doc.incarnation,
@@ -234,7 +244,9 @@ const toConversationStreamRow = (
producerId: doc.producerId ?? null,
});
const toConversationBatchRow = (doc: ConversationBatchDoc): ConversationBatchRow => ({
const toConversationBatchRow = (
doc: ConversationBatchDoc
): ConversationBatchRow => ({
offset: doc.seq,
recordsJson: doc.recordsJson,
});
@@ -299,7 +311,7 @@ const sameAttachment = (
readonly conversationId: string;
readonly attachment: AttachmentRefWire;
readonly bytes: ArrayBuffer;
},
}
): boolean =>
existing.conversationId === input.conversationId &&
existing.attachmentId === input.attachment.id &&
@@ -309,7 +321,10 @@ const sameAttachment = (
existing.filename === input.attachment.filename &&
compareBuffers(existing.bytes, input.bytes);
const compareRunPointerDesc = (left: RunPointerRow, right: RunPointerRow): number => {
const compareRunPointerDesc = (
left: RunPointerRow,
right: RunPointerRow
): number => {
if (left.startedAt !== right.startedAt) {
return left.startedAt < right.startedAt ? 1 : -1;
}
@@ -326,11 +341,10 @@ const parseSessionInstance = (sessionKey: string): string | undefined => {
return undefined;
}
try {
const parsed = JSON.parse(sessionKey.slice("agent-session:".length)) as unknown;
if (
Array.isArray(parsed) &&
typeof parsed[0] === "string"
) {
const parsed = JSON.parse(
sessionKey.slice("agent-session:".length)
) as unknown;
if (Array.isArray(parsed) && typeof parsed[0] === "string") {
return parsed[0];
}
} catch {
@@ -350,11 +364,15 @@ const parseOwnedRecord = (record: unknown): OwnedConversationRecord | null => {
...(typeof value.submissionId === "string"
? { submissionId: value.submissionId }
: {}),
...(typeof value.attemptId === "string" ? { attemptId: value.attemptId } : {}),
...(typeof value.attemptId === "string"
? { attemptId: value.attemptId }
: {}),
};
};
const parseSettledOutcome = (recordJson: string | undefined): SettledOutcome | undefined => {
const parseSettledOutcome = (
recordJson: string | undefined
): SettledOutcome | undefined => {
if (recordJson === undefined) {
return undefined;
}
@@ -376,10 +394,9 @@ const parseSettledOutcome = (recordJson: string | undefined): SettledOutcome | u
type ReadCtx = QueryCtx | MutationCtx;
const getSubmissionDoc = (
ctx: ReadCtx,
submissionId: string,
submissionId: string
): Promise<SubmissionDoc | null> =>
ctx.db
.query("flueSubmissions")
@@ -388,14 +405,13 @@ const getSubmissionDoc = (
const getConversationStreamDoc = (
ctx: ReadCtx,
path: string,
path: string
): Promise<ConversationStreamDoc | null> =>
ctx.db
.query("flueConversationStreams")
.withIndex("by_path", (q) => q.eq("path", path))
.unique();
const assertSubmissionAuthorization = async (
ctx: ReadCtx,
path: string,
@@ -405,7 +421,7 @@ const assertSubmissionAuthorization = async (
readonly attemptId: string;
}
| undefined,
recordsJson: string,
recordsJson: string
): Promise<void> => {
const records = safeJsonParse<unknown[]>(recordsJson);
const owned = records
@@ -413,13 +429,13 @@ const assertSubmissionAuthorization = async (
.filter(
(record): record is OwnedConversationRecord =>
record !== null &&
(record.submissionId !== undefined || record.attemptId !== undefined),
(record.submissionId !== undefined || record.attemptId !== undefined)
);
if (submission === undefined) {
if (owned.length > 0) {
throw new Error(
`[flue] Conversation stream "${path}" received submission-owned records without authorization.`,
`[flue] Conversation stream "${path}" received submission-owned records without authorization.`
);
}
return;
@@ -429,11 +445,11 @@ const assertSubmissionAuthorization = async (
owned.some(
(record) =>
record.submissionId !== submission.submissionId ||
record.attemptId !== submission.attemptId,
record.attemptId !== submission.attemptId
)
) {
throw new Error(
`[flue] Conversation stream "${path}" record ownership does not match the authorized submission attempt.`,
`[flue] Conversation stream "${path}" record ownership does not match the authorized submission attempt.`
);
}
@@ -441,11 +457,13 @@ const assertSubmissionAuthorization = async (
const stored = await getSubmissionDoc(ctx, submission.submissionId);
if (stream === null || stored === null) {
throw new Error(
`[flue] Submission attempt no longer owns work for agent instance "${path}".`,
`[flue] Submission attempt no longer owns work for agent instance "${path}".`
);
}
const streamIdentity = safeJsonParse<ConversationStreamIdentity>(stream.identityJson);
const streamIdentity = safeJsonParse<ConversationStreamIdentity>(
stream.identityJson
);
const terminalizingSettlement =
stored.status === "terminalizing" &&
stored.attemptId === submission.attemptId &&
@@ -466,7 +484,7 @@ const assertSubmissionAuthorization = async (
)
) {
throw new Error(
`[flue] Submission attempt no longer owns work for agent instance "${path}".`,
`[flue] Submission attempt no longer owns work for agent instance "${path}".`
);
}
};
@@ -489,7 +507,9 @@ export const getSubmission = query({
assertToken(args.token);
const submission = await ctx.db
.query("flueSubmissions")
.withIndex("by_submissionId", (q) => q.eq("submissionId", args.submissionId))
.withIndex("by_submissionId", (q) =>
q.eq("submissionId", args.submissionId)
)
.unique();
return submission === null ? null : toSubmissionRow(submission);
},
@@ -509,7 +529,7 @@ export const listRunnableSubmissions = query({
handler: async (ctx, args) => {
assertToken(args.token);
const rows = (await ctx.db.query("flueSubmissions").collect()).sort(
(left, right) => left.sequence - right.sequence,
(left, right) => left.sequence - right.sequence
);
return rows
.filter(
@@ -520,8 +540,8 @@ export const listRunnableSubmissions = query({
(candidate) =>
candidate.sessionKey === row.sessionKey &&
candidate.sequence < row.sequence &&
isUnsettled(candidate.status),
),
isUnsettled(candidate.status)
)
)
.map(toSubmissionRow);
},
@@ -533,7 +553,7 @@ export const listUnreadySubmissions = query({
assertToken(args.token);
return (await ctx.db.query("flueSubmissions").collect())
.filter(
(row) => row.status === "queued" && row.canonicalReadyAt === undefined,
(row) => row.status === "queued" && row.canonicalReadyAt === undefined
)
.sort((left, right) => left.sequence - right.sequence)
.map(toSubmissionRow);
@@ -576,7 +596,9 @@ export const replaceSubmissionAttempt = mutation({
assertToken(args.token);
const submission = await ctx.db
.query("flueSubmissions")
.withIndex("by_submissionId", (q) => q.eq("submissionId", args.submissionId))
.withIndex("by_submissionId", (q) =>
q.eq("submissionId", args.submissionId)
)
.unique();
if (
submission === null ||
@@ -591,7 +613,9 @@ export const replaceSubmissionAttempt = mutation({
attemptId: args.nextAttemptId,
recoveryRequestedAt: undefined,
startedAt: now,
...(args.ownerId === undefined ? { ownerId: undefined } : { ownerId: args.ownerId }),
...(args.ownerId === undefined
? { ownerId: undefined }
: { ownerId: args.ownerId }),
...(args.leaseExpiresAt === undefined
? { leaseExpiresAt: submission.leaseExpiresAt }
: { leaseExpiresAt: args.leaseExpiresAt }),
@@ -605,6 +629,7 @@ export const replaceSubmissionAttempt = mutation({
export const admitSubmission = mutation({
args: {
...tokenArgs,
clientRequestId: v.optional(v.string()),
input: v.object({
acceptedAt: v.number(),
chunksJson: v.optional(v.string()),
@@ -614,12 +639,25 @@ export const admitSubmission = mutation({
submissionId: v.string(),
traceCarrierJson: v.optional(v.string()),
}),
turnId: v.optional(v.id("conversationTurns")),
},
handler: async (ctx, args): Promise<AdmitSubmissionResponse> => {
assertToken(args.token);
const correlatedTurn =
args.clientRequestId === undefined || args.turnId === undefined
? null
: await ctx.db.get(args.turnId);
if (
correlatedTurn !== null &&
correlatedTurn.clientRequestId !== args.clientRequestId
) {
throw new Error("[flue] Turn admission correlation does not match.");
}
const existing = await ctx.db
.query("flueSubmissions")
.withIndex("by_submissionId", (q) => q.eq("submissionId", args.input.submissionId))
.withIndex("by_submissionId", (q) =>
q.eq("submissionId", args.input.submissionId)
)
.unique();
if (existing !== null) {
@@ -629,7 +667,8 @@ export const admitSubmission = mutation({
existing.acceptedAt === args.input.acceptedAt &&
existing.inputJson === args.input.inputJson &&
existing.chunksJson === (args.input.chunksJson ?? "[]") &&
(existing.traceCarrierJson ?? undefined) === args.input.traceCarrierJson;
(existing.traceCarrierJson ?? undefined) ===
args.input.traceCarrierJson;
if (!exactMatch) {
return { kind: "conflict" };
}
@@ -642,14 +681,25 @@ export const admitSubmission = mutation({
},
};
}
if (
correlatedTurn !== null &&
correlatedTurn.submissionId !== existing.submissionId
) {
await ctx.db.patch(correlatedTurn._id, {
error: undefined,
leaseExpiresAt: undefined,
leaseOwner: undefined,
status: "running",
submissionId: existing.submissionId,
});
}
return { kind: "submission", submission: toSubmissionRow(existing) };
}
const all = await ctx.db.query("flueSubmissions").collect();
const nextSequence = all.reduce(
(max, row) => (row.sequence > max ? row.sequence : max),
-1,
) + 1;
const nextSequence =
all.reduce((max, row) => (row.sequence > max ? row.sequence : max), -1) +
1;
const now = Date.now();
const rowId = await ctx.db.insert("flueSubmissions", {
acceptedAt: args.input.acceptedAt,
@@ -671,6 +721,15 @@ export const admitSubmission = mutation({
if (created === null) {
throw new Error("[flue] Failed to create submission row.");
}
if (correlatedTurn !== null) {
await ctx.db.patch(correlatedTurn._id, {
error: undefined,
leaseExpiresAt: undefined,
leaseOwner: undefined,
status: "running",
submissionId: args.input.submissionId,
});
}
return { kind: "submission", submission: toSubmissionRow(created) };
},
});
@@ -681,7 +740,9 @@ export const markSubmissionCanonicalReady = mutation({
assertToken(args.token);
const submission = await ctx.db
.query("flueSubmissions")
.withIndex("by_submissionId", (q) => q.eq("submissionId", args.submissionId))
.withIndex("by_submissionId", (q) =>
q.eq("submissionId", args.submissionId)
)
.unique();
if (submission === null || submission.status !== "queued") {
return null;
@@ -708,9 +769,10 @@ export const claimSubmission = mutation({
handler: async (ctx, args) => {
assertToken(args.token);
const rows = (await ctx.db.query("flueSubmissions").collect()).sort(
(left, right) => left.sequence - right.sequence,
(left, right) => left.sequence - right.sequence
);
const submission = rows.find((row) => row.submissionId === args.submissionId) ?? null;
const submission =
rows.find((row) => row.submissionId === args.submissionId) ?? null;
if (
submission === null ||
submission.status !== "queued" ||
@@ -722,7 +784,7 @@ export const claimSubmission = mutation({
(row) =>
row.sessionKey === submission.sessionKey &&
row.sequence < submission.sequence &&
isUnsettled(row.status),
isUnsettled(row.status)
);
if (hasEarlierUnsettled) {
return null;
@@ -762,7 +824,9 @@ export const markSubmissionInputApplied = mutation({
assertToken(args.token);
const submission = await ctx.db
.query("flueSubmissions")
.withIndex("by_submissionId", (q) => q.eq("submissionId", args.submissionId))
.withIndex("by_submissionId", (q) =>
q.eq("submissionId", args.submissionId)
)
.unique();
if (
submission === null ||
@@ -793,7 +857,9 @@ export const requestSubmissionRecovery = mutation({
assertToken(args.token);
const submission = await ctx.db
.query("flueSubmissions")
.withIndex("by_submissionId", (q) => q.eq("submissionId", args.submissionId))
.withIndex("by_submissionId", (q) =>
q.eq("submissionId", args.submissionId)
)
.unique();
if (
submission === null ||
@@ -817,7 +883,7 @@ export const requestSessionAbort = mutation({
const rows = (await ctx.db.query("flueSubmissions").collect()).filter(
(row) =>
row.sessionKey === args.sessionKey &&
(row.status === "queued" || row.status === "running"),
(row.status === "queued" || row.status === "running")
);
const now = Date.now();
for (const row of rows) {
@@ -835,7 +901,9 @@ export const requeueSubmissionBeforeInputApplied = mutation({
assertToken(args.token);
const submission = await ctx.db
.query("flueSubmissions")
.withIndex("by_submissionId", (q) => q.eq("submissionId", args.submissionId))
.withIndex("by_submissionId", (q) =>
q.eq("submissionId", args.submissionId)
)
.unique();
if (
submission === null ||
@@ -870,7 +938,9 @@ export const reserveSubmissionSettlement = mutation({
assertToken(args.token);
const submission = await ctx.db
.query("flueSubmissions")
.withIndex("by_submissionId", (q) => q.eq("submissionId", args.submissionId))
.withIndex("by_submissionId", (q) =>
q.eq("submissionId", args.submissionId)
)
.unique();
if (submission === null) {
return null;
@@ -901,12 +971,19 @@ export const reserveSubmissionSettlement = mutation({
});
export const finalizeSubmissionSettlement = mutation({
args: { ...tokenArgs, attemptId: v.string(), recordId: v.string(), submissionId: v.string() },
args: {
...tokenArgs,
attemptId: v.string(),
recordId: v.string(),
submissionId: v.string(),
},
handler: async (ctx, args) => {
assertToken(args.token);
const submission = await ctx.db
.query("flueSubmissions")
.withIndex("by_submissionId", (q) => q.eq("submissionId", args.submissionId))
.withIndex("by_submissionId", (q) =>
q.eq("submissionId", args.submissionId)
)
.unique();
if (
submission === null ||
@@ -940,7 +1017,9 @@ export const settleSubmission = mutation({
assertToken(args.token);
const submission = await ctx.db
.query("flueSubmissions")
.withIndex("by_submissionId", (q) => q.eq("submissionId", args.submissionId))
.withIndex("by_submissionId", (q) =>
q.eq("submissionId", args.submissionId)
)
.unique();
if (
submission === null ||
@@ -968,7 +1047,7 @@ export const insertAttemptMarker = mutation({
const existing = await ctx.db
.query("flueAttemptMarkers")
.withIndex("by_submissionId_and_attemptId", (q) =>
q.eq("submissionId", args.submissionId).eq("attemptId", args.attemptId),
q.eq("submissionId", args.submissionId).eq("attemptId", args.attemptId)
)
.unique();
if (existing !== null) {
@@ -994,7 +1073,7 @@ export const deleteAttemptMarker = mutation({
const existing = await ctx.db
.query("flueAttemptMarkers")
.withIndex("by_submissionId_and_attemptId", (q) =>
q.eq("submissionId", args.submissionId).eq("attemptId", args.attemptId),
q.eq("submissionId", args.submissionId).eq("attemptId", args.attemptId)
)
.collect();
for (const row of existing) {
@@ -1014,7 +1093,11 @@ export const listAttemptMarkers = query({
});
export const renewLeases = mutation({
args: { ...tokenArgs, ownerId: v.string(), submissionIds: v.array(v.string()) },
args: {
...tokenArgs,
ownerId: v.string(),
submissionIds: v.array(v.string()),
},
handler: async (ctx, args) => {
assertToken(args.token);
const wanted = new Set(args.submissionIds);
@@ -1046,7 +1129,7 @@ export const listExpiredSubmissions = query({
(row) =>
row.status === "running" &&
row.leaseExpiresAt > 0 &&
row.leaseExpiresAt < now,
row.leaseExpiresAt < now
)
.sort((left, right) => left.sequence - right.sequence)
.map(toSubmissionRow);
@@ -1065,7 +1148,7 @@ export const createConversationStream = mutation({
if (existing !== null) {
if (existing.identityJson !== identityJson) {
throw new Error(
`[flue] Conversation stream "${args.path}" identity conflicts with the existing stream.`,
`[flue] Conversation stream "${args.path}" identity conflicts with the existing stream.`
);
}
return;
@@ -1092,7 +1175,9 @@ export const acquireConversationProducer = mutation({
.withIndex("by_path", (q) => q.eq("path", args.path))
.unique();
if (stream === null) {
throw new Error(`[flue] Conversation stream "${args.path}" does not exist.`);
throw new Error(
`[flue] Conversation stream "${args.path}" does not exist.`
);
}
const producerEpoch = stream.producerEpoch + 1;
await ctx.db.patch(stream._id, {
@@ -1120,7 +1205,7 @@ export const appendConversationBatch = mutation({
producerSequence: v.number(),
recordsJson: v.string(),
submission: v.optional(
v.object({ attemptId: v.string(), submissionId: v.string() }),
v.object({ attemptId: v.string(), submissionId: v.string() })
),
},
handler: async (ctx, args): Promise<AppendResult> => {
@@ -1128,7 +1213,7 @@ export const appendConversationBatch = mutation({
const records = safeJsonParse<unknown[]>(args.recordsJson);
if (records.length === 0) {
throw new Error(
`[flue] Conversation stream "${args.path}" cannot append an empty canonical batch.`,
`[flue] Conversation stream "${args.path}" cannot append an empty canonical batch.`
);
}
const stream = await ctx.db
@@ -1136,7 +1221,9 @@ export const appendConversationBatch = mutation({
.withIndex("by_path", (q) => q.eq("path", args.path))
.unique();
if (stream === null) {
throw new Error(`[flue] Conversation stream "${args.path}" does not exist.`);
throw new Error(
`[flue] Conversation stream "${args.path}" does not exist.`
);
}
if (
stream.producerId !== args.producerId ||
@@ -1144,7 +1231,7 @@ export const appendConversationBatch = mutation({
stream.incarnation !== args.incarnation
) {
throw new Error(
`[flue] Conversation stream "${args.path}" producer ownership is stale.`,
`[flue] Conversation stream "${args.path}" producer ownership is stale.`
);
}
const existing = await ctx.db
@@ -1154,7 +1241,7 @@ export const appendConversationBatch = mutation({
.eq("path", args.path)
.eq("producerId", args.producerId)
.eq("producerEpoch", args.producerEpoch)
.eq("producerSequence", args.producerSequence),
.eq("producerSequence", args.producerSequence)
)
.unique();
if (existing !== null) {
@@ -1163,17 +1250,22 @@ export const appendConversationBatch = mutation({
existing.attemptId === args.submission?.attemptId;
if (!sameSubmission || existing.recordsJson !== args.recordsJson) {
throw new Error(
`[flue] Conversation stream "${args.path}" producer sequence has conflicting content.`,
`[flue] Conversation stream "${args.path}" producer sequence has conflicting content.`
);
}
return { appended: false, offset: existing.seq };
}
if (stream.nextProducerSequence !== args.producerSequence) {
throw new Error(
`[flue] Conversation stream "${args.path}" producer sequence is not the next expected value.`,
`[flue] Conversation stream "${args.path}" producer sequence is not the next expected value.`
);
}
await assertSubmissionAuthorization(ctx, args.path, args.submission, args.recordsJson);
await assertSubmissionAuthorization(
ctx,
args.path,
args.submission,
args.recordsJson
);
const seq = stream.nextOffset;
await ctx.db.insert("flueConversationBatches", {
appendedAt: Date.now(),
@@ -1190,12 +1282,28 @@ export const appendConversationBatch = mutation({
nextOffset: seq + 1,
nextProducerSequence: stream.nextProducerSequence + 1,
});
// Projection is a disposable product view. Canonical persistence must win
// even if a future projector record shape is malformed.
try {
await projectConversationRecords(
ctx,
args.recordsJson,
args.submission?.submissionId
);
} catch {
// The raw canonical batch above remains durable and replayable.
}
return { appended: true, offset: seq };
},
});
export const readConversationBatches = query({
args: { ...tokenArgs, afterOffset: v.number(), limit: v.number(), path: v.string() },
args: {
...tokenArgs,
afterOffset: v.number(),
limit: v.number(),
path: v.string(),
},
handler: async (ctx, args) => {
assertToken(args.token);
const stream = await ctx.db
@@ -1209,10 +1317,12 @@ export const readConversationBatches = query({
upToDate: true,
};
}
const rows = (await ctx.db
.query("flueConversationBatches")
.withIndex("by_path_and_seq", (q) => q.eq("path", args.path))
.collect())
const rows = (
await ctx.db
.query("flueConversationBatches")
.withIndex("by_path_and_seq", (q) => q.eq("path", args.path))
.collect()
)
.filter((row) => row.seq > args.afterOffset)
.sort((left, right) => left.seq - right.seq);
const page = rows.slice(0, args.limit);
@@ -1305,7 +1415,12 @@ export const appendEvent = mutation({
});
export const appendEventOnce = mutation({
args: { ...tokenArgs, dataJson: v.string(), key: v.string(), path: v.string() },
args: {
...tokenArgs,
dataJson: v.string(),
key: v.string(),
path: v.string(),
},
handler: async (ctx, args): Promise<AppendResult> => {
assertToken(args.token);
const stream = await ctx.db
@@ -1321,13 +1436,13 @@ export const appendEventOnce = mutation({
const existing = await ctx.db
.query("flueEventEntries")
.withIndex("by_path_and_onceKey", (q) =>
q.eq("path", args.path).eq("onceKey", args.key),
q.eq("path", args.path).eq("onceKey", args.key)
)
.unique();
if (existing !== null) {
if (existing.dataJson !== args.dataJson) {
throw new Error(
`[flue] Event key "${args.key}" already has a conflicting payload.`,
`[flue] Event key "${args.key}" already has a conflicting payload.`
);
}
return { appended: false, offset: existing.seq };
@@ -1346,7 +1461,12 @@ export const appendEventOnce = mutation({
});
export const readEvents = query({
args: { ...tokenArgs, afterOffset: v.number(), limit: v.number(), path: v.string() },
args: {
...tokenArgs,
afterOffset: v.number(),
limit: v.number(),
path: v.string(),
},
handler: async (ctx, args) => {
assertToken(args.token);
const stream = await ctx.db
@@ -1361,10 +1481,12 @@ export const readEvents = query({
upToDate: true,
};
}
const rows = (await ctx.db
.query("flueEventEntries")
.withIndex("by_path_and_seq", (q) => q.eq("path", args.path))
.collect())
const rows = (
await ctx.db
.query("flueEventEntries")
.withIndex("by_path_and_seq", (q) => q.eq("path", args.path))
.collect()
)
.filter((row) => row.seq > args.afterOffset)
.sort((left, right) => left.seq - right.seq);
const page = rows.slice(0, args.limit);
@@ -1507,7 +1629,8 @@ export const listRuns = query({
.filter(
(row) =>
(args.status === undefined || row.status === args.status) &&
(args.workflowName === undefined || row.workflowName === args.workflowName),
(args.workflowName === undefined ||
row.workflowName === args.workflowName)
)
.sort(compareRunPointerDesc)
.filter((row) => {
@@ -1540,7 +1663,9 @@ export const putAttachment = mutation({
const existing = await ctx.db
.query("flueAttachments")
.withIndex("by_streamPath_and_attachmentId", (q) =>
q.eq("streamPath", args.streamPath).eq("attachmentId", args.attachment.id),
q
.eq("streamPath", args.streamPath)
.eq("attachmentId", args.attachment.id)
)
.unique();
if (existing !== null) {
@@ -1576,7 +1701,7 @@ export const getAttachment = query({
q
.eq("streamPath", args.streamPath)
.eq("conversationId", args.conversationId)
.eq("attachmentId", args.attachmentId),
.eq("attachmentId", args.attachmentId)
)
.unique();
return attachment === null ? null : toAttachmentWire(attachment);

View File

@@ -1,13 +1,68 @@
import { providerForHost } from "@code/primitives/git-provider";
import { ConvexError, v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import { internalMutation, mutation, query } from "./_generated/server";
import type { MutationCtx } from "./_generated/server";
import { requireCurrentOrganization, requireProjectMember } from "./authz";
const upsertProviderAccount = async (
ctx: MutationCtx,
args: {
externalAccountId: string;
externalEmail?: string;
externalUsername: string;
organizationId: Id<"organizations">;
provider: "github" | "gitea";
serverUrl: string;
userId: string;
}
): Promise<Id<"gitProviderAccounts">> => {
const existing = await ctx.db
.query("gitProviderAccounts")
.withIndex("by_userId_and_provider_and_serverUrl", (q) =>
q
.eq("userId", args.userId)
.eq("provider", args.provider)
.eq("serverUrl", args.serverUrl)
)
.unique();
const timestamp = Date.now();
if (existing) {
await ctx.db.patch(existing._id, {
externalAccountId: args.externalAccountId,
externalEmail: args.externalEmail,
externalUsername: args.externalUsername,
status: "active",
updatedAt: timestamp,
});
return existing._id;
}
return await ctx.db.insert("gitProviderAccounts", {
createdAt: timestamp,
externalAccountId: args.externalAccountId,
externalEmail: args.externalEmail,
externalUsername: args.externalUsername,
organizationId: args.organizationId,
provider: args.provider,
serverUrl: args.serverUrl,
status: "active",
updatedAt: timestamp,
userId: args.userId,
});
};
export const persist = internalMutation({
args: {
credentialCiphertext: v.string(),
credentialIv: v.string(),
credentialKind: v.union(v.literal("oauth"), v.literal("token")),
externalAccountId: v.string(),
externalEmail: v.optional(v.string()),
externalUsername: v.string(),
grantedScopesJson: v.optional(v.string()),
provider: v.union(v.literal("github"), v.literal("gitea")),
serverUrl: v.string(),
userId: v.string(),
@@ -23,34 +78,54 @@ export const persist = internalMutation({
if (!organization) {
throw new ConvexError("Organization not found");
}
const providerAccountId = await upsertProviderAccount(ctx, {
externalAccountId: args.externalAccountId,
externalEmail: args.externalEmail,
externalUsername: args.externalUsername,
organizationId: organization._id,
provider: args.provider,
serverUrl: args.serverUrl,
userId: args.userId,
});
const existing = await ctx.db
.query("gitConnections")
.withIndex("by_organizationId_and_provider_and_serverUrl", (q) =>
q
.eq("organizationId", organization._id)
.eq("provider", args.provider)
.eq("serverUrl", args.serverUrl)
.withIndex("by_gitProviderAccountId", (q) =>
q.eq("gitProviderAccountId", providerAccountId)
)
.unique();
const timestamp = Date.now();
if (existing) {
await ctx.db.patch(existing._id, {
credentialCiphertext: args.credentialCiphertext,
credentialIv: args.credentialIv,
credentialKind: args.credentialKind,
grantedScopesJson: args.grantedScopesJson,
lastError: undefined,
lastVerifiedAt: timestamp,
reauthRequiredAt: undefined,
state: "active",
updatedAt: timestamp,
username: args.username,
});
return existing._id;
}
return await ctx.db.insert("gitConnections", {
connectedAt: timestamp,
creatorUserId: args.userId,
credentialCiphertext: args.credentialCiphertext,
credentialIv: args.credentialIv,
credentialKind: args.credentialKind,
gitProviderAccountId: providerAccountId,
grantedScopesJson: args.grantedScopesJson,
lastVerifiedAt: timestamp,
organizationId: organization._id,
provider: args.provider,
serverUrl: args.serverUrl,
state: "active",
updatedAt: timestamp,
username: args.username,
});
@@ -71,8 +146,10 @@ export const list = query({
connectedAt: connection.connectedAt,
credentialKind: connection.credentialKind,
id: String(connection._id),
lastVerifiedAt: connection.lastVerifiedAt,
provider: connection.provider,
serverUrl: connection.serverUrl,
state: connection.state,
username: connection.username,
}));
},
@@ -91,8 +168,10 @@ export const getForProject = query({
connectedAt: connection.connectedAt,
credentialKind: connection.credentialKind,
id: String(connection._id),
lastVerifiedAt: connection.lastVerifiedAt,
provider: connection.provider,
serverUrl: connection.serverUrl,
state: connection.state,
username: connection.username,
}
: null;
@@ -110,6 +189,15 @@ export const attachToProject = mutation({
if (!connection || connection.organizationId !== organizationId) {
throw new ConvexError("Git connection not found");
}
const project = await ctx.db.get(args.projectId);
if (project) {
const expected = providerForHost(project.sourceHost);
if (expected && connection.provider !== expected) {
throw new ConvexError(
`Git credential provider (${connection.provider}) does not match this project's provider (${expected})`
);
}
}
await ctx.db.patch(args.projectId, {
gitConnectionId: connection._id,
updatedAt: Date.now(),
@@ -117,3 +205,46 @@ export const attachToProject = mutation({
return { attached: true };
},
});
/**
* Backfill legacy gitConnections rows that predate the normalized schema.
* Sets default values for creatorUserId, state, and lastVerifiedAt when
* missing. Also creates a gitProviderAccount for each legacy connection.
*/
export const backfillConnections = internalMutation({
args: {},
handler: async (ctx) => {
const all = await ctx.db.query("gitConnections").collect();
let migrated = 0;
for (const conn of all) {
if (conn.state !== undefined && conn.gitProviderAccountId !== undefined) {
continue;
}
const timestamp = Date.now();
// Create a provider account for the legacy connection.
const providerAccountId = await ctx.db.insert("gitProviderAccounts", {
createdAt: conn.connectedAt,
externalAccountId: conn.username ?? "legacy",
externalUsername: conn.username ?? "legacy",
organizationId: conn.organizationId,
provider: conn.provider,
serverUrl: conn.serverUrl,
status: "reauth-required",
updatedAt: timestamp,
userId: conn.creatorUserId ?? "legacy",
});
// Mark as reauth-required so the health check cron verifies the
// credential before marking active. This prevents trusting legacy
// credentials without verification.
await ctx.db.patch(conn._id, {
creatorUserId: conn.creatorUserId ?? "legacy",
gitProviderAccountId: providerAccountId,
reauthRequiredAt: timestamp,
state: "reauth-required",
updatedAt: timestamp,
});
migrated += 1;
}
return { migrated };
},
});

View File

@@ -0,0 +1,243 @@
"use node";
import {
CREDENTIAL_FRESHNESS_MS,
isCredentialFresh,
} from "@code/primitives/git-provider";
import { makeFunctionReference } from "convex/server";
import { ConvexError, v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import {
action,
internalMutation,
internalQuery,
query,
} from "./_generated/server";
import { requireCurrentOrganization } from "./authz";
import { decryptCredential } from "./gitConnections";
interface VerifyResult {
readonly lastError?: string;
readonly state: "active" | "reauth-required" | "unavailable";
}
const verifyGiteaCredential = async (
serverUrl: string,
token: string
): Promise<VerifyResult> => {
try {
const response = await fetch(
`${serverUrl.replace(/\/+$/u, "")}/api/v1/user`,
{ headers: { authorization: `token ${token}` } }
);
if (response.status === 401 || response.status === 403) {
return { lastError: "Token rejected", state: "reauth-required" };
}
if (!response.ok) {
return {
lastError: `Provider returned ${response.status}`,
state: "unavailable",
};
}
return { state: "active" };
} catch (error) {
return {
lastError: error instanceof Error ? error.message : "Unreachable",
state: "unavailable",
};
}
};
const verifyGithubCredential = async (token: string): Promise<VerifyResult> => {
try {
const response = await fetch("https://api.github.com/user", {
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
},
});
if (response.status === 401 || response.status === 403) {
return { lastError: "Token rejected", state: "reauth-required" };
}
if (!response.ok) {
return {
lastError: `Provider returned ${response.status}`,
state: "unavailable",
};
}
return { state: "active" };
} catch (error) {
return {
lastError: error instanceof Error ? error.message : "Unreachable",
state: "unavailable",
};
}
};
export const verifyCredential = async (
connection: Doc<"gitConnections">
): Promise<VerifyResult> => {
const credential = await decryptCredential(
connection.credentialCiphertext,
connection.credentialIv
);
return connection.provider === "gitea"
? verifyGiteaCredential(connection.serverUrl, credential)
: verifyGithubCredential(credential);
};
export const updateConnectionState = internalMutation({
args: {
connectionId: v.id("gitConnections"),
lastError: v.optional(v.string()),
state: v.union(
v.literal("active"),
v.literal("reauth-required"),
v.literal("unavailable")
),
},
handler: async (ctx, args) => {
const timestamp = Date.now();
const patch: Record<string, unknown> = {
lastError: args.lastError,
state: args.state,
updatedAt: timestamp,
};
if (args.state === "active") {
patch.lastError = undefined;
patch.lastVerifiedAt = timestamp;
patch.reauthRequiredAt = undefined;
} else if (args.state === "reauth-required") {
patch.reauthRequiredAt = timestamp;
}
await ctx.db.patch(args.connectionId, patch);
},
});
const getConnectionForOwnerRef = makeFunctionReference<
"query",
{ connectionId: Id<"gitConnections"> },
Doc<"gitConnections"> | null
>("gitConnectionHealth:getConnectionForOwner");
const getConnectionRef = makeFunctionReference<
"query",
{ connectionId: Id<"gitConnections"> },
Doc<"gitConnections"> | null
>("gitConnectionHealth:getConnection");
const getStaleConnectionsRef = makeFunctionReference<
"query",
Record<string, never>,
{ connectionId: Id<"gitConnections"> }[]
>("gitConnectionHealth:getStaleConnections");
const updateConnectionStateRef = makeFunctionReference<
"mutation",
{
connectionId: Id<"gitConnections">;
lastError?: string;
state: "active" | "reauth-required" | "unavailable";
},
null
>("gitConnectionHealth:updateConnectionState");
export const getConnectionForOwner = internalQuery({
args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args) => {
const { organizationId } = await requireCurrentOrganization(ctx);
const connection = await ctx.db.get(args.connectionId);
if (!connection || connection.organizationId !== organizationId) {
return null;
}
return connection;
},
});
export const getConnection = internalQuery({
args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args) => await ctx.db.get(args.connectionId),
});
export const getStaleConnections = internalQuery({
args: {},
handler: async (ctx) => {
const now = Date.now();
const cutoff = now - CREDENTIAL_FRESHNESS_MS;
const all = await ctx.db.query("gitConnections").collect();
return all
.filter(
(conn) =>
(conn.state === "active" &&
(conn.lastVerifiedAt === undefined ||
conn.lastVerifiedAt < cutoff)) ||
conn.state === "reauth-required" ||
conn.state === undefined
)
.map((conn) => ({ connectionId: conn._id }));
},
});
export const verify = action({
args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args) => {
const connection = await ctx.runQuery(getConnectionForOwnerRef, {
connectionId: args.connectionId,
});
if (!connection) {
throw new ConvexError("Git connection not found");
}
const result = await verifyCredential(connection);
await ctx.runMutation(updateConnectionStateRef, {
connectionId: args.connectionId,
lastError: result.lastError,
state: result.state,
});
return result;
},
});
export const isFresh = query({
args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args): Promise<boolean> => {
const { organizationId } = await requireCurrentOrganization(ctx);
const connection = await ctx.db.get(args.connectionId);
if (!connection || connection.organizationId !== organizationId) {
throw new ConvexError("Git connection not found");
}
return isCredentialFresh(connection.lastVerifiedAt, Date.now());
},
});
export const reconcileStaleConnections = action({
args: {},
handler: async (ctx) => {
const stale = await ctx.runQuery(getStaleConnectionsRef, {});
let checked = 0;
for (const { connectionId } of stale) {
const connection = await ctx.runQuery(getConnectionRef, {
connectionId,
});
if (!connection) {
continue;
}
try {
const result = await verifyCredential(connection);
await ctx.runMutation(updateConnectionStateRef, {
connectionId,
lastError: result.lastError,
state: result.state,
});
checked += 1;
} catch {
await ctx.runMutation(updateConnectionStateRef, {
connectionId,
lastError: "Verification failed",
state: "unavailable",
});
}
}
return { checked };
},
});

View File

@@ -2,11 +2,16 @@
import { env } from "@code/env/convex";
import { decodeGitConnectionInput } from "@code/primitives/execution-runtime";
import {
GITHUB_SERVER_URL,
PUTER_GIT_SERVER_URL,
} from "@code/primitives/git-provider";
import { makeFunctionReference } from "convex/server";
import { ConvexError, v } from "convex/values";
import { Effect } from "effect";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import type { Doc, Id } from "./_generated/dataModel";
import { action } from "./_generated/server";
import { authComponent, createAuth } from "./auth";
@@ -52,9 +57,73 @@ export const decryptCredential = async (
return new TextDecoder().decode(decrypted);
};
interface ExternalUserInfo {
readonly externalAccountId: string;
readonly externalEmail?: string;
readonly externalUsername: string;
}
/** Fetch the Gitea user identity from /api/v1/user using a PAT. */
const fetchGiteaUser = async (
serverUrl: string,
token: string
): Promise<ExternalUserInfo> => {
const response = await fetch(
`${serverUrl.replace(/\/+$/u, "")}/api/v1/user`,
{
headers: { authorization: `token ${token}` },
}
);
if (!response.ok) {
throw new ConvexError(
`Gitea user verification failed (${response.status})`
);
}
const user = (await response.json()) as {
readonly email?: string;
readonly id: number;
readonly login: string;
};
return {
externalAccountId: String(user.id),
externalEmail: user.email,
externalUsername: user.login,
};
};
/** Fetch the GitHub user identity from /user using an OAuth token. */
const fetchGithubUser = async (token: string): Promise<ExternalUserInfo> => {
const response = await fetch("https://api.github.com/user", {
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new ConvexError(
`GitHub user verification failed (${response.status})`
);
}
const user = (await response.json()) as {
readonly email?: string;
readonly id: number;
readonly login: string;
};
return {
externalAccountId: String(user.id),
externalEmail: user.email,
externalUsername: user.login,
};
};
const syncRepositoriesRef = makeFunctionReference<
"action",
{ connectionId: Id<"gitConnections"> },
{ synced: number }
>("gitConnections:syncRepositories");
export const connectGitea = action({
args: {
serverUrl: v.string(),
token: v.string(),
username: v.optional(v.string()),
},
@@ -73,22 +142,32 @@ export const connectGitea = action({
credential: args.token,
credentialKind: "token",
provider: "gitea",
serverUrl: args.serverUrl,
serverUrl: PUTER_GIT_SERVER_URL,
username: args.username,
})
);
// Verify the token and fetch external identity before persisting.
const externalUser = await fetchGiteaUser(
connection.serverUrl,
connection.credential
);
const encrypted = await encryptCredential(connection.credential);
const connectionId = await ctx.runMutation(
internal.gitConnectionData.persist,
{
...encrypted,
...externalUser,
credentialKind: connection.credentialKind,
provider: connection.provider,
serverUrl: connection.serverUrl,
userId,
username: connection.username,
username: connection.username ?? externalUser.externalUsername,
}
);
// Sync accessible repositories after connecting.
await ctx.runAction(syncRepositoriesRef, {
connectionId,
});
return { connectionId };
},
});
@@ -108,17 +187,125 @@ export const connectGithub = action({
if (!token.accessToken) {
throw new ConvexError("GitHub account is not connected");
}
const externalUser = await fetchGithubUser(token.accessToken);
const encrypted = await encryptCredential(token.accessToken);
const connectionId = await ctx.runMutation(
internal.gitConnectionData.persist,
{
...encrypted,
...externalUser,
credentialKind: "oauth",
provider: "github",
serverUrl: "https://github.com",
serverUrl: GITHUB_SERVER_URL,
userId: identity.tokenIdentifier,
}
);
// Sync accessible repositories after connecting.
await ctx.runAction(syncRepositoriesRef, {
connectionId,
});
return { connectionId };
},
});
const getConnectionForOwnerSyncRef = makeFunctionReference<
"query",
{ connectionId: Id<"gitConnections"> },
Doc<"gitConnections"> | null
>("gitConnectionHealth:getConnectionForOwner");
const syncRepositoriesBatchRef = makeFunctionReference<
"mutation",
{
providerAccountId: Id<"gitProviderAccounts">;
provider: "github" | "gitea";
repos: {
cloneUrl: string;
defaultBranch: string;
fullName: string;
name: string;
owner: string;
private: boolean;
providerRepositoryId: string;
serverUrl: string;
webUrl: string;
}[];
serverUrl: string;
},
number
>("gitProvisioning:syncRepositoriesBatch");
export const syncRepositories = action({
args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args): Promise<{ synced: number }> => {
const connection = await ctx.runQuery(getConnectionForOwnerSyncRef, {
connectionId: args.connectionId,
});
if (!connection) {
throw new ConvexError("Git connection not found");
}
const token = await decryptCredential(
connection.credentialCiphertext,
connection.credentialIv
);
let repos: {
clone_url: string;
default_branch: string;
full_name: string;
html_url: string;
id: number;
name: string;
owner: { login: string };
private: boolean;
}[];
if (connection.provider === "gitea") {
const response = await fetch(
`${connection.serverUrl.replace(/\/+$/u, "")}/api/v1/repos/search?limit=50`,
{ headers: { authorization: `token ${token}` } }
);
if (!response.ok) {
throw new ConvexError(
`Failed to list Gitea repositories (${response.status})`
);
}
const body = (await response.json()) as { data: typeof repos };
repos = body.data ?? [];
} else {
const response = await fetch(
"https://api.github.com/user/repos?sort=updated&per_page=50",
{
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
throw new ConvexError(
`Failed to list GitHub repositories (${response.status})`
);
}
repos = (await response.json()) as typeof repos;
}
const synced = await ctx.runMutation(syncRepositoriesBatchRef, {
provider: connection.provider,
providerAccountId:
connection.gitProviderAccountId ?? ("" as Id<"gitProviderAccounts">),
repos: repos.map((repo) => ({
cloneUrl: repo.clone_url,
defaultBranch: repo.default_branch ?? "main",
fullName: repo.full_name,
name: repo.name,
owner: repo.owner.login,
private: repo.private,
providerRepositoryId: String(repo.id),
serverUrl: connection.serverUrl,
webUrl: repo.html_url,
})),
serverUrl: connection.serverUrl,
});
return { synced };
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,254 @@
"use node";
import { env } from "@code/env/convex";
import {
isProcessedEvent,
MAX_WEBHOOK_PAYLOAD_BYTES,
} from "@code/primitives/git-webhook";
import { makeFunctionReference } from "convex/server";
import type { Id } from "./_generated/dataModel";
import { httpAction } from "./_generated/server";
const recordWebhookDeliveryRef = makeFunctionReference<
"mutation",
{
deliveryId: string;
event: string;
externalRepositoryId?: string;
payloadHash: string;
provider: "github" | "gitea";
},
{ deliveryId: Id<"gitWebhookDeliveries">; duplicate: boolean }
>("gitProvisioning:recordWebhookDelivery");
const resolveRepositoryByExternalIdRef = makeFunctionReference<
"query",
{
externalRepositoryId: string;
provider: "github" | "gitea";
},
{ _id: Id<"gitRepositories"> } | null
>("gitProvisioning:resolveRepositoryByExternalId");
const markDeliveryProcessedRef = makeFunctionReference<
"mutation",
{
deliveryId: Id<"gitWebhookDeliveries">;
repositoryRef?: Id<"gitRepositories">;
},
null
>("gitProvisioning:markDeliveryProcessed");
/* eslint-disable @typescript-eslint/no-explicit-any -- action ctx type is complex without codegen */
const processDelivery = async (
ctx: any,
input: {
readonly deliveryId: string;
readonly event: string;
readonly externalRepositoryId?: string;
readonly payloadHash: string;
readonly provider: "github" | "gitea";
}
): Promise<{ deliveryId: string; duplicate: boolean }> => {
const result: { deliveryId: Id<"gitWebhookDeliveries">; duplicate: boolean } =
await ctx.runMutation(recordWebhookDeliveryRef, input);
if (result.duplicate) {
return result;
}
// Resolve the repository reference if we have an external ID.
let repositoryRef: Id<"gitRepositories"> | undefined;
if (input.externalRepositoryId) {
const repo = await ctx.runQuery(resolveRepositoryByExternalIdRef, {
externalRepositoryId: input.externalRepositoryId,
provider: input.provider,
});
if (repo) {
repositoryRef = repo._id as Id<"gitRepositories">;
}
}
await ctx.runMutation(markDeliveryProcessedRef, {
deliveryId: result.deliveryId,
...(repositoryRef ? { repositoryRef } : {}),
});
return result;
};
const verifyHmacSignature = async (
body: string,
signature: string,
secret: string,
algorithm: "sha256" | "sha1"
): Promise<boolean> => {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ hash: algorithm, name: "HMAC" },
false,
["verify"]
);
let sigHex = signature;
if (signature.startsWith("sha256=")) {
sigHex = signature.slice(7);
} else if (signature.startsWith("sha1=")) {
sigHex = signature.slice(5);
}
const sigBytes = new Uint8Array(
sigHex.match(/.{2}/gu)?.flatMap((byte) => Number.parseInt(byte, 16)) ?? []
);
return await crypto.subtle.verify(
"HMAC",
key,
sigBytes,
new TextEncoder().encode(body)
);
};
const hashPayload = async (body: string): Promise<string> => {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(body)
);
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
};
export const githubWebhook = httpAction(async (ctx, request) => {
const deliveryId = request.headers.get("x-github-delivery") ?? "";
const event = request.headers.get("x-github-event") ?? "";
const signature = request.headers.get("x-hub-signature-256") ?? "";
if (!deliveryId || !event) {
return new Response("Missing GitHub delivery headers", { status: 400 });
}
const rawBody = await request.text();
const payloadBytes = new TextEncoder().encode(rawBody).length;
if (payloadBytes > MAX_WEBHOOK_PAYLOAD_BYTES) {
return new Response("Payload too large", { status: 413 });
}
if (!env.GITHUB_WEBHOOK_SECRET) {
return new Response("GitHub webhook secret not configured", {
status: 500,
});
}
const valid = await verifyHmacSignature(
rawBody,
signature,
env.GITHUB_WEBHOOK_SECRET,
"sha256"
);
if (!valid) {
return new Response("Invalid signature", { status: 401 });
}
if (!isProcessedEvent(event)) {
return new Response("Event ignored", { status: 200 });
}
const payload = JSON.parse(rawBody) as {
action?: string;
repository?: {
full_name: string;
html_url: string;
id: number;
name: string;
owner: { login: string };
};
};
const externalRepositoryId = payload.repository
? String(payload.repository.id)
: undefined;
const payloadHash = await hashPayload(rawBody);
// Persist delivery, resolve repository, and mark processed.
const result = await processDelivery(ctx, {
deliveryId,
event,
externalRepositoryId,
payloadHash,
provider: "github",
});
return Response.json({
deliveryId,
duplicate: result.duplicate,
event,
externalRepositoryId,
payloadHash,
processed: true,
provider: "github",
});
});
export const puterWebhook = httpAction(async (ctx, request) => {
const deliveryId = request.headers.get("x-gitea-delivery") ?? "";
const event = request.headers.get("x-gitea-event") ?? "";
const signature = request.headers.get("x-gitea-signature") ?? "";
if (!deliveryId || !event) {
return new Response("Missing Gitea delivery headers", { status: 400 });
}
const rawBody = await request.text();
const payloadBytes = new TextEncoder().encode(rawBody).length;
if (payloadBytes > MAX_WEBHOOK_PAYLOAD_BYTES) {
return new Response("Payload too large", { status: 413 });
}
if (!env.GITEA_WEBHOOK_SECRET) {
return new Response("Gitea webhook secret not configured", { status: 500 });
}
const valid = await verifyHmacSignature(
rawBody,
signature,
env.GITEA_WEBHOOK_SECRET,
"sha256"
);
if (!valid) {
return new Response("Invalid signature", { status: 401 });
}
if (!isProcessedEvent(event)) {
return new Response("Event ignored", { status: 200 });
}
const payload = JSON.parse(rawBody) as {
action?: string;
repository?: {
full_name: string;
html_url: string;
id: number;
name: string;
owner: { login: string };
};
};
const externalRepositoryId = payload.repository
? String(payload.repository.id)
: undefined;
const payloadHash = await hashPayload(rawBody);
const result = await processDelivery(ctx, {
deliveryId,
event,
externalRepositoryId,
payloadHash,
provider: "gitea",
});
return Response.json({
deliveryId,
duplicate: result.duplicate,
event,
externalRepositoryId,
payloadHash,
processed: true,
provider: "gitea",
});
});

View File

@@ -1,9 +1,22 @@
import { httpRouter } from "convex/server";
import { authComponent, createAuth } from "./auth";
import { githubWebhook, puterWebhook } from "./gitWebhooks";
const http = httpRouter();
authComponent.registerRoutes(http, createAuth, { cors: true });
http.route({
handler: githubWebhook,
method: "POST",
path: "/api/git/webhooks/github",
});
http.route({
handler: puterWebhook,
method: "POST",
path: "/api/git/webhooks/puter",
});
export default http;

View File

@@ -1,3 +1,4 @@
import { validateProjectInstructions } from "@code/primitives/git-provisioning";
import {
decodePublicGitImportResult,
preparePublicGitSource,
@@ -11,8 +12,12 @@ import { Effect } from "effect";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import { action, internalMutation, query } from "./_generated/server";
import { requireAuthUserId, requireCurrentOrganization } from "./authz";
import { action, internalMutation, mutation, query } from "./_generated/server";
import {
requireAuthUserId,
requireCurrentOrganization,
requireProjectMember,
} from "./authz";
import { inspectPublicGit } from "./publicGit";
const toProjectView = async (
@@ -201,3 +206,30 @@ export const importPublicGit = action({
});
},
});
export const updateInstructions = mutation({
args: {
instructions: v.string(),
projectId: v.id("projects"),
},
handler: async (ctx, args) => {
await requireProjectMember(ctx, args.projectId);
const validated = await Effect.runPromise(
validateProjectInstructions(args.instructions)
);
await ctx.db.patch(args.projectId, {
instructions: validated,
updatedAt: Date.now(),
});
return { updated: true };
},
});
export const getInstructions = query({
args: { projectId: v.id("projects") },
handler: async (ctx, args) => {
await requireProjectMember(ctx, args.projectId);
const project = await ctx.db.get(args.projectId);
return project?.instructions ?? "";
},
});

View File

@@ -44,28 +44,168 @@ export default defineSchema({
.index("by_userId", ["userId"])
.index("by_organizationId", ["organizationId"])
.index("by_organizationId_and_userId", ["organizationId", "userId"]),
gitConnections: defineTable({
connectedAt: v.number(),
credentialCiphertext: v.string(),
credentialIv: v.string(),
credentialKind: v.union(v.literal("oauth"), v.literal("token")),
gitProviderAccounts: defineTable({
createdAt: v.number(),
externalAccountId: v.string(),
externalEmail: v.optional(v.string()),
externalUsername: v.string(),
organizationId: v.id("organizations"),
provider: v.union(v.literal("github"), v.literal("gitea")),
serverUrl: v.string(),
status: v.union(
v.literal("pending-auth"),
v.literal("active"),
v.literal("reauth-required"),
v.literal("revoked"),
v.literal("unavailable")
),
updatedAt: v.number(),
userId: v.string(),
})
.index("by_userId_and_provider_and_serverUrl", [
"userId",
"provider",
"serverUrl",
])
.index("by_organizationId", ["organizationId"]),
gitConnections: defineTable({
connectedAt: v.number(),
creatorUserId: v.optional(v.string()),
credentialCiphertext: v.string(),
credentialIv: v.string(),
credentialKind: v.union(v.literal("oauth"), v.literal("token")),
gitProviderAccountId: v.optional(v.id("gitProviderAccounts")),
grantedScopesJson: v.optional(v.string()),
lastError: v.optional(v.string()),
lastVerifiedAt: v.optional(v.number()),
organizationId: v.id("organizations"),
provider: v.union(v.literal("github"), v.literal("gitea")),
reauthRequiredAt: v.optional(v.number()),
serverUrl: v.string(),
state: v.optional(
v.union(
v.literal("pending-auth"),
v.literal("active"),
v.literal("reauth-required"),
v.literal("revoked"),
v.literal("unavailable")
)
),
updatedAt: v.number(),
username: v.optional(v.string()),
})
.index("by_organizationId", ["organizationId"])
.index("by_organizationId_and_provider_and_serverUrl", [
"organizationId",
.index("by_gitProviderAccountId", ["gitProviderAccountId"]),
gitProviderOrganizations: defineTable({
createdAt: v.number(),
displayName: v.optional(v.string()),
externalId: v.string(),
externalSlug: v.string(),
gitProviderAccountId: v.id("gitProviderAccounts"),
organizationId: v.id("organizations"),
serverUrl: v.string(),
updatedAt: v.number(),
verificationState: v.union(
v.literal("unverified"),
v.literal("verified"),
v.literal("stale")
),
visibility: v.union(v.literal("public"), v.literal("private")),
})
.index("by_gitProviderAccountId", ["gitProviderAccountId"])
.index("by_organizationId", ["organizationId"]),
gitRepositories: defineTable({
cloneUrl: v.string(),
createdAt: v.number(),
defaultBranch: v.string(),
fullName: v.string(),
gitProviderAccountId: v.id("gitProviderAccounts"),
gitProviderOrganizationId: v.optional(v.id("gitProviderOrganizations")),
lfsCapability: v.union(
v.literal("unknown"),
v.literal("supported"),
v.literal("unsupported")
),
name: v.string(),
organizationId: v.id("organizations"),
owner: v.string(),
permissionsAdmin: v.boolean(),
permissionsMaintain: v.boolean(),
permissionsPull: v.boolean(),
permissionsPush: v.boolean(),
permissionsTriage: v.boolean(),
privacy: v.union(v.literal("public"), v.literal("private")),
provider: v.union(v.literal("github"), v.literal("gitea")),
providerRepositoryId: v.string(),
serverUrl: v.string(),
sourceMigrationId: v.optional(v.id("gitMigrations")),
updatedAt: v.number(),
webUrl: v.string(),
webhookState: v.union(
v.literal("none"),
v.literal("pending"),
v.literal("active"),
v.literal("failed")
),
})
.index("by_provider_and_serverUrl_and_externalRepositoryId", [
"provider",
"serverUrl",
]),
"providerRepositoryId",
])
.index("by_organizationId", ["organizationId"]),
gitMigrations: defineTable({
createdAt: v.number(),
failureReason: v.optional(v.string()),
githubConnectionId: v.id("gitConnections"),
idempotencyKey: v.string(),
includeLfs: v.boolean(),
organizationId: v.id("organizations"),
puterConnectionId: v.id("gitConnections"),
resultingRepositoryId: v.optional(v.id("gitRepositories")),
sourceIsPrivate: v.boolean(),
sourceRepositoryUrl: v.string(),
status: v.union(
v.literal("queued"),
v.literal("running"),
v.literal("succeeded"),
v.literal("failed"),
v.literal("cancelled")
),
targetOwner: v.string(),
targetRepositoryName: v.string(),
updatedAt: v.number(),
})
.index("by_organizationId", ["organizationId"])
.index("by_idempotencyKey", ["idempotencyKey"]),
gitWebhookDeliveries: defineTable({
createdAt: v.number(),
deliveryId: v.string(),
error: v.optional(v.string()),
event: v.string(),
externalRepositoryId: v.optional(v.string()),
payloadHash: v.string(),
processingState: v.union(
v.literal("received"),
v.literal("processing"),
v.literal("processed"),
v.literal("ignored"),
v.literal("failed"),
v.literal("duplicate")
),
provider: v.union(v.literal("github"), v.literal("gitea")),
repositoryRef: v.optional(v.id("gitRepositories")),
updatedAt: v.number(),
})
.index("by_provider_and_deliveryId", ["provider", "deliveryId"])
.index("by_processingState", ["processingState"]),
projects: defineTable({
createdAt: v.number(),
defaultBranch: v.optional(v.string()),
description: v.optional(v.string()),
gitConnectionId: v.optional(v.id("gitConnections")),
gitRepositoryId: v.optional(v.id("gitRepositories")),
instructions: v.optional(v.string()),
name: v.string(),
normalizedSourceUrl: v.string(),
organizationId: v.id("organizations"),
@@ -111,9 +251,11 @@ export default defineSchema({
leaseOwner: v.optional(v.string()),
status: v.union(
v.literal("queued"),
v.literal("processing"),
v.literal("dispatching"),
v.literal("running"),
v.literal("completed"),
v.literal("failed")
v.literal("failed"),
v.literal("aborted")
),
submissionId: v.optional(v.string()),
})
@@ -121,7 +263,8 @@ export default defineSchema({
"conversationId",
"clientRequestId",
])
.index("by_status_and_leaseExpiresAt", ["status", "leaseExpiresAt"]),
.index("by_status_and_leaseExpiresAt", ["status", "leaseExpiresAt"])
.index("by_submissionId", ["submissionId"]),
conversationMessages: defineTable({
content: v.string(),
conversationId: v.id("conversations"),
@@ -367,6 +510,17 @@ export default defineSchema({
workAttempts: defineTable({
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()),
leaseExpiresAt: v.optional(v.number()),
leaseOwner: v.optional(v.string()),

View File

@@ -42,7 +42,7 @@ describe("signalRouting", () => {
clientRequestId: "request-1",
conversationId,
createdAt: 1,
status: "processing",
status: "running",
});
const messageId = await ctx.db.insert("conversationMessages", {
content: "Fix the deploy",

View File

@@ -60,7 +60,7 @@ export const listEvidence = query({
continue;
}
const turn = await ctx.db.get(message.turnId);
if (!turn || !["processing", "completed"].includes(turn.status)) {
if (!turn || !["running", "completed"].includes(turn.status)) {
continue;
}
const consumed = await ctx.db
@@ -131,7 +131,7 @@ export const createSignal = mutation({
throw new ConvexError(`Source message not found: ${messageId}`);
}
const turn = await ctx.db.get(message.turnId);
if (!turn || !["processing", "completed"].includes(turn.status)) {
if (!turn || !["running", "completed"].includes(turn.status)) {
throw new ConvexError(`Source message is not admitted: ${messageId}`);
}
messages.push(message);

View File

@@ -6,6 +6,7 @@
"compilerOptions": {
/* These settings are not required by Convex and can be modified. */
"allowJs": true,
"allowImportingTsExtensions": true,
"strict": true,
"moduleResolution": "Bundler",
"jsx": "react-jsx",

View File

@@ -1,14 +1,29 @@
"use node";
import { env } from "@code/env/convex";
import { decodeWorkAttemptExecutionResult } from "@code/primitives/execution-runtime";
import {
WorkAttemptExecutionError,
decodeWorkAttemptExecutionFailure,
decodeWorkAttemptExecutionResult,
} from "@code/primitives/execution-runtime";
import { makeFunctionReference } from "convex/server";
import { ConvexError, v } from "convex/values";
import { Effect } from "effect";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { internalAction } from "./_generated/server";
import { decryptCredential } from "./gitConnections";
const getRepositoryRef = makeFunctionReference<
"query",
{ repositoryId: Id<"gitRepositories"> },
{
readonly cloneUrl: string;
readonly defaultBranch: string;
} | null
>("gitProvisioning:getRepository");
const backendUrl = () => env.AGENT_BACKEND_URL ?? env.FLUE_URL;
export const executeAttempt = internalAction({
@@ -29,6 +44,17 @@ export const executeAttempt = internalAction({
context.connection.credentialCiphertext,
context.connection.credentialIv
);
// Resolve the project's normalized repository if available; fall back to
// legacy sourceUrl for backward compatibility with pre-backfill projects.
const repository = context.project.gitRepositoryId
? await ctx.runQuery(getRepositoryRef, {
repositoryId: context.project.gitRepositoryId,
})
: null;
const repositoryUrl = repository?.cloneUrl ?? context.project.sourceUrl;
const baseBranch =
repository?.defaultBranch ?? context.project.defaultBranch ?? "main";
const response = await fetch(
`${backendUrl()}/internal/work-attempts/execute`,
{
@@ -40,9 +66,9 @@ export const executeAttempt = internalAction({
serverUrl: context.connection.serverUrl,
username: context.connection.username,
},
baseBranch: context.project.defaultBranch ?? "main",
baseBranch,
prompt: context.prompt,
repositoryUrl: context.project.sourceUrl,
repositoryUrl,
runId: String(context.run._id),
workId: String(context.work._id),
workspaceKey: context.attempt.workspaceKey,
@@ -56,23 +82,36 @@ export const executeAttempt = internalAction({
);
const payload = (await response.json()) as unknown;
if (!response.ok) {
throw new ConvexError(
typeof payload === "object" && payload && "error" in payload
? String(payload.error)
: `Agent backend returned ${response.status}`
// Decode the agent's classified failure envelope and re-throw as the
// typed runtime error so the workflow handler maps reason/retryable to
// a durable attempt classification. A malformed envelope falls back to
// an InvalidInput failure (non-retryable).
const failure = await Effect.runPromise(
decodeWorkAttemptExecutionFailure(payload)
);
throw new WorkAttemptExecutionError(failure.error);
}
return await Effect.runPromise(decodeWorkAttemptExecutionResult(payload));
},
});
export const cancelAttempt = internalAction({
args: { workspaceKey: v.string() },
args: {
attemptId: v.string(),
workspaceKey: v.string(),
},
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(
`${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",
}
);

View File

@@ -7,94 +7,116 @@ import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
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", () => {
test("records revisions, activity, diff, and terminal state atomically", async () => {
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://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 };
});
const { t, ...seeded } = await seedRunningAttempt();
await t.mutation(api.workExecutionWorkflow.completeAttempt, {
attemptId: seeded.attemptId,
result: {
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",
},
result: successResult,
});
const state = await t.run(async (ctx) => ({
artifacts: await ctx.db.query("workArtifacts").collect(),
attempt: await ctx.db.get(seeded.attemptId),
run: await ctx.db.get(seeded.runId),
work: await ctx.db.get(seeded.workId),
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("Succeeded");
expect(state.run).toMatchObject({
@@ -109,3 +131,266 @@ 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 providerAccountId = await ctx.db.insert("gitProviderAccounts", {
createdAt: 1,
externalAccountId: "ext-1",
externalUsername: "zopu",
organizationId,
provider,
serverUrl: `https://${sourceHost}`,
status: "active",
updatedAt: 1,
userId: identity.tokenIdentifier,
});
const connectionId = await ctx.db.insert("gitConnections", {
connectedAt: 1,
creatorUserId: identity.tokenIdentifier,
credentialCiphertext: "x",
credentialIv: "y",
credentialKind: provider === "github" ? "oauth" : "token",
gitProviderAccountId: providerAccountId,
lastVerifiedAt: Date.now(),
organizationId,
provider,
serverUrl: `https://${sourceHost}`,
state: "active",
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 provider/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,11 @@
import { defaultCodingKitV0 } from "@code/primitives/resolver";
import { WorkAttemptExecutionError } from "@code/primitives/execution-runtime";
import type { WorkAttemptExecutionErrorReason } from "@code/primitives/execution-runtime";
import {
CREDENTIAL_FRESHNESS_MS,
providerForHost as forgeForHost,
} from "@code/primitives/git-provider";
import { defaultCodingKitV0, resolveOutcome } from "@code/primitives/resolver";
import type { AttemptClassification } from "@code/primitives/resolver";
import { WorkflowManager } from "@convex-dev/workflow";
import type { WorkflowId } from "@convex-dev/workflow";
import { ConvexError, v } from "convex/values";
@@ -11,6 +18,64 @@ import { requireProjectMember } from "./authz";
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 (
ctx: MutationCtx,
work: Doc<"works">,
@@ -36,6 +101,48 @@ const resolveReadySlice = async (
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 provider (${expected})`
);
}
if (connection.state !== undefined && connection.state !== "active") {
throw new ConvexError(
`Git connection is ${connection.state}; verify or reconnect credentials before execution`
);
}
const now = Date.now();
if (
connection.lastVerifiedAt === undefined ||
now - connection.lastVerifiedAt > CREDENTIAL_FRESHNESS_MS
) {
throw new ConvexError(
"Git credentials have not been verified recently; run a connection health check before execution"
);
}
return connection;
};
export const execute = workflow
.define({ args: { attemptId: v.id("workAttempts") } })
.handler(async (step, args): Promise<void> => {
@@ -57,9 +164,12 @@ export const execute = workflow
},
});
} catch (error) {
const failure = toExecutionFailure(error);
await step.runMutation(internal.workExecutionWorkflow.failAttempt, {
attemptId: args.attemptId,
summary: error instanceof Error ? error.message : "Execution failed",
reason: failure.reason,
retryable: failure.retryable,
summary: failure.message,
});
}
});
@@ -91,10 +201,7 @@ export const startExecution = mutation({
) {
throw new ConvexError("Execution requires exact approved versions");
}
const project = await ctx.db.get(work.projectId);
if (!project?.gitConnectionId) {
throw new ConvexError("Connect Git credentials to this project first");
}
await validateProjectDeployment(ctx, work);
const slice = await resolveReadySlice(ctx, work, args.sliceId);
const createdAt = Date.now();
const runId = await ctx.db.insert("workRuns", {
@@ -159,6 +266,17 @@ export const executionContext = internalQuery({
if (!connection) {
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 provider (${expectedForge})`
);
}
if (connection.state !== undefined && connection.state !== "active") {
throw new ConvexError(
`Git connection is ${connection.state}; execution is blocked`
);
}
return {
attempt,
connection,
@@ -179,11 +297,16 @@ export const markAttemptRunning = internalMutation({
args: { attemptId: v.id("workAttempts") },
handler: async (ctx, args) => {
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;
}
await ctx.db.patch(attempt._id, {
startedAt: Date.now(),
leaseExpiresAt: Date.now() + LEASE_MS,
startedAt: attempt.startedAt ?? Date.now(),
status: "running",
});
return true;
@@ -193,7 +316,8 @@ export const markAttemptRunning = internalMutation({
const settleSliceAndWork = async (
ctx: MutationCtx,
run: Doc<"workRuns">,
succeeded: boolean
succeeded: boolean,
workStatus: Doc<"works">["status"]
) => {
if (!run.sliceRowId) {
return;
@@ -202,12 +326,18 @@ const settleSliceAndWork = async (
if (!slice) {
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);
if (!work) {
return;
}
let status: Doc<"works">["status"] = succeeded ? "completed" : "failed";
let status = workStatus;
if (succeeded) {
const slices = await ctx.db
.query("workSlices")
@@ -249,6 +379,8 @@ export const completeAttempt = internalMutation({
},
handler: async (ctx, args) => {
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") {
return;
}
@@ -257,6 +389,33 @@ export const completeAttempt = internalMutation({
if (!run || !work) {
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) {
await ctx.db.insert("workAttemptEvents", {
attemptId: attempt._id,
@@ -271,6 +430,7 @@ export const completeAttempt = internalMutation({
await ctx.db.patch(attempt._id, {
classification: "Succeeded",
endedAt,
leaseExpiresAt: undefined,
status: "terminal",
summary: args.result.summary,
});
@@ -292,7 +452,7 @@ export const completeAttempt = internalMutation({
kind: "diff",
metadataJson: JSON.stringify({ changedFiles: args.result.changedFiles }),
organizationId: work.organizationId,
producer: "agentos-codex",
producer: "agentos-pi",
projectId: work.projectId,
provenanceJson: JSON.stringify({
baseRevision: args.result.baseRevision,
@@ -309,7 +469,7 @@ export const completeAttempt = internalMutation({
}
: {}),
});
await settleSliceAndWork(ctx, run, true);
await settleSliceAndWork(ctx, run, true, "completed");
await ctx.db.insert("workEvents", {
createdAt: endedAt,
idempotencyKey: `real-run-completed:${run._id}`,
@@ -321,31 +481,107 @@ export const completeAttempt = internalMutation({
},
});
export const failAttempt = internalMutation({
args: { attemptId: v.id("workAttempts"), summary: v.string() },
export const launchAttemptWorkflow = internalMutation({
args: { attemptId: v.id("workAttempts") },
handler: async (ctx, args) => {
const attempt = await ctx.db.get(args.attemptId);
if (!attempt || attempt.status === "terminal") {
return;
}
const run = await ctx.db.get(attempt.runId);
if (!run) {
if (!run || run.status !== "running") {
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();
await ctx.db.patch(attempt._id, {
classification: "PermanentFailure",
classification,
endedAt,
failureReason: args.reason,
leaseExpiresAt: undefined,
status: "terminal",
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, {
endedAt,
status: "terminal",
terminalClassification: "PermanentFailure",
terminalClassification: classification,
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 +611,30 @@ export const cancelExecution = mutation({
0,
internal.workExecutionAgent.cancelAttempt,
{
attemptId: String(attempt._id),
workspaceKey: attempt.workspaceKey,
}
);
const cancelledAt = Date.now();
await ctx.db.patch(attempt._id, {
classification: "Cancelled",
endedAt: Date.now(),
endedAt: cancelledAt,
failureReason: "Cancelled",
leaseExpiresAt: undefined,
status: "terminal",
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, {
endedAt: Date.now(),

View File

@@ -20,7 +20,7 @@ import {
query,
} from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { requireProjectMember } from "./authz";
import { requireCurrentOrganization, requireProjectMember } from "./authz";
const plannerRef = makeFunctionReference<
"action",
@@ -868,3 +868,32 @@ export const listForProject = query({
);
},
});
export const listForOrganization = query({
args: {},
handler: async (ctx) => {
const { organizationId } = await requireCurrentOrganization(ctx);
const works = await ctx.db
.query("works")
.withIndex("by_organization_and_createdAt", (q) =>
q.eq("organizationId", organizationId)
)
.order("desc")
.take(100);
return await Promise.all(
works.map(async (work) => {
const project = await ctx.db.get(work.projectId);
return {
...work,
project: project
? {
id: String(project._id),
name: project.name,
}
: null,
repositoryReady: Boolean(project?.gitRepositoryId),
};
})
);
},
});

Some files were not shown because too many files have changed in this diff Show More